mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
4322c3af90
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.
406 lines
10 KiB
TypeScript
406 lines
10 KiB
TypeScript
/**
|
|
* Component: Authentication Middleware
|
|
* Documentation: documentation/backend/services/auth.md
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import crypto from 'crypto';
|
|
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
|
import { prisma } from '../db';
|
|
import { RMABLogger } from '../utils/logger';
|
|
import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
|
|
|
|
const logger = RMABLogger.create('Auth');
|
|
|
|
export interface AuthenticatedRequest extends NextRequest {
|
|
user?: TokenPayload & { id: string };
|
|
}
|
|
|
|
/**
|
|
* Extract token from Authorization header
|
|
*/
|
|
function extractToken(request: NextRequest): string | null {
|
|
const authHeader = request.headers.get('authorization');
|
|
|
|
if (!authHeader) {
|
|
return null;
|
|
}
|
|
|
|
const parts = authHeader.split(' ');
|
|
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
|
return null;
|
|
}
|
|
|
|
return parts[1];
|
|
}
|
|
|
|
/**
|
|
* Authenticate via static API token (rmab_ prefix).
|
|
* Returns a synthetic TokenPayload if valid, null otherwise.
|
|
* Updates lastUsedAt asynchronously.
|
|
*/
|
|
async function authenticateApiToken(token: string): Promise<TokenPayload | null> {
|
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
|
|
|
const apiToken = await prisma.apiToken.findUnique({
|
|
where: { tokenHash },
|
|
include: {
|
|
tokenUser: {
|
|
select: {
|
|
id: true,
|
|
plexId: true,
|
|
plexUsername: true,
|
|
role: true,
|
|
deletedAt: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!apiToken) return null;
|
|
|
|
// Check expiration
|
|
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
|
|
logger.warn('API token expired', { tokenPrefix: apiToken.tokenPrefix });
|
|
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 },
|
|
data: { lastUsedAt: new Date() },
|
|
}).catch((err) => {
|
|
logger.debug('Failed to update API token lastUsedAt', {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
tokenId: apiToken.id,
|
|
});
|
|
});
|
|
|
|
// Use the token's target user (userId), not the creator (createdById)
|
|
return {
|
|
sub: user.id,
|
|
plexId: user.plexId,
|
|
username: user.plexUsername,
|
|
role: apiToken.role,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require authentication
|
|
* Verifies JWT token or static API token and adds user to request
|
|
*/
|
|
export async function requireAuth(
|
|
request: NextRequest,
|
|
handler: (request: AuthenticatedRequest) => Promise<NextResponse>
|
|
): Promise<NextResponse> {
|
|
const token = extractToken(request);
|
|
|
|
if (!token) {
|
|
logger.error('No token provided');
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Unauthorized',
|
|
message: 'No authentication token provided',
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Check if this is a static API token
|
|
if (token.startsWith(API_TOKEN_PREFIX)) {
|
|
const apiUser = await authenticateApiToken(token);
|
|
if (!apiUser) {
|
|
logger.error('API token authentication failed');
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Unauthorized',
|
|
message: 'Invalid or expired API token',
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Enforce endpoint allowlist for API token auth
|
|
const pathname = request.nextUrl.pathname;
|
|
const method = request.method;
|
|
if (!isEndpointAllowed(method, pathname)) {
|
|
logger.warn('API token used on restricted endpoint', {
|
|
method,
|
|
path: pathname,
|
|
});
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Forbidden',
|
|
message: 'This endpoint is not available via API token authentication',
|
|
},
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const authenticatedRequest = request as AuthenticatedRequest;
|
|
authenticatedRequest.user = { ...apiUser, id: apiUser.sub };
|
|
return handler(authenticatedRequest);
|
|
}
|
|
|
|
// Fall back to JWT verification
|
|
const payload = verifyAccessToken(token);
|
|
|
|
if (!payload) {
|
|
logger.error('Token verification failed');
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Unauthorized',
|
|
message: 'Invalid or expired token',
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Verify user still exists in database
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: payload.sub },
|
|
select: {
|
|
id: true,
|
|
deletedAt: true,
|
|
sessionsInvalidatedAt: true,
|
|
},
|
|
});
|
|
|
|
if (!user || user.deletedAt) {
|
|
logger.error('User not found in database', { userId: payload.sub });
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Unauthorized',
|
|
message: 'User not found',
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// 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 = {
|
|
...payload,
|
|
id: payload.sub,
|
|
};
|
|
|
|
return handler(authenticatedRequest);
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require admin role
|
|
* Must be chained after requireAuth
|
|
*/
|
|
export async function requireAdmin(
|
|
request: AuthenticatedRequest,
|
|
handler: (request: AuthenticatedRequest) => Promise<NextResponse>
|
|
): Promise<NextResponse> {
|
|
if (!request.user) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Unauthorized',
|
|
message: 'Authentication required',
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
if (request.user.role !== 'admin') {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Forbidden',
|
|
message: 'Admin access required',
|
|
},
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
return handler(request);
|
|
}
|
|
|
|
/**
|
|
* Helper: Get current user from request (for use in API routes)
|
|
*/
|
|
export function getCurrentUser(request: NextRequest): TokenPayload | null {
|
|
const token = extractToken(request);
|
|
if (!token) return null;
|
|
return verifyAccessToken(token);
|
|
}
|
|
|
|
/**
|
|
* Helper: Check if user is admin
|
|
*/
|
|
export function isAdmin(user: TokenPayload | null): boolean {
|
|
return user?.role === 'admin';
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require local admin (setup admin)
|
|
* Must be chained after requireAuth
|
|
* Only allows local admin users (created during setup with username/password)
|
|
*/
|
|
export async function requireLocalAdmin(
|
|
request: AuthenticatedRequest,
|
|
handler: (request: AuthenticatedRequest) => Promise<NextResponse>
|
|
): Promise<NextResponse> {
|
|
if (!request.user) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Unauthorized',
|
|
message: 'Authentication required',
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Verify user is admin
|
|
if (request.user.role !== 'admin') {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Forbidden',
|
|
message: 'Admin access required',
|
|
},
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
// Fetch user from database to check isSetupAdmin flag
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: request.user.id },
|
|
select: {
|
|
isSetupAdmin: true,
|
|
plexId: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Unauthorized',
|
|
message: 'User not found',
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
// Check if user is local admin (setup admin with local authentication)
|
|
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
|
|
|
|
if (!isLocalAdmin) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Forbidden',
|
|
message: 'This action is only available to the local admin account',
|
|
},
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
return handler(request);
|
|
}
|
|
|
|
/**
|
|
* Helper: Check if user is local admin (setup admin with local authentication)
|
|
*/
|
|
export async function isLocalAdmin(userId: string): Promise<boolean> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
isSetupAdmin: true,
|
|
plexId: true,
|
|
},
|
|
});
|
|
|
|
if (!user) return false;
|
|
|
|
return user.isSetupAdmin && user.plexId.startsWith('local-');
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require setup to be incomplete
|
|
* Blocks access to setup-only endpoints after initial setup is finished.
|
|
* Returns 403 if setup is already complete, otherwise invokes the handler.
|
|
*/
|
|
export async function requireSetupIncomplete(
|
|
request: NextRequest,
|
|
handler: (request: NextRequest) => Promise<NextResponse>
|
|
): Promise<NextResponse> {
|
|
try {
|
|
const config = await prisma.configuration.findUnique({
|
|
where: { key: 'setup_completed' },
|
|
});
|
|
|
|
if (config?.value === 'true') {
|
|
logger.warn('Setup endpoint called after setup is complete', {
|
|
path: request.nextUrl.pathname,
|
|
});
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Forbidden',
|
|
message: 'Setup has already been completed',
|
|
},
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
} catch {
|
|
// If database is not ready, setup is definitely not complete — allow through
|
|
}
|
|
|
|
return handler(request);
|
|
}
|
|
|
|
/**
|
|
* Middleware: Require setup incomplete OR authenticated admin
|
|
* For endpoints shared between the setup wizard and admin settings.
|
|
* Allows access during setup (no auth needed) or after setup (admin auth required).
|
|
*/
|
|
export async function requireSetupIncompleteOrAdmin(
|
|
request: NextRequest,
|
|
handler: (request: NextRequest) => Promise<NextResponse>
|
|
): Promise<NextResponse> {
|
|
let setupComplete = false;
|
|
|
|
try {
|
|
const config = await prisma.configuration.findUnique({
|
|
where: { key: 'setup_completed' },
|
|
});
|
|
setupComplete = config?.value === 'true';
|
|
} catch {
|
|
// If database is not ready, setup is definitely not complete — allow through
|
|
return handler(request);
|
|
}
|
|
|
|
if (!setupComplete) {
|
|
// Setup in progress — allow unauthenticated access (setup wizard)
|
|
return handler(request);
|
|
}
|
|
|
|
// Setup is complete — require admin authentication
|
|
return requireAuth(request, (authenticatedReq) =>
|
|
requireAdmin(authenticatedReq, () => handler(request))
|
|
);
|
|
}
|