mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
eae74c5605
Every visitor of a demo instance signs in as the same account, so one visitor's API key, MCP token, OAuth client, passkey or new email address became everybody's. These writes now answer 403 for the demo account through refuseForDemoAccount in demo-write.ts: creating API keys, creating and deleting MCP tokens, registering, rotating and deleting OAuth clients, approving an app and ending a connected app, changing the name, address or picture, and enrolling, renaming or deleting passkeys. Password, 2FA, account deletion, sessions and the own AI connection were refused already. The demo account's language is fixed to English: a single settings write of the language answers unchanged, a bulk save drops it and keeps the rest. The MCP settings tool is held back for the demo account by the tool gate, as every write tool is. The admin of a demo instance and every real instance are untouched.
175 lines
6.4 KiB
TypeScript
175 lines
6.4 KiB
TypeScript
import type { User } from '../../types';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { getClientIp } from '../audit/client-ip';
|
|
import { CurrentUser } from '../auth-core/current-user.decorator';
|
|
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
|
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
|
import { setAuthCookie } from '../common/cookie';
|
|
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
|
|
import { RateLimitService } from '../common/rate-limit.service';
|
|
import { sessionClientFrom } from '../sessions/sessions.service';
|
|
import {
|
|
PasskeyRegisterOptionsDto,
|
|
PasskeyRegisterVerifyDto,
|
|
PasskeyLoginVerifyDto,
|
|
PasskeyRenameDto,
|
|
PasskeyDeleteDto,
|
|
} from './auth.dto';
|
|
import { PasskeyEnabledGuard } from './passkey-enabled.guard';
|
|
import { PasskeyService } from './passkey.service';
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
HttpCode,
|
|
HttpException,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Req,
|
|
Res,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
|
|
import type { Request, Response } from 'express';
|
|
|
|
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 readonly audit: AuditService,
|
|
private readonly passkeys: PasskeyService,
|
|
) {}
|
|
|
|
private async limit(bucket: string, req: Request, max: number): Promise<void> {
|
|
if (!(await 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')
|
|
@MfaExempt('a user-verified passkey satisfies require_mfa, so enrolling one is a way out')
|
|
@HttpCode(200)
|
|
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
|
async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) {
|
|
// Every visitor shares the demo account: a passkey enrolled on it would be
|
|
// one stranger's key to everybody's demo.
|
|
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
|
await this.limit('mfa', req, 5);
|
|
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
|
|
// — the RP ID itself is never derived from request headers.
|
|
const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password, req.headers.origin);
|
|
return result.options;
|
|
}
|
|
|
|
@Post('register/verify')
|
|
@MfaExempt('a user-verified passkey satisfies require_mfa, so enrolling one is a way out')
|
|
@HttpCode(200)
|
|
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
|
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
|
|
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
|
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
|
|
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
|
return { success: true, credential: result.credential };
|
|
}
|
|
|
|
// ── Authentication (public — primary login) ──
|
|
@Post('login/options')
|
|
@MfaExempt('unauthenticated passkey login ceremony')
|
|
@HttpCode(200)
|
|
@UseGuards(PasskeyEnabledGuard)
|
|
async loginOptions(@Req() req: Request) {
|
|
await this.limit('login', req, 10);
|
|
const result = await this.passkeys.passkeyLoginOptions(req.headers.origin);
|
|
return result.options;
|
|
}
|
|
|
|
@Post('login/verify')
|
|
@MfaExempt('unauthenticated passkey login ceremony')
|
|
@HttpCode(200)
|
|
@UseGuards(PasskeyEnabledGuard)
|
|
async loginVerify(
|
|
@Body() body: PasskeyLoginVerifyDto,
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response,
|
|
) {
|
|
await this.limit('login', req, 10);
|
|
const started = Date.now();
|
|
const result = await this.passkeys.passkeyLoginVerify(body, sessionClientFrom(req));
|
|
if (result.auditAction) {
|
|
await this.audit.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!);
|
|
await this.audit.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')
|
|
@MfaExempt('the setup screen lists what the user already has')
|
|
@UseGuards(JwtAuthGuard)
|
|
async list(@CurrentUser() user: User) {
|
|
return { credentials: await this.passkeys.listPasskeys(user.id) };
|
|
}
|
|
|
|
@Patch('credentials/:id')
|
|
@UseGuards(JwtAuthGuard)
|
|
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
|
|
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
|
await this.passkeys.renamePasskey(user.id, id, body?.name);
|
|
return { success: true };
|
|
}
|
|
|
|
@Delete('credentials/:id')
|
|
@UseGuards(JwtAuthGuard)
|
|
async remove(
|
|
@CurrentUser() user: User,
|
|
@Param('id') id: string,
|
|
@Body() body: PasskeyDeleteDto,
|
|
@Req() req: Request,
|
|
) {
|
|
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
|
await this.limit('login', req, 5);
|
|
await this.passkeys.deletePasskey(user.id, id, body?.password);
|
|
await this.audit.writeAudit({
|
|
userId: user.id,
|
|
action: 'user.passkey_delete',
|
|
resource: String(id),
|
|
ip: getClientIp(req),
|
|
});
|
|
return { success: true };
|
|
}
|
|
}
|