mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Harden API token auth for deleted users and add route rate limiting
This commit is contained in:
@@ -7,6 +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';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||||
|
|
||||||
@@ -21,6 +22,19 @@ export async function DELETE(
|
|||||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||||
requireAdmin(req, async () => {
|
requireAdmin(req, async () => {
|
||||||
try {
|
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 { id } = await params;
|
||||||
|
|
||||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import crypto from 'crypto';
|
|||||||
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 { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||||
@@ -74,6 +75,19 @@ export async function POST(request: NextRequest) {
|
|||||||
return requireAuth(request, (req: AuthenticatedRequest) =>
|
return requireAuth(request, (req: AuthenticatedRequest) =>
|
||||||
requireAdmin(req, async () => {
|
requireAdmin(req, async () => {
|
||||||
try {
|
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 body = await req.json();
|
||||||
const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body);
|
const { name, expiresAt, userId, role } = CreateTokenSchema.parse(body);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +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';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||||
|
|
||||||
@@ -20,6 +21,19 @@ export async function DELETE(
|
|||||||
) {
|
) {
|
||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
try {
|
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 { id } = await params;
|
||||||
|
|
||||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import crypto from 'crypto';
|
|||||||
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 { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.User.ApiTokens');
|
const logger = RMABLogger.create('API.User.ApiTokens');
|
||||||
@@ -57,6 +58,19 @@ export async function GET(request: NextRequest) {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
try {
|
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 body = await req.json();
|
||||||
const { name, expiresAt } = CreateTokenSchema.parse(body);
|
const { name, expiresAt } = CreateTokenSchema.parse(body);
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,17 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id
|
|||||||
|
|
||||||
const apiToken = await prisma.apiToken.findUnique({
|
const apiToken = await prisma.apiToken.findUnique({
|
||||||
where: { tokenHash },
|
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;
|
if (!apiToken) return null;
|
||||||
@@ -56,6 +66,16 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id
|
|||||||
return null;
|
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)
|
// Update lastUsedAt (fire-and-forget)
|
||||||
prisma.apiToken.update({
|
prisma.apiToken.update({
|
||||||
where: { id: apiToken.id },
|
where: { id: apiToken.id },
|
||||||
@@ -63,7 +83,6 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
// Use the token's target user (userId), not the creator (createdById)
|
// Use the token's target user (userId), not the creator (createdById)
|
||||||
const user = apiToken.tokenUser;
|
|
||||||
return {
|
return {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -130,9 +149,13 @@ export async function requireAuth(
|
|||||||
// Verify user still exists in database
|
// Verify user still exists in database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
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 });
|
logger.error('User not found in database', { userId: payload.sub });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
type Bucket = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateLimitResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
retryAfterSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user