From 04b6a2c135ff7d78a5e792be42a0d869aace381c Mon Sep 17 00:00:00 2001 From: Michael Borohovski Date: Tue, 3 Mar 2026 15:16:03 -0800 Subject: [PATCH] Harden API token auth for deleted users and add route rate limiting --- src/app/api/admin/api-tokens/[id]/route.ts | 14 ++++++++ src/app/api/admin/api-tokens/route.ts | 14 ++++++++ src/app/api/user/api-tokens/[id]/route.ts | 14 ++++++++ src/app/api/user/api-tokens/route.ts | 14 ++++++++ src/lib/middleware/auth.ts | 29 +++++++++++++-- src/lib/utils/apiTokenRateLimit.ts | 42 ++++++++++++++++++++++ 6 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/lib/utils/apiTokenRateLimit.ts diff --git a/src/app/api/admin/api-tokens/[id]/route.ts b/src/app/api/admin/api-tokens/[id]/route.ts index cfe7b38..e0b0e7a 100644 --- a/src/app/api/admin/api-tokens/[id]/route.ts +++ b/src/app/api/admin/api-tokens/[id]/route.ts @@ -7,6 +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'; const logger = RMABLogger.create('API.Admin.ApiTokens'); @@ -21,6 +22,19 @@ export async function DELETE( return requireAuth(request, (req: AuthenticatedRequest) => requireAdmin(req, async () => { try { + const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token revoke attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const { id } = await params; const token = await prisma.apiToken.findUnique({ where: { id } }); diff --git a/src/app/api/admin/api-tokens/route.ts b/src/app/api/admin/api-tokens/route.ts index 34bf58f..b28c826 100644 --- a/src/app/api/admin/api-tokens/route.ts +++ b/src/app/api/admin/api-tokens/route.ts @@ -8,6 +8,7 @@ import crypto from 'crypto'; 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 { z } from 'zod'; const logger = RMABLogger.create('API.Admin.ApiTokens'); @@ -74,6 +75,19 @@ export async function POST(request: NextRequest) { return requireAuth(request, (req: AuthenticatedRequest) => requireAdmin(req, async () => { try { + const rateLimit = checkApiTokenCreateRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token create attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const body = await req.json(); const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body); diff --git a/src/app/api/user/api-tokens/[id]/route.ts b/src/app/api/user/api-tokens/[id]/route.ts index 771113c..4169218 100644 --- a/src/app/api/user/api-tokens/[id]/route.ts +++ b/src/app/api/user/api-tokens/[id]/route.ts @@ -7,6 +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'; const logger = RMABLogger.create('API.User.ApiTokens'); @@ -20,6 +21,19 @@ export async function DELETE( ) { return requireAuth(request, async (req: AuthenticatedRequest) => { try { + const rateLimit = checkApiTokenRevokeRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token revoke attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const { id } = await params; const token = await prisma.apiToken.findUnique({ where: { id } }); diff --git a/src/app/api/user/api-tokens/route.ts b/src/app/api/user/api-tokens/route.ts index 8ffa8af..234b54f 100644 --- a/src/app/api/user/api-tokens/route.ts +++ b/src/app/api/user/api-tokens/route.ts @@ -8,6 +8,7 @@ import crypto from 'crypto'; 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 { z } from 'zod'; const logger = RMABLogger.create('API.User.ApiTokens'); @@ -57,6 +58,19 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { try { + const rateLimit = checkApiTokenCreateRateLimit(req.user!.id); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many API token create attempts. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfterSeconds), + }, + } + ); + } + const body = await req.json(); const { name, expiresAt } = CreateTokenSchema.parse(body); diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index b42940d..c1e7f98 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -45,7 +45,17 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id const apiToken = await prisma.apiToken.findUnique({ where: { tokenHash }, - include: { tokenUser: { select: { id: true, plexId: true, plexUsername: true, role: true } } }, + include: { + tokenUser: { + select: { + id: true, + plexId: true, + plexUsername: true, + role: true, + deletedAt: true, + }, + }, + }, }); if (!apiToken) return null; @@ -56,6 +66,16 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id return null; } + // Reject tokens for soft-deleted users + const user = apiToken.tokenUser; + if (!user || user.deletedAt) { + logger.warn('API token used by deleted or missing user', { + tokenPrefix: apiToken.tokenPrefix, + userId: user?.id, + }); + return null; + } + // Update lastUsedAt (fire-and-forget) prisma.apiToken.update({ where: { id: apiToken.id }, @@ -63,7 +83,6 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id }).catch(() => {}); // Use the token's target user (userId), not the creator (createdById) - const user = apiToken.tokenUser; return { sub: user.id, id: user.id, @@ -130,9 +149,13 @@ export async function requireAuth( // Verify user still exists in database const user = await prisma.user.findUnique({ where: { id: payload.sub }, + select: { + id: true, + deletedAt: true, + }, }); - if (!user) { + if (!user || user.deletedAt) { logger.error('User not found in database', { userId: payload.sub }); return NextResponse.json( { diff --git a/src/lib/utils/apiTokenRateLimit.ts b/src/lib/utils/apiTokenRateLimit.ts new file mode 100644 index 0000000..9d01e2c --- /dev/null +++ b/src/lib/utils/apiTokenRateLimit.ts @@ -0,0 +1,42 @@ +type Bucket = { + count: number; + resetAt: number; +}; + +type RateLimitResult = { + allowed: boolean; + retryAfterSeconds: number; +}; + +const buckets = new Map(); + +function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult { + const now = Date.now(); + const current = buckets.get(key); + + if (!current || now >= current.resetAt) { + 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); +}