/
dupleymi-amp
/
sql-trainer
Обзор
Документация
Войти
/
dupleymi-amp
/
sql-trainer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/lib/api-auth.ts
349 строк
12 KB
Maxim Dupley
modified: eslint.config.mjs
31 июл 2026, 11:14
31 июл 2026, 11:14
db62f0d
Код
Авторство
О чём код?
/** * Reusable auth check helpers for API routes. * Reduces boilerplate for session validation and role checks. */ import { auth } from '@/lib/auth-internal'; import { NextResponse } from 'next/server'; import type { Session } from 'next-auth'; import { hasRole } from '@/lib/rbac'; import { rateLimit, type RateLimitResult } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { t } from '@/lib/i18n'; import { validateCsrfTokenEdge, csrfErrorResponse } from '@/lib/csrf'; const RATE_LIMIT_MESSAGE = 'error.rateLimit'; /** * Inject standard rate limit headers into a response. * Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset */ function withRateLimitHeaders(response: NextResponse, result: RateLimitResult): NextResponse { response.headers.set('X-RateLimit-Limit', String(result.limit)); response.headers.set('X-RateLimit-Remaining', String(result.remaining)); response.headers.set('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000))); return response; } export async function requireAdmin(): Promise<{ error: NextResponse | null; session: Session | null }> { const session = await auth(); if (!session?.user?.id || session.user.id === '') { return { error: NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }), session: null }; } const userRole = session.user.role; if (!userRole || !hasRole(userRole, 'admin')) { return { error: NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }), session: null }; } return { error: null, session }; } export async function requireTeacher(): Promise<{ error: NextResponse | null; session: Session | null }> { const session = await auth(); if (!session?.user?.id || session.user.id === '') { return { error: NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }), session: null }; } const userRole = session.user.role; if (!userRole || !hasRole(userRole, 'teacher')) { return { error: NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }), session: null }; } return { error: null, session }; } /** * UUID v4 format regex (loose — validates hex groups and dashes, not version bits). */ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * Returns true if the string is a valid UUID format. */ export function isValidUUID(id: string): boolean { return UUID_REGEX.test(id); } /** * Maximum valid timestamp (~year 3000) to reject impossibly large values. */ const MAX_VALID_TIMESTAMP = 32503680000000; // 3000-01-01T00:00:00Z export function parseDateParams(searchParams: URLSearchParams): { startDate: number | null; endDate: number | null; error?: string; } { const startDate = searchParams.get('startDate'); const endDate = searchParams.get('endDate'); if (!startDate && !endDate) { return { startDate: null, endDate: null }; } const start = startDate ? parseInt(startDate, 10) : NaN; const end = endDate ? parseInt(endDate, 10) : NaN; if (startDate && (isNaN(start) || start <= 0 || start > MAX_VALID_TIMESTAMP)) { return { startDate: null, endDate: null, error: 'Invalid startDate parameter' }; } if (endDate && (isNaN(end) || end <= 0 || end > MAX_VALID_TIMESTAMP)) { return { startDate: null, endDate: null, error: 'Invalid endDate parameter' }; } if (!isNaN(start) && !isNaN(end) && start > end) { return { startDate: null, endDate: null, error: 'startDate must be before endDate' }; } return { startDate: start, endDate: end }; } type RouteHandlerContext = { session: Session; request: Request; params?: Record<string, string>; }; type AnalyticsHandlerContext = { session: Session; startDate: number | null; endDate: number | null; searchParams: URLSearchParams; }; /** * Resolve params from context, handling both sync and promise-based params * (Next.js 15+ uses promise-based params). */ async function resolveParams(context?: { params?: Promise<Record<string, string>> | Record<string, string>; }): Promise<Record<string, string> | undefined> { if (!context?.params) return undefined; return 'then' in context.params ? await context.params : context.params; } /** * Rate limit configuration for withRoleAuth. */ interface RateLimitConfig { max: number; windowMs: number; } /** * Factory that creates a role-scoped auth wrapper with rate limiting and error handling. * Eliminates the near-identical withAdminAuth / withTeacherAuth implementations. */ function withRoleAuth( roleCheck: () => Promise<{ error: NextResponse | null; session: Session | null }>, rateLimitPrefix: string, defaultRateLimit: RateLimitConfig, errorLabel: string, ) { return function ( handler: (ctx: RouteHandlerContext) => NextResponse | Promise<NextResponse>, overrideRateLimit?: RateLimitConfig, ) { return async ( request: Request, context?: { params?: Promise<Record<string, string>> | Record<string, string> }, ): Promise<NextResponse> => { const authResult = await roleCheck(); if (!authResult.session) { return authResult.error ?? NextResponse.json({ success: false, error: 'Internal error' }, { status: 500 }); } const method = request.method.toUpperCase(); if (method !== 'GET' && method !== 'HEAD') { if (!(await validateCsrfTokenEdge(request))) { return csrfErrorResponse(); } } const userId = authResult.session.user.id; const rl = overrideRateLimit ?? defaultRateLimit; const limitResult = await rateLimit(`${rateLimitPrefix}:${userId}`, rl); if (!limitResult.success) { return withRateLimitHeaders( NextResponse.json({ success: false, error: t(RATE_LIMIT_MESSAGE) }, { status: 429 }), limitResult, ); } const params = await resolveParams(context); try { const response = await handler({ session: authResult.session, request, params }); // Wrap success response with success: true envelope if (response.ok) { // Clone before reading to avoid consuming the response body const cloned = response.clone(); const body = await cloned.json(); if (!('success' in body)) { return withRateLimitHeaders(NextResponse.json({ success: true, ...body }), limitResult); } } return withRateLimitHeaders(response, limitResult); } catch (error) { logger.error(`${errorLabel} handler error:`, error); return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); } }; }; } export const withAdminAuth = withRoleAuth(requireAdmin, 'admin', { max: 30, windowMs: 60_000 }, 'Admin'); export const withTeacherAuth = withRoleAuth(requireTeacher, 'teacher', { max: 30, windowMs: 60_000 }, 'Teacher'); /** * Check that user is authenticated (no specific role required). */ async function requireUser() { const session = await auth(); if (!session?.user?.id || session.user.id === '') { return { error: NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }), session: null }; } return { error: null, session }; } /** * Higher-order wrapper for any authenticated user (no specific role required). * Replaces manual `const session = await auth()` checks in user-facing routes. */ export const withUserAuth = withRoleAuth(requireUser, 'user', { max: 60, windowMs: 60_000 }, 'User'); /** * Strict variant of withUserAuth for sensitive operations (password change, account deletion, etc). * Default: 5 requests per 15 minutes. Callers can override via withUserAuthStrict(handler, { max, windowMs }). */ export const withUserAuthStrict = withRoleAuth(requireUser, 'user', { max: 5, windowMs: 15 * 60_000 }, 'User'); /** * Higher-order wrapper for analytics GET routes. * Handles admin auth + date param parsing in one call. * Replaces the ~18 lines of boilerplate repeated across 50+ analytics routes. */ export function withAnalyticsAuth( handler: (ctx: AnalyticsHandlerContext) => NextResponse | Promise<NextResponse>, ): (request: Request) => Promise<NextResponse> { return async (request: Request): Promise<NextResponse> => { const authResult = await requireAdmin(); if (!authResult.session) { return authResult.error ?? NextResponse.json({ success: false, error: 'Internal error' }, { status: 500 }); } // Rate limit analytics requests: 30 per minute per user const userId = authResult.session.user.id; const limitResult = await rateLimit(`analytics:${userId}`, { max: 30, windowMs: 60_000 }); if (!limitResult.success) { return withRateLimitHeaders( NextResponse.json({ success: false, error: t(RATE_LIMIT_MESSAGE) }, { status: 429 }), limitResult, ); } const url = new URL(request.url); const searchParams = url.searchParams; const { startDate, endDate, error } = parseDateParams(searchParams); if (error) { return NextResponse.json({ success: false, error }, { status: 400 }); } try { const response = await handler({ session: authResult.session, startDate, endDate, searchParams, }); // Wrap success response with success: true envelope if (response.ok) { // Clone before reading to avoid consuming the response body const cloned = response.clone(); const body = await cloned.json(); if (!('success' in body)) { const wrapped = NextResponse.json({ success: true, ...body }); return withRateLimitHeaders(wrapped, limitResult); } } return withRateLimitHeaders(response, limitResult); } catch (error) { logger.error('Analytics handler error:', error); return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }); } }; } /** * Parse a single query param as a validated integer. * Returns null if missing or NaN. */ export function intParam(searchParams: URLSearchParams, key: string): number | null { const val = parseInt(searchParams.get(key) || '', 10); return isNaN(val) ? null : val; } /** * Parse a single query param as a validated positive integer with optional max. */ export function positiveIntParam(searchParams: URLSearchParams, key: string, max?: number): number | null { const val = intParam(searchParams, key); if (val === null || val <= 0) return null; if (max !== undefined && val > max) return max; return val; } const DEFAULT_ANALYTICS_DAYS = 7; const MIN_ANALYTICS_DAYS = 1; const MAX_ANALYTICS_DAYS = 90; /** * Parse a `days` query parameter with bounds [1, 90]. Defaults to 7. */ export function parseDaysParam(searchParams: URLSearchParams): number { return Math.min( Math.max(parseInt(searchParams.get('days') || String(DEFAULT_ANALYTICS_DAYS), 10), MIN_ANALYTICS_DAYS), MAX_ANALYTICS_DAYS, ); } /** * Verify group ownership. Returns { error, group } — if error is non-null, return it immediately. */ export async function requireGroupOwnership( groupId: string | undefined, teacherId: string, ): Promise<{ error: NextResponse | null; group: { id: string; teacher_id: string; name: string } | null }> { if (!groupId) { return { error: NextResponse.json({ success: false, error: 'Group ID is required' }, { status: 400 }), group: null, }; } if (!isValidUUID(groupId)) { return { error: NextResponse.json({ success: false, error: 'Invalid group ID format' }, { status: 400 }), group: null, }; } // Dynamic import to avoid circular dependency const { getGroupById } = await import('@/lib/db-users'); const group = getGroupById(groupId); if (!group) { return { error: NextResponse.json({ success: false, error: 'Group not found' }, { status: 404 }), group: null, }; } if (group.teacher_id !== teacherId) { return { error: NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }), group: null, }; } return { error: null, group }; }