Spaces:
Sleeping
Sleeping
| import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, Res, UseGuards } from '@nestjs/common'; | |
| import type { Request, Response } from 'express'; | |
| import { AuthService } from './auth.service'; | |
| import { RateLimitService } from './rate-limit.service'; | |
| import { OptionalJwtGuard } from './optional-jwt.guard'; | |
| import { writeAudit, getClientIp } from '../../services/auditLog'; | |
| import type { User } from '../../types'; | |
| const WINDOW = 15 * 60 * 1000; | |
| const LOGIN_MIN_LATENCY_MS = 350; | |
| const FORGOT_MIN_LATENCY_MS = 350; | |
| const GENERIC_FORGOT_RESPONSE = { ok: true }; | |
| const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| /** | |
| * Public auth endpoints (no session required) — byte-identical to the legacy | |
| * Express route (server/src/routes/auth.ts): the same per-IP rate-limit buckets | |
| * + limits, the constant-time login/forgot latency padding, the enumeration-safe | |
| * forgot response, the audit writes and the JWT httpOnly cookie set/clear via | |
| * the shared cookie service (no new token shape). | |
| */ | |
| ('api/auth') | |
| export class AuthPublicController { | |
| constructor(private readonly auth: AuthService, private readonly rl: RateLimitService) {} | |
| private limit(bucket: string, req: Request, max: number): void { | |
| if (!this.rl.check(bucket, req.ip || 'unknown', max, WINDOW, Date.now())) { | |
| throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429); | |
| } | |
| } | |
| ('app-config') | |
| (OptionalJwtGuard) | |
| appConfig(() req: Request) { | |
| return this.auth.getAppConfig((req.user as User | undefined) ?? undefined); | |
| } | |
| ('demo-login') | |
| (200) | |
| demoLogin(() req: Request, ({ passthrough: true }) res: Response) { | |
| const result = this.auth.demoLogin(); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| this.auth.setAuthCookie(res, result.token!, req); | |
| return { token: result.token, user: result.user }; | |
| } | |
| ('invite/:token') | |
| invite(('token') token: string, () req: Request) { | |
| this.limit('login', req, 10); | |
| const result = this.auth.validateInviteToken(token); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| return { valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at }; | |
| } | |
| ('register') | |
| (201) | |
| register(() body: unknown, () req: Request, ({ passthrough: true }) res: Response) { | |
| this.limit('login', req, 10); | |
| const result = this.auth.registerUser(body); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails }); | |
| this.auth.setAuthCookie(res, result.token!, req); | |
| return { token: result.token, user: result.user }; | |
| } | |
| ('login') | |
| (200) | |
| async login(() body: unknown, () req: Request, ({ passthrough: true }) res: Response) { | |
| this.limit('login', req, 10); | |
| const started = Date.now(); | |
| const result = this.auth.loginUser(body); | |
| if (result.auditAction) { | |
| writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); | |
| } | |
| const elapsed = Date.now() - started; | |
| if (elapsed < LOGIN_MIN_LATENCY_MS) await delay(LOGIN_MIN_LATENCY_MS - elapsed); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| if (result.mfa_required) { | |
| return { mfa_required: true, mfa_token: result.mfa_token }; | |
| } | |
| this.auth.setAuthCookie(res, result.token!, req, result.remember); | |
| return { token: result.token, user: result.user }; | |
| } | |
| ('forgot-password') | |
| (200) | |
| async forgotPassword(() body: { email?: unknown }, () req: Request) { | |
| this.limit('forgot', req, 3); | |
| const started = Date.now(); | |
| const rawEmail = typeof body?.email === 'string' ? body.email : ''; | |
| const ip = getClientIp(req); | |
| const outcome = this.auth.requestPasswordReset(rawEmail, ip); | |
| if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) { | |
| const origin = this.auth.getAppUrl(); | |
| const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`; | |
| writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } }); | |
| try { | |
| const delivery = await this.auth.sendPasswordResetEmail(outcome.userEmail, url, outcome.userId); | |
| writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } }); | |
| } catch { | |
| writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } }); | |
| } | |
| } else { | |
| writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } }); | |
| } | |
| const elapsed = Date.now() - started; | |
| if (elapsed < FORGOT_MIN_LATENCY_MS) await delay(FORGOT_MIN_LATENCY_MS - elapsed); | |
| return GENERIC_FORGOT_RESPONSE; | |
| } | |
| ('reset-password') | |
| (200) | |
| resetPassword(() body: unknown, () req: Request) { | |
| // Per-IP brute-force guard, parity with the legacy resetLimiter (5 / 15 min on | |
| // a dedicated bucket) — without it reset tokens could be guessed unthrottled. | |
| this.limit('reset', req, 5); | |
| const ip = getClientIp(req); | |
| const result = this.auth.resetPassword(body); | |
| if (result.error) { | |
| writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } }); | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| if (result.mfa_required) { | |
| return { mfa_required: true }; | |
| } | |
| writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip }); | |
| return { success: true }; | |
| } | |
| ('mfa/verify-login') | |
| (200) | |
| verifyMfaLogin(() body: unknown, () req: Request, ({ passthrough: true }) res: Response) { | |
| this.limit('mfa', req, 5); | |
| const result = this.auth.verifyMfaLogin(body); | |
| if (result.error) { | |
| throw new HttpException({ error: result.error }, result.status!); | |
| } | |
| writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); | |
| this.auth.setAuthCookie(res, result.token!, req, result.remember); | |
| return { token: result.token, user: result.user }; | |
| } | |
| ('logout') | |
| (200) | |
| logout(() req: Request, ({ passthrough: true }) res: Response) { | |
| this.auth.clearAuthCookie(res, req); | |
| return { success: true }; | |
| } | |
| } | |