dorfteich/apps/api/src/auth/auth.controller.ts
Claude Fable 5 db4c5ce9ca
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m53s
CI / Build container images (pull_request) Successful in 3m55s
CI / Auth e2e pack (pull_request) Successful in 7m53s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m53s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m37s
CI / Import/export fidelity gate (push) Successful in 52s
#190: configurable session lifetime with a server-side idle timeout
SESSION_ABSOLUTE_HOURS (default 168 h) caps a session's total lifetime
from login: expiresAt is set once at creation and never extended — the
old sliding 30-day renewal is gone. SESSION_IDLE_HOURS (default 72 h)
ends sessions unused for that long, enforced server-side against
lastSeenAt with a write throttle scaled to the idle bound so short idle
windows still renew. Expired rows are removed on validation and the
session list applies both bounds, so idle-dead sessions never show as
active. The cookie maxAge follows the configured absolute bound.

Documented in .env.example (with the VS-NfD reference values for the
upcoming hardening guide #227), compose passes the variables through,
security.md and ADR 0007 record the amendment.

Refs #190

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 11:11:15 +02:00

146 lines
4.4 KiB
TypeScript

import { Body, Controller, Get, HttpCode, Post, Req, Res } from '@nestjs/common';
import {
CurrentUser as CurrentUserShape,
LoginInput,
SignupInput,
forgotPasswordInputSchema,
loginInputSchema,
resendVerificationInputSchema,
resetPasswordInputSchema,
signupInputSchema,
verifyEmailInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AppConfig } from '../config/app-config.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { RateLimit } from '../rate-limit/rate-limit.guard';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { SetupExempt } from '../setup/setup.guard';
import {
AuthedRequest,
Public,
SESSION_COOKIE,
setSessionCookie,
toCurrentUser,
} from './auth.guard';
import { AuthService } from './auth.service';
import { SessionsService, sessionAbsoluteMs } from './sessions.service';
@AuthenticatedOnly() // routes reachable without a session opt out via @Public
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly sessions: SessionsService,
private readonly config: AppConfig,
private readonly settings: InstanceSettingsService,
) {}
/** Public: the SPA hides the signup route while registration is closed. */
@Public()
@Get('registration')
async registration(): Promise<{ mode: 'open' | 'closed' }> {
return { mode: await this.settings.get('auth.registrationMode') };
}
@Public()
@Post('signup')
@HttpCode(201)
@RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 })
async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> {
await this.auth.signup(input);
}
@Public()
@Post('verify-email')
@HttpCode(204)
@RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 })
async verifyEmail(
@Body(new ZodValidationPipe(verifyEmailInputSchema)) input: { token: string },
): Promise<void> {
await this.auth.verifyEmail(input.token);
}
@Public()
@Post('resend-verification')
@HttpCode(204)
@RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 })
async resendVerification(
@Body(new ZodValidationPipe(resendVerificationInputSchema)) input: { email: string },
): Promise<void> {
await this.auth.resendVerification(input.email);
}
// Exempt from the setup gate: a mid-wizard Site Admin who lost the
// session cookie must be able to sign back in and finish setup.
@SetupExempt()
@Public()
@Post('login')
@HttpCode(200)
@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })
async login(
@Body(new ZodValidationPipe(loginInputSchema)) input: LoginInput,
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<CurrentUserShape> {
const { sessionToken, user } = await this.auth.login(
input.usernameOrEmail,
input.password,
request.headers['user-agent'],
);
setSessionCookie(
response,
sessionToken,
this.config.env.NODE_ENV === 'production',
sessionAbsoluteMs(this.config.env),
);
return toCurrentUser(user);
}
@SetupExempt()
@Post('logout')
@HttpCode(204)
async logout(
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
if (request.sessionToken) {
await this.sessions.destroyByRawToken(request.sessionToken);
}
response.clearCookie(SESSION_COOKIE, { path: '/' });
}
@SetupExempt()
@Get('me')
me(@Req() request: AuthedRequest): CurrentUserShape {
// AuthGuard guarantees request.user for non-@Public routes.
return toCurrentUser(request.user!);
}
@Public()
@Post('forgot-password')
@HttpCode(204)
@RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 })
async forgotPassword(
@Body(new ZodValidationPipe(forgotPasswordInputSchema)) input: { email: string },
): Promise<void> {
await this.auth.forgotPassword(input.email);
}
@Public()
@Post('reset-password')
@HttpCode(204)
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
async resetPassword(
@Body(new ZodValidationPipe(resetPasswordInputSchema))
input: {
token: string;
password: string;
},
): Promise<void> {
await this.auth.resetPassword(input.token, input.password);
}
}