import { CanActivate, ExecutionContext, ForbiddenException, Injectable, SetMetadata, UnauthorizedException, createParamDecorator, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import type { CurrentUser as CurrentUserShape } from '@dorfteich/shared'; import type { User } from '@prisma/client'; import type { Request, Response } from 'express'; import { AppConfig } from '../config/app-config.service'; import { SessionsService } from './sessions.service'; export const SESSION_COOKIE = 'dt_session'; const IS_PUBLIC_KEY = 'isPublic'; /** Marks a route as reachable without a session (login, signup, healthz…). */ export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true); export interface AuthedRequest extends Request { user?: User; sessionId?: string; sessionToken?: string; } /** Injects the authenticated Prisma user into a handler parameter. */ export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext) => { return context.switchToHttp().getRequest().user; }); export function toCurrentUser(user: User): CurrentUserShape { return { id: user.id, username: user.username, email: user.email, displayName: user.displayName, locale: user.locale === 'de' ? 'de' : 'en', isSiteAdmin: user.isSiteAdmin, autoWatchOwnPages: user.autoWatchOwnPages, autoWatchOnComment: user.autoWatchOnComment, digestFrequency: user.digestFrequency === 'daily' || user.digestFrequency === 'off' ? user.digestFrequency : 'hourly', }; } /** Session cookie contract shared by login and the setup wizard (issue #80). */ export function setSessionCookie(response: Response, token: string, production: boolean): void { response.cookie(SESSION_COOKIE, token, { httpOnly: true, sameSite: 'lax', secure: production, maxAge: 30 * 24 * 60 * 60 * 1000, path: '/', }); } const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); /** * Global authentication guard: routes are protected by default and opt * out with @Public(). Also enforces the CSRF origin check on mutating * requests that carry a session cookie — SameSite=Lax is the first line * of defense, this is the second (security.md). */ @Injectable() export class AuthGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly sessions: SessionsService, private readonly config: AppConfig, ) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const rawToken = (request.cookies as Record | undefined)?.[SESSION_COOKIE]; if (rawToken && MUTATING_METHODS.has(request.method)) { this.assertSameOrigin(request); } // Attach the user whenever the cookie is valid — public routes may // still want to know who is asking. if (rawToken) { const validated = await this.sessions.validate(rawToken); if (validated) { request.user = validated.user; request.sessionId = validated.session.id; request.sessionToken = rawToken; } } const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); if (isPublic) return true; if (!request.user) throw new UnauthorizedException(); return true; } private assertSameOrigin(request: Request): void { const origin = request.headers.origin ?? request.headers.referer; // Non-browser clients (curl, supertest) send neither header; SameSite // cookies already stop cross-site browser requests without Origin. if (!origin) return; const expected = new URL(this.config.env.APP_BASE_URL).origin; if (new URL(origin).origin !== expected) { throw new ForbiddenException({ code: 'csrf_origin_mismatch' }); } } }