Spaces:
Sleeping
Sleeping
File size: 5,313 Bytes
57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Patch, Post, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { RateLimitService } from './rate-limit.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { PasskeyEnabledGuard } from './passkey-enabled.guard';
import { CurrentUser } from './current-user.decorator';
import { setAuthCookie } from '../../services/cookie';
import { writeAudit, getClientIp } from '../../services/auditLog';
import * as passkey from '../../services/passkeyService';
import type { User } from '../../types';
const WINDOW = 15 * 60 * 1000;
const LOGIN_MIN_LATENCY_MS = 350;
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* /api/auth/passkey β WebAuthn (passkey) registration, primary login and
* credential management.
*
* - register/* : authenticated, gated by the admin toggle + password re-auth.
* - login/* : UNauthenticated discoverable-credential login, gated by the
* admin toggle; mints the SAME session cookie as password login.
* - credentials : owner-scoped management β intentionally NOT toggle-gated so a
* user can always view/remove their passkeys.
*
* PasskeyEnabledGuard is listed first so a disabled feature 404s before auth.
*/
@Controller('api/auth/passkey')
export class PasskeyController {
constructor(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);
}
}
// ββ Registration (authenticated) ββ
@Post('register/options')
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
async registerOptions(@CurrentUser() user: User, @Body() body: { password?: string }, @Req() req: Request) {
this.limit('mfa', req, 5);
const result = await passkey.passkeyRegisterOptions(user.id, body?.password);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return result.options;
}
@Post('register/verify')
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
async registerVerify(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
const result = await passkey.passkeyRegisterVerify(user.id, body as Parameters<typeof passkey.passkeyRegisterVerify>[1]);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
return { success: true, credential: result.credential };
}
// ββ Authentication (public β primary login) ββ
@Post('login/options')
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard)
async loginOptions(@Req() req: Request) {
this.limit('login', req, 10);
const result = await passkey.passkeyLoginOptions();
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return result.options;
}
@Post('login/verify')
@HttpCode(200)
@UseGuards(PasskeyEnabledGuard)
async loginVerify(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 10);
const started = Date.now();
const result = await passkey.passkeyLoginVerify(body as Parameters<typeof passkey.passkeyLoginVerify>[0]);
if (result.auditAction) {
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req) });
}
// Pad to the same floor as password login so timing can't distinguish a
// known credential from an unknown one.
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!);
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { method: 'passkey' } });
setAuthCookie(res, result.token!, req);
return { token: result.token, user: result.user };
}
// ββ Management (authenticated, owner-scoped β NOT toggle-gated) ββ
@Get('credentials')
@UseGuards(JwtAuthGuard)
list(@CurrentUser() user: User) {
return { credentials: passkey.listPasskeys(user.id) };
}
@Patch('credentials/:id')
@UseGuards(JwtAuthGuard)
rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { name?: unknown }) {
const result = passkey.renamePasskey(user.id, id, body?.name);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return { success: true };
}
@Delete('credentials/:id')
@UseGuards(JwtAuthGuard)
remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: { password?: string }, @Req() req: Request) {
this.limit('login', req, 5);
const result = passkey.deletePasskey(user.id, id, body?.password);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
}
|