mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
feat: Passkey (WebAuthn) login (#1111)
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers. * feat(auth): passkey enrolment, login button + admin settings UI PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action. * i18n(auth): passkey strings across all locales Add login/settings/admin passkey keys to en and all 19 translated locales.
This commit is contained in:
@@ -60,6 +60,13 @@ export class AdminController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete('users/:id/passkeys')
|
||||
resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
const result = ok(this.admin.resetUserPasskeys(id));
|
||||
writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
|
||||
return { success: true, deleted: result.deleted };
|
||||
}
|
||||
|
||||
// ── Stats / permissions / audit ──
|
||||
@Get('stats')
|
||||
stats() { return this.admin.getStats(); }
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as svc from '../../services/adminService';
|
||||
import { getAdminUserDefaults, setAdminUserDefaults } from '../../services/settingsService';
|
||||
import { invalidateMcpSessions } from '../../mcp';
|
||||
import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService';
|
||||
import { adminResetPasskeys } from '../../services/passkeyService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing admin service (+ the settings,
|
||||
@@ -17,6 +18,7 @@ export class AdminService {
|
||||
createUser(body: unknown) { return svc.createUser(body as Parameters<typeof svc.createUser>[0]); }
|
||||
updateUser(id: string, body: unknown) { return svc.updateUser(id, body as Parameters<typeof svc.updateUser>[1]); }
|
||||
deleteUser(id: string, actingUserId: number) { return svc.deleteUser(id, actingUserId); }
|
||||
resetUserPasskeys(id: string) { return adminResetPasskeys(Number(id)); }
|
||||
|
||||
getStats() { return svc.getStats(); }
|
||||
getPermissions() { return svc.getPermissions(); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthPublicController } from './auth-public.controller';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { PasskeyController } from './passkey.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
|
||||
@@ -11,7 +12,7 @@ import { RateLimitService } from './rate-limit.service';
|
||||
* sub-paths explicitly rather than claiming all of /api/auth.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AuthPublicController, AuthController],
|
||||
controllers: [AuthPublicController, AuthController, PasskeyController],
|
||||
providers: [AuthService, RateLimitService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
|
||||
import { resolveAuthToggles } from '../../services/authService';
|
||||
|
||||
/**
|
||||
* Server-side enforcement of the instance-wide `passkey_login` toggle. Placed
|
||||
* BEFORE the auth guard on every passkey ceremony route so a disabled feature
|
||||
* returns 404 (not "auth required") and cannot be driven by direct API calls —
|
||||
* hiding the button in the UI is not enough. Mirrors JourneyAddonGuard.
|
||||
*
|
||||
* The credential-management routes (list/rename/delete) are deliberately NOT
|
||||
* gated by this guard so users can still clean up their passkeys after an admin
|
||||
* turns the feature off.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PasskeyEnabledGuard implements CanActivate {
|
||||
canActivate(): boolean {
|
||||
if (!resolveAuthToggles().passkey_login) {
|
||||
throw new HttpException({ error: 'Passkey login is not enabled' }, 404);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user