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.
This commit is contained in:
kikootwo
2026-03-13 12:41:07 -04:00
parent c8bfcdb611
commit 4322c3af90
17 changed files with 68 additions and 105 deletions
@@ -0,0 +1,2 @@
-- AlterTable - Add sessions_invalidated_at column for immediate session revocation
ALTER TABLE "users" ADD COLUMN "sessions_invalidated_at" TIMESTAMPTZ;
+3
View File
@@ -60,6 +60,9 @@ model User {
// Login token (admin-generated, for direct URL login) // Login token (admin-generated, for direct URL login)
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext) 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 // Soft delete support
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; 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'); const logger = RMABLogger.create('API.Admin.ApiTokens');
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; 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 { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
import { generateApiToken } from '@/lib/utils/api-token'; import { generateApiToken } from '@/lib/utils/api-token';
import { z } from 'zod'; import { z } from 'zod';
@@ -79,7 +79,7 @@ export async function DELETE(
await prisma.user.update({ await prisma.user.update({
where: { id }, where: { id },
data: { loginTokenHash: null }, data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() },
}); });
logger.info('Admin revoked login token for user', { logger.info('Admin revoked login token for user', {
+22 -1
View File
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
// Get user from database // Get user from database
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: payload.sub }, 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( return NextResponse.json(
{ {
error: 'Unauthorized', 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 // Generate new access token
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: user.id, sub: user.id,
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit'; import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit';
import crypto from 'crypto'; import crypto from 'crypto';
const logger = RMABLogger.create('API.Auth.TokenLogin'); const logger = RMABLogger.create('API.Auth.TokenLogin');
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; 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'); const logger = RMABLogger.create('API.User.ApiTokens');
+1 -1
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; 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 { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
import { generateApiToken } from '@/lib/utils/api-token'; import { generateApiToken } from '@/lib/utils/api-token';
import { z } from 'zod'; import { z } from 'zod';
+3
View File
@@ -22,6 +22,9 @@ function TokenLoginContent() {
return; return;
} }
// Scrub token from browser URL/history immediately after extraction
window.history.replaceState({}, '', '/auth/token/login');
fetch('/api/auth/token/login', { fetch('/api/auth/token/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
+14
View File
@@ -172,6 +172,7 @@ export async function requireAuth(
select: { select: {
id: true, id: true,
deletedAt: 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 // Add user to request
const authenticatedRequest = request as AuthenticatedRequest; const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.user = { authenticatedRequest.user = {
-92
View File
@@ -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<string, Bucket>();
/** 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;
}
+2
View File
@@ -20,11 +20,13 @@ export interface TokenPayload {
plexId: string; plexId: string;
username: string; username: string;
role: string; role: string;
iat?: number; // Issued-at (auto-set by jsonwebtoken)
} }
export interface RefreshTokenPayload { export interface RefreshTokenPayload {
sub: string; sub: string;
type: 'refresh'; type: 'refresh';
iat?: number; // Issued-at (auto-set by jsonwebtoken)
} }
/** /**
@@ -1,5 +1,5 @@
/** /**
* Component: Auth Rate Limiting * Component: Rate Limiting
* Documentation: documentation/backend/services/auth.md * Documentation: documentation/backend/services/auth.md
* *
* In-memory fixed-window rate limiter with lazy eviction and periodic sweep * In-memory fixed-window rate limiter with lazy eviction and periodic sweep
@@ -11,7 +11,7 @@ type Bucket = {
resetAt: number; resetAt: number;
}; };
type RateLimitResult = { export type RateLimitResult = {
allowed: boolean; allowed: boolean;
retryAfterSeconds: number; 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(); const now = Date.now();
// Periodic full sweep every SWEEP_INTERVAL calls // 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 */ /** 10 attempts per 15 minutes per IP */
export function checkTokenLoginRateLimit(ip: string): RateLimitResult { export function checkTokenLoginRateLimit(ip: string): RateLimitResult {
return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000); return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000);
+1 -1
View File
@@ -29,7 +29,7 @@ vi.mock('@/lib/middleware/auth', () => ({
requireAdmin: requireAdminMock, requireAdmin: requireAdminMock,
})); }));
vi.mock('@/lib/utils/apiTokenRateLimit', () => ({ vi.mock('@/lib/utils/rateLimit', () => ({
checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock, checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock,
})); }));
+1 -1
View File
@@ -20,7 +20,7 @@ vi.mock('@/lib/utils/jwt', () => ({
generateRefreshToken: generateRefreshTokenMock, generateRefreshToken: generateRefreshTokenMock,
})); }));
vi.mock('@/lib/utils/authRateLimit', () => ({ vi.mock('@/lib/utils/rateLimit', () => ({
checkTokenLoginRateLimit: checkTokenLoginRateLimitMock, checkTokenLoginRateLimit: checkTokenLoginRateLimitMock,
})); }));
+1 -1
View File
@@ -9,7 +9,7 @@ import {
checkApiTokenRevokeRateLimit, checkApiTokenRevokeRateLimit,
_resetBuckets, _resetBuckets,
_getBucketCount, _getBucketCount,
} from '@/lib/utils/apiTokenRateLimit'; } from '@/lib/utils/rateLimit';
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
describe('API Token Rate Limiting', () => { describe('API Token Rate Limiting', () => {