mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
168 lines
7.2 KiB
TypeScript
168 lines
7.2 KiB
TypeScript
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 { getClientIp } from '../audit/client-ip';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { willDropSecureCookie } from '../../services/cookie';
|
|
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).
|
|
*/
|
|
@Controller('api/auth')
|
|
export class AuthPublicController {
|
|
constructor(private readonly auth: AuthService, private readonly rl: RateLimitService, private readonly audit: AuditService) {}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
@Get('app-config')
|
|
@UseGuards(OptionalJwtGuard)
|
|
appConfig(@Req() req: Request) {
|
|
return this.auth.getAppConfig((req.user as User | undefined) ?? undefined);
|
|
}
|
|
|
|
@Post('demo-login')
|
|
@HttpCode(200)
|
|
demoLogin(@Req() req: Request, @Res({ 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 };
|
|
}
|
|
|
|
@Get('invite/:token')
|
|
invite(@Param('token') token: string, @Req() 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 };
|
|
}
|
|
|
|
@Post('register')
|
|
@HttpCode(201)
|
|
register(@Body() body: unknown, @Req() req: Request, @Res({ 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!);
|
|
}
|
|
this.audit.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 };
|
|
}
|
|
|
|
@Post('login')
|
|
@HttpCode(200)
|
|
async login(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
|
this.limit('login', req, 10);
|
|
const started = Date.now();
|
|
const result = this.auth.loginUser(body);
|
|
if (result.auditAction) {
|
|
this.audit.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,
|
|
// Surfaced so the client can explain the plain-HTTP cookie gotcha instead
|
|
// of the user hitting a bare "Access token required" on the next request.
|
|
...(willDropSecureCookie(req) ? { insecureCookie: true } : {}),
|
|
};
|
|
}
|
|
|
|
@Post('forgot-password')
|
|
@HttpCode(200)
|
|
async forgotPassword(@Body() body: { email?: unknown }, @Req() 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)}`;
|
|
this.audit.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);
|
|
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
|
|
} catch {
|
|
this.audit.writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
|
|
}
|
|
} else {
|
|
this.audit.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;
|
|
}
|
|
|
|
@Post('reset-password')
|
|
@HttpCode(200)
|
|
resetPassword(@Body() body: unknown, @Req() 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) {
|
|
this.audit.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 };
|
|
}
|
|
this.audit.writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
|
|
return { success: true };
|
|
}
|
|
|
|
@Post('mfa/verify-login')
|
|
@HttpCode(200)
|
|
verifyMfaLogin(@Body() body: unknown, @Req() req: Request, @Res({ 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!);
|
|
}
|
|
this.audit.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 };
|
|
}
|
|
|
|
@Post('logout')
|
|
@HttpCode(200)
|
|
logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
|
this.auth.clearAuthCookie(res, req);
|
|
return { success: true };
|
|
}
|
|
}
|