From 4322c3af906fd05866b975f879893a10002300c2 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 13 Mar 2026 12:41:07 -0400 Subject: [PATCH] Add session revocation & consolidate rate limiting Add sessions_invalidated_at to users (migration + Prisma schema) to support immediate session revocation. Set sessionsInvalidatedAt when an admin revokes a user's login token and enforce revocation checks in auth middleware and the refresh endpoint (compare token iat against sessionsInvalidatedAt). Add optional iat fields to JWT payload types. Scrub token from browser history after token-login. Consolidate rate-limiting logic into src/lib/utils/rateLimit.ts (rename/merge previous auth/apiToken rate limiter implementations), remove the old apiTokenRateLimit.ts, and update imports and tests to use the new module. --- .../migration.sql | 2 + prisma/schema.prisma | 3 + src/app/api/admin/api-tokens/[id]/route.ts | 2 +- src/app/api/admin/api-tokens/route.ts | 2 +- .../api/admin/users/[id]/login-token/route.ts | 2 +- src/app/api/auth/refresh/route.ts | 23 ++++- src/app/api/auth/token/login/route.ts | 2 +- src/app/api/user/api-tokens/[id]/route.ts | 2 +- src/app/api/user/api-tokens/route.ts | 2 +- src/app/auth/token/login/page.tsx | 3 + src/lib/middleware/auth.ts | 14 +++ src/lib/utils/apiTokenRateLimit.ts | 92 ------------------- src/lib/utils/jwt.ts | 2 + .../utils/{authRateLimit.ts => rateLimit.ts} | 16 +++- tests/api/admin-api-tokens.routes.test.ts | 2 +- tests/api/auth-login-token.routes.test.ts | 2 +- tests/utils/apiTokenRateLimit.test.ts | 2 +- 17 files changed, 68 insertions(+), 105 deletions(-) create mode 100644 prisma/migrations/20260313000000_add_sessions_invalidated_at/migration.sql delete mode 100644 src/lib/utils/apiTokenRateLimit.ts rename src/lib/utils/{authRateLimit.ts => rateLimit.ts} (79%) diff --git a/prisma/migrations/20260313000000_add_sessions_invalidated_at/migration.sql b/prisma/migrations/20260313000000_add_sessions_invalidated_at/migration.sql new file mode 100644 index 0000000..17ecbe0 --- /dev/null +++ b/prisma/migrations/20260313000000_add_sessions_invalidated_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable - Add sessions_invalidated_at column for immediate session revocation +ALTER TABLE "users" ADD COLUMN "sessions_invalidated_at" TIMESTAMPTZ; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a613d62..c3ee742 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,9 @@ model User { // Login token (admin-generated, for direct URL login) loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext) + // Session invalidation (set when login token is revoked to force-logout active sessions) + sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at") + // Soft delete support deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Admin user ID who deleted this user diff --git a/src/app/api/admin/api-tokens/[id]/route.ts b/src/app/api/admin/api-tokens/[id]/route.ts index e0b0e7a..f55f34c 100644 --- a/src/app/api/admin/api-tokens/[id]/route.ts +++ b/src/app/api/admin/api-tokens/[id]/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; +import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit'; const logger = RMABLogger.create('API.Admin.ApiTokens'); diff --git a/src/app/api/admin/api-tokens/route.ts b/src/app/api/admin/api-tokens/route.ts index 4dd11e7..99a6b9d 100644 --- a/src/app/api/admin/api-tokens/route.ts +++ b/src/app/api/admin/api-tokens/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; +import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; import { generateApiToken } from '@/lib/utils/api-token'; import { z } from 'zod'; diff --git a/src/app/api/admin/users/[id]/login-token/route.ts b/src/app/api/admin/users/[id]/login-token/route.ts index 05fb69d..c1b85f0 100644 --- a/src/app/api/admin/users/[id]/login-token/route.ts +++ b/src/app/api/admin/users/[id]/login-token/route.ts @@ -79,7 +79,7 @@ export async function DELETE( await prisma.user.update({ where: { id }, - data: { loginTokenHash: null }, + data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() }, }); logger.info('Admin revoked login token for user', { diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index f5875c0..f593c7b 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -45,9 +45,17 @@ export async function POST(request: NextRequest) { // Get user from database const user = await prisma.user.findUnique({ where: { id: payload.sub }, + select: { + id: true, + plexId: true, + plexUsername: true, + role: true, + deletedAt: true, + sessionsInvalidatedAt: true, + }, }); - if (!user) { + if (!user || user.deletedAt) { return NextResponse.json( { error: 'Unauthorized', @@ -57,6 +65,19 @@ export async function POST(request: NextRequest) { ); } + // Check if session was invalidated after this refresh token was issued + if (user.sessionsInvalidatedAt && payload.iat && + payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) { + logger.warn('Refresh token issued before session invalidation', { userId: payload.sub }); + return NextResponse.json( + { + error: 'Unauthorized', + message: 'Session has been revoked', + }, + { status: 401 } + ); + } + // Generate new access token const accessToken = generateAccessToken({ sub: user.id, diff --git a/src/app/api/auth/token/login/route.ts b/src/app/api/auth/token/login/route.ts index f5f7c42..ea1591e 100644 --- a/src/app/api/auth/token/login/route.ts +++ b/src/app/api/auth/token/login/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit'; +import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit'; import crypto from 'crypto'; const logger = RMABLogger.create('API.Auth.TokenLogin'); diff --git a/src/app/api/user/api-tokens/[id]/route.ts b/src/app/api/user/api-tokens/[id]/route.ts index 4169218..4e7fcc4 100644 --- a/src/app/api/user/api-tokens/[id]/route.ts +++ b/src/app/api/user/api-tokens/[id]/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; +import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit'; const logger = RMABLogger.create('API.User.ApiTokens'); diff --git a/src/app/api/user/api-tokens/route.ts b/src/app/api/user/api-tokens/route.ts index f3902e4..51fe1d2 100644 --- a/src/app/api/user/api-tokens/route.ts +++ b/src/app/api/user/api-tokens/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; +import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; import { generateApiToken } from '@/lib/utils/api-token'; import { z } from 'zod'; diff --git a/src/app/auth/token/login/page.tsx b/src/app/auth/token/login/page.tsx index 9b57aad..bf8b91f 100644 --- a/src/app/auth/token/login/page.tsx +++ b/src/app/auth/token/login/page.tsx @@ -22,6 +22,9 @@ function TokenLoginContent() { return; } + // Scrub token from browser URL/history immediately after extraction + window.history.replaceState({}, '', '/auth/token/login'); + fetch('/api/auth/token/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index 7f6efd5..8ab4410 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -172,6 +172,7 @@ export async function requireAuth( select: { id: true, deletedAt: true, + sessionsInvalidatedAt: true, }, }); @@ -186,6 +187,19 @@ export async function requireAuth( ); } + // Check if session was invalidated after this token was issued + if (user.sessionsInvalidatedAt && payload.iat && + payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) { + logger.warn('Token issued before session invalidation', { userId: payload.sub }); + return NextResponse.json( + { + error: 'Unauthorized', + message: 'Session has been revoked', + }, + { status: 401 } + ); + } + // Add user to request const authenticatedRequest = request as AuthenticatedRequest; authenticatedRequest.user = { diff --git a/src/lib/utils/apiTokenRateLimit.ts b/src/lib/utils/apiTokenRateLimit.ts deleted file mode 100644 index 86b11fc..0000000 --- a/src/lib/utils/apiTokenRateLimit.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Component: API Token Rate Limiting - * Documentation: documentation/backend/services/api-tokens.md - * - * In-memory sliding-window rate limiter with lazy eviction and periodic sweep - * to prevent unbounded memory growth. - */ - -type Bucket = { - count: number; - resetAt: number; -}; - -type RateLimitResult = { - allowed: boolean; - retryAfterSeconds: number; -}; - -const buckets = new Map(); - -/** Number of checkRateLimit calls since the last full sweep */ -let checkCount = 0; - -/** How often (in calls) to perform a full sweep of expired buckets */ -const SWEEP_INTERVAL = 100; - -/** - * Sweep the entire bucket map and delete all expired entries. - * Called automatically every SWEEP_INTERVAL checks. - */ -function sweepExpiredBuckets(): void { - const now = Date.now(); - for (const [key, bucket] of buckets) { - if (now >= bucket.resetAt) { - buckets.delete(key); - } - } -} - -function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { - const now = Date.now(); - - // Periodic full sweep every SWEEP_INTERVAL calls - checkCount += 1; - if (checkCount >= SWEEP_INTERVAL) { - checkCount = 0; - sweepExpiredBuckets(); - } - - const current = buckets.get(key); - - // Lazy eviction: if the bucket is expired, delete it and start fresh - if (!current || now >= current.resetAt) { - if (current) { - buckets.delete(key); - } - buckets.set(key, { count: 1, resetAt: now + windowMs }); - return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) }; - } - - if (current.count >= maxRequests) { - return { - allowed: false, - retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), - }; - } - - current.count += 1; - return { - allowed: true, - retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)), - }; -} - -export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult { - return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000); -} - -export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult { - return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000); -} - -/** Reset all buckets and the sweep counter. For testing only. */ -export function _resetBuckets(): void { - buckets.clear(); - checkCount = 0; -} - -/** Get the current number of tracked buckets. For testing only. */ -export function _getBucketCount(): number { - return buckets.size; -} diff --git a/src/lib/utils/jwt.ts b/src/lib/utils/jwt.ts index 657423e..26078d0 100644 --- a/src/lib/utils/jwt.ts +++ b/src/lib/utils/jwt.ts @@ -20,11 +20,13 @@ export interface TokenPayload { plexId: string; username: string; role: string; + iat?: number; // Issued-at (auto-set by jsonwebtoken) } export interface RefreshTokenPayload { sub: string; type: 'refresh'; + iat?: number; // Issued-at (auto-set by jsonwebtoken) } /** diff --git a/src/lib/utils/authRateLimit.ts b/src/lib/utils/rateLimit.ts similarity index 79% rename from src/lib/utils/authRateLimit.ts rename to src/lib/utils/rateLimit.ts index 0d0a56c..ff65c68 100644 --- a/src/lib/utils/authRateLimit.ts +++ b/src/lib/utils/rateLimit.ts @@ -1,5 +1,5 @@ /** - * Component: Auth Rate Limiting + * Component: Rate Limiting * Documentation: documentation/backend/services/auth.md * * In-memory fixed-window rate limiter with lazy eviction and periodic sweep @@ -11,7 +11,7 @@ type Bucket = { resetAt: number; }; -type RateLimitResult = { +export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number; }; @@ -37,7 +37,7 @@ function sweepExpiredBuckets(): void { } } -function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { +export function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { const now = Date.now(); // Periodic full sweep every SWEEP_INTERVAL calls @@ -72,6 +72,16 @@ function checkRateLimit(key: string, maxRequests: number, windowMs: number): Rat }; } +/** 10 attempts per minute per actor */ +export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult { + return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000); +} + +/** 20 attempts per minute per actor */ +export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult { + return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000); +} + /** 10 attempts per 15 minutes per IP */ export function checkTokenLoginRateLimit(ip: string): RateLimitResult { return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000); diff --git a/tests/api/admin-api-tokens.routes.test.ts b/tests/api/admin-api-tokens.routes.test.ts index c7865b8..87ff7c6 100644 --- a/tests/api/admin-api-tokens.routes.test.ts +++ b/tests/api/admin-api-tokens.routes.test.ts @@ -29,7 +29,7 @@ vi.mock('@/lib/middleware/auth', () => ({ requireAdmin: requireAdminMock, })); -vi.mock('@/lib/utils/apiTokenRateLimit', () => ({ +vi.mock('@/lib/utils/rateLimit', () => ({ checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock, })); diff --git a/tests/api/auth-login-token.routes.test.ts b/tests/api/auth-login-token.routes.test.ts index 12015a7..e5bf3df 100644 --- a/tests/api/auth-login-token.routes.test.ts +++ b/tests/api/auth-login-token.routes.test.ts @@ -20,7 +20,7 @@ vi.mock('@/lib/utils/jwt', () => ({ generateRefreshToken: generateRefreshTokenMock, })); -vi.mock('@/lib/utils/authRateLimit', () => ({ +vi.mock('@/lib/utils/rateLimit', () => ({ checkTokenLoginRateLimit: checkTokenLoginRateLimitMock, })); diff --git a/tests/utils/apiTokenRateLimit.test.ts b/tests/utils/apiTokenRateLimit.test.ts index 2c60583..49631da 100644 --- a/tests/utils/apiTokenRateLimit.test.ts +++ b/tests/utils/apiTokenRateLimit.test.ts @@ -9,7 +9,7 @@ import { checkApiTokenRevokeRateLimit, _resetBuckets, _getBucketCount, -} from '@/lib/utils/apiTokenRateLimit'; +} from '@/lib/utils/rateLimit'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; describe('API Token Rate Limiting', () => {