mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Harden API token auth for deleted users and add route rate limiting
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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