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 } 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 { 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 { 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 { 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 { const { sessionToken, user } = await this.auth.login( input.usernameOrEmail, input.password, request.headers['user-agent'], ); setSessionCookie(response, sessionToken, this.config.env.NODE_ENV === 'production'); return toCurrentUser(user); } @SetupExempt() @Post('logout') @HttpCode(204) async logout( @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { 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 { 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 { await this.auth.resetPassword(input.token, input.password); } }