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
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
146 lines
4.4 KiB
TypeScript
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);
|
|
}
|
|
}
|