diff --git a/apps/api/package.json b/apps/api/package.json index 47460f5..f972d27 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "@nestjs/platform-express": "^11.0.0", "@prisma/client": "^6.3.0", "argon2": "^0.44.0", + "cookie-parser": "^1.4.7", "i18next": "^26.3.4", "nestjs-pino": "^4.3.0", "nodemailer": "^9.0.3", @@ -33,6 +34,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/testing": "^11.0.0", "@swc/core": "^1.10.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/nodemailer": "^8.0.1", "@types/supertest": "^6.0.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index efefa29..b267ce2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { APP_FILTER } from '@nestjs/core'; import { LoggerModule } from 'nestjs-pino'; +import { AuthModule } from './auth/auth.module'; import { ApiExceptionFilter } from './common/api-exception.filter'; import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; @@ -18,6 +19,7 @@ import { UsersModule } from './users/users.module'; RateLimitModule, MailModule, UsersModule, + AuthModule, LoggerModule.forRootAsync({ inject: [AppConfig], useFactory: (config: AppConfig) => ({ diff --git a/apps/api/src/auth/auth-tokens.service.ts b/apps/api/src/auth/auth-tokens.service.ts new file mode 100644 index 0000000..d4cef44 --- /dev/null +++ b/apps/api/src/auth/auth-tokens.service.ts @@ -0,0 +1,56 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { AuthTokenPurpose } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Single-use tokens for e-mail flows (ADR 0007). Only the SHA-256 hash is + * stored; consuming marks the row instead of deleting it so replay + * attempts remain visible in the data. + */ +@Injectable() +export class AuthTokensService { + constructor(private readonly prisma: PrismaService) {} + + async issue(userId: string, purpose: AuthTokenPurpose, ttlSeconds: number): Promise { + const raw = randomBytes(32).toString('base64url'); + // Previous unconsumed tokens for the same purpose die with the new + // one — only the latest link in the inbox works. + await this.prisma.authToken.updateMany({ + where: { userId, purpose, consumedAt: null }, + data: { consumedAt: new Date() }, + }); + await this.prisma.authToken.create({ + data: { + tokenHash: hashToken(raw), + userId, + purpose, + expiresAt: new Date(Date.now() + ttlSeconds * 1000), + }, + }); + return raw; + } + + /** Returns the owning user id, or null for unknown/expired/reused tokens. */ + async consume(raw: string, purpose: AuthTokenPurpose): Promise { + // Atomic claim: only one request can flip consumedAt from null. + const result = await this.prisma.authToken.updateMany({ + where: { + tokenHash: hashToken(raw), + purpose, + consumedAt: null, + expiresAt: { gt: new Date() }, + }, + data: { consumedAt: new Date() }, + }); + if (result.count === 0) return null; + const row = await this.prisma.authToken.findUnique({ where: { tokenHash: hashToken(raw) } }); + return row?.userId ?? null; + } +} + +function hashToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..5ee8a2e --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,127 @@ +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 { RateLimit } from '../rate-limit/rate-limit.guard'; +import { AuthedRequest, Public, SESSION_COOKIE, toCurrentUser } from './auth.guard'; +import { AuthService } from './auth.service'; +import { SessionsService } from './sessions.service'; + +@Controller('auth') +export class AuthController { + constructor( + private readonly auth: AuthService, + private readonly sessions: SessionsService, + private readonly config: AppConfig, + ) {} + + @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); + } + + @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'], + ); + this.setSessionCookie(response, sessionToken); + return toCurrentUser(user); + } + + @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: '/' }); + } + + @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); + } + + private setSessionCookie(response: Response, token: string): void { + response.cookie(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + secure: this.config.env.NODE_ENV === 'production', + maxAge: 30 * 24 * 60 * 60 * 1000, + path: '/', + }); + } +} diff --git a/apps/api/src/auth/auth.e2e.db.test.ts b/apps/api/src/auth/auth.e2e.db.test.ts new file mode 100644 index 0000000..8915107 --- /dev/null +++ b/apps/api/src/auth/auth.e2e.db.test.ts @@ -0,0 +1,193 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; + +describe.skipIf(!hasTestDb)('auth flows (e2e)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + + const account = { + username: `erik-${suffix}`, + email: `erik-${suffix}@example.org`, + displayName: 'Erik End-to-End', + password: 'ein wirklich gutes passwort', + locale: 'de' as const, + }; + + const api = () => request(app.getHttpServer()); + + /** Latest mail for an address, from the outbox (worker is off in tests). */ + async function latestMailLink(to: string): Promise { + const mail = await prisma.mailOutbox.findFirst({ + where: { toAddress: to }, + orderBy: { createdAt: 'desc' }, + }); + const link = mail?.textBody.match(/https?:\/\/\S+token=(\S+)/)?.[0]; + if (!link) throw new Error(`no mail with token link for ${to}`); + return link; + } + + function tokenFromLink(link: string): string { + return new URL(link).searchParams.get('token')!; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + // Rate-limit counters survive across local runs on the shared test + // db — a clean slate keeps the suite deterministic. + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + }); + + afterAll(async () => { + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); + await prisma.instanceSetting.deleteMany({ where: { key: 'auth.registrationMode' } }); + await prisma.$disconnect(); + await app.close(); + }); + + // ---------------------------------------------------------------- #13 + it('signs up and enqueues a verification mail with the app link', async () => { + await api().post('/api/v1/auth/signup').send(account).expect(201); + const link = await latestMailLink(account.email); + expect(link).toContain('/verify-email?token='); + }); + + it('rejects a duplicate username with a field-level conflict', async () => { + const res = await api() + .post('/api/v1/auth/signup') + .send({ ...account, email: `other-${suffix}@example.org` }) + .expect(409); + expect(res.body.details).toHaveProperty('username'); + }); + + it('blocks login before the e-mail is verified', async () => { + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: account.username, password: account.password }) + .expect(403); + }); + + it('verifies the e-mail exactly once', async () => { + const token = tokenFromLink(await latestMailLink(account.email)); + await api().post('/api/v1/auth/verify-email').send({ token }).expect(204); + await api().post('/api/v1/auth/verify-email').send({ token }).expect(400); + const user = await prisma.user.findUnique({ where: { email: account.email } }); + expect(user?.status).toBe('ACTIVE'); + }); + + it('refuses signup while registration is closed', async () => { + await prisma.instanceSetting.create({ + data: { key: 'auth.registrationMode', value: 'closed' }, + }); + await api() + .post('/api/v1/auth/signup') + .send({ ...account, username: `late-${suffix}`, email: `late-${suffix}@example.org` }) + .expect(403); + await prisma.instanceSetting.delete({ where: { key: 'auth.registrationMode' } }); + }); + + // ---------------------------------------------------------------- #14 + let cookie: string; + + it('logs in with username or e-mail and sets the session cookie', async () => { + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: account.email, password: account.password }) + .expect(200); + expect(res.body.username).toBe(account.username); + cookie = sessionCookieOf(res); + const cookieHeader = (res.headers['set-cookie'] as unknown as string[])[0]!; + expect(cookieHeader).toContain('HttpOnly'); + expect(cookieHeader).toContain('SameSite=Lax'); + }); + + it('serves /auth/me with a valid session and 401 without', async () => { + const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200); + expect(me.body.email).toBe(account.email); + await api().get('/api/v1/auth/me').expect(401); + }); + + it('answers wrong password and unknown user with the same generic 401', async () => { + const wrong = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: account.username, password: 'falsch falsch falsch' }) + .expect(401); + const unknown = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: `ghost-${suffix}`, password: 'egal egal egal' }) + .expect(401); + expect(wrong.body.message).toBe(unknown.body.message); + }); + + it('applies per-account backoff after repeated failures', async () => { + // 1 failure from the previous test + 4 more = 5 within the window; + // the next attempt is blocked even with correct credentials. + for (let i = 0; i < 4; i += 1) { + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: account.username, password: 'immer noch falsch' }) + .expect(401); + } + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: account.username, password: account.password }) + .expect(401); + await prisma.rateLimit.deleteMany({ where: { key: { startsWith: 'login-account' } } }); + }); + + it('rejects mutating requests from a foreign origin (CSRF)', async () => { + await api() + .post('/api/v1/auth/logout') + .set('Cookie', cookie) + .set('Origin', 'https://evil.example') + .expect(403); + }); + + it('logs out and invalidates the session immediately', async () => { + await api().post('/api/v1/auth/logout').set('Cookie', cookie).expect(204); + await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(401); + }); + + // ---------------------------------------------------------------- #15 + it('handles forgot-password without account enumeration', async () => { + await api().post('/api/v1/auth/forgot-password').send({ email: account.email }).expect(204); + await api() + .post('/api/v1/auth/forgot-password') + .send({ email: `niemand-${suffix}@example.org` }) + .expect(204); + }); + + it('resets the password, kills old sessions, and accepts the new password', async () => { + // The suite itself has spent the per-IP login budget by now. + await prisma.rateLimit.deleteMany({ where: { key: { startsWith: 'login:' } } }); + const login = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: account.username, password: account.password }) + .expect(200); + const oldCookie = sessionCookieOf(login); + + const token = tokenFromLink(await latestMailLink(account.email)); + const newPassword = 'ein noch besseres passwort'; + await api() + .post('/api/v1/auth/reset-password') + .send({ token, password: newPassword }) + .expect(204); + + await api().get('/api/v1/auth/me').set('Cookie', oldCookie).expect(401); + await api() + .post('/api/v1/auth/reset-password') + .send({ token, password: newPassword }) + .expect(400); + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: account.username, password: newPassword }) + .expect(200); + }); +}); diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts new file mode 100644 index 0000000..088d2bc --- /dev/null +++ b/apps/api/src/auth/auth.guard.ts @@ -0,0 +1,101 @@ +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 } 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, + }; +} + +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' }); + } + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..eff4bae --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; + +import { MailModule } from '../mail/mail.module'; +import { UsersModule } from '../users/users.module'; +import { AuthController } from './auth.controller'; +import { AuthGuard } from './auth.guard'; +import { AuthService } from './auth.service'; +import { AuthTokensService } from './auth-tokens.service'; +import { SessionsService } from './sessions.service'; + +@Module({ + imports: [UsersModule, MailModule], + controllers: [AuthController], + providers: [ + AuthService, + AuthTokensService, + SessionsService, + // Global default-protected: every route needs a session unless it + // opts out with @Public(). + { provide: APP_GUARD, useClass: AuthGuard }, + ], + exports: [SessionsService, AuthTokensService], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..abc9fc0 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,160 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { SignupInput } from '@dorfteich/shared'; +import { User } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { AppConfig } from '../config/app-config.service'; +import { MailService } from '../mail/mail.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RateLimitService } from '../rate-limit/rate-limit.service'; +import { UsersService } from '../users/users.service'; +import { AuthTokensService } from './auth-tokens.service'; +import { SessionsService } from './sessions.service'; + +const VERIFY_TTL_SECONDS = 24 * 60 * 60; +const RESET_TTL_SECONDS = 60 * 60; +// Account-scoped login backoff: 5 failures per 15 minutes, reset on success. +const LOGIN_BACKOFF = { limit: 5, windowSeconds: 15 * 60 }; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly users: UsersService, + private readonly tokens: AuthTokensService, + private readonly sessions: SessionsService, + private readonly mail: MailService, + private readonly rateLimits: RateLimitService, + private readonly config: AppConfig, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(AuthService.name); + } + + async signup(input: SignupInput): Promise { + if ((await this.registrationMode()) === 'closed') { + throw new ForbiddenException({ code: 'registration_closed' }); + } + const user = await this.users.createUser(input); + await this.sendVerificationMail(user); + this.logger.info({ userId: user.id }, 'audit: user signed up'); + } + + async verifyEmail(token: string): Promise { + const userId = await this.tokens.consume(token, 'EMAIL_VERIFICATION'); + if (!userId) throw new BadRequestException({ code: 'token_invalid' }); + const user = await this.users.findById(userId); + if (!user) throw new BadRequestException({ code: 'token_invalid' }); + if (user.status === 'PENDING_VERIFICATION') { + await this.users.markEmailVerified(userId); + this.logger.info({ userId }, 'audit: e-mail verified'); + } + } + + /** Always succeeds outwardly — never reveals whether the address exists. */ + async resendVerification(email: string): Promise { + const user = await this.users.findByEmail(email); + if (user?.status === 'PENDING_VERIFICATION') { + await this.sendVerificationMail(user); + } + } + + async login( + usernameOrEmail: string, + password: string, + userAgent: string | undefined, + ): Promise<{ sessionToken: string; user: User }> { + const user = await this.users.findByUsernameOrEmail(usernameOrEmail); + + // Backoff before the (expensive) hash check; keyed by account so a + // distributed guesser cannot sidestep it by rotating IPs. + if (user) { + const backoff = await this.rateLimits.hit( + 'login-account', + user.id, + LOGIN_BACKOFF.limit, + LOGIN_BACKOFF.windowSeconds, + ); + if (!backoff.allowed) { + throw new UnauthorizedException({ code: 'login_backoff' }); + } + } + + const passwordOk = user ? await this.users.checkPassword(user.id, password) : false; + if (!user || !passwordOk) { + // Same generic error for unknown user and wrong password. + this.logger.info({ userId: user?.id ?? null }, 'audit: login failed'); + throw new UnauthorizedException({ code: 'login_failed' }); + } + if (user.status === 'DISABLED') { + throw new ForbiddenException({ code: 'account_disabled' }); + } + if (user.status === 'PENDING_VERIFICATION') { + throw new ForbiddenException({ code: 'email_unverified' }); + } + + await this.rateLimits.reset('login-account', user.id); + const sessionToken = await this.sessions.create(user.id, userAgent); + await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); + this.logger.info({ userId: user.id }, 'audit: login succeeded'); + return { sessionToken, user }; + } + + /** Always succeeds outwardly — never reveals whether the address exists. */ + async forgotPassword(email: string): Promise { + const user = await this.users.findByEmail(email); + if (!user || user.status === 'DISABLED') return; + const token = await this.tokens.issue(user.id, 'PASSWORD_RESET', RESET_TTL_SECONDS); + await this.mail.enqueue( + user.email, + 'resetPassword', + { + displayName: user.displayName, + link: `${this.config.env.APP_BASE_URL}/reset-password?token=${token}`, + }, + asLocale(user.locale), + ); + } + + async resetPassword(token: string, password: string): Promise { + const userId = await this.tokens.consume(token, 'PASSWORD_RESET'); + if (!userId) throw new BadRequestException({ code: 'token_invalid' }); + await this.users.setPassword(userId, password); + // Whoever held old sessions (possibly an attacker) is logged out. + await this.sessions.destroyAllForUser(userId); + this.logger.info({ userId }, 'audit: password reset'); + } + + private async sendVerificationMail(user: User): Promise { + const token = await this.tokens.issue(user.id, 'EMAIL_VERIFICATION', VERIFY_TTL_SECONDS); + await this.mail.enqueue( + user.email, + 'verifyEmail', + { + displayName: user.displayName, + link: `${this.config.env.APP_BASE_URL}/verify-email?token=${token}`, + }, + asLocale(user.locale), + ); + } + + /** + * Registration mode straight from instance_settings; the typed + * InstanceSettingsService (issue #19) will replace this direct read. + */ + private async registrationMode(): Promise<'open' | 'closed'> { + const row = await this.prisma.instanceSetting.findUnique({ + where: { key: 'auth.registrationMode' }, + }); + return row?.value === 'closed' ? 'closed' : 'open'; + } +} + +function asLocale(locale: string): 'de' | 'en' { + return locale === 'de' ? 'de' : 'en'; +} diff --git a/apps/api/src/auth/sessions.service.ts b/apps/api/src/auth/sessions.service.ts new file mode 100644 index 0000000..782927f --- /dev/null +++ b/apps/api/src/auth/sessions.service.ts @@ -0,0 +1,98 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { Session, User } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; + +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // sliding 30 days +const REFRESH_AT_MOST_EVERY_MS = 60 * 60 * 1000; // avoid write storms + +export interface ValidatedSession { + session: Session; + user: User; +} + +/** + * Opaque server-side sessions (ADR 0007). The cookie value is 32 random + * bytes; the database stores only its SHA-256 hash as the row id, so a + * database leak cannot be replayed as cookies. + */ +@Injectable() +export class SessionsService { + constructor(private readonly prisma: PrismaService) {} + + async create(userId: string, userAgent: string | undefined): Promise { + const raw = randomBytes(32).toString('base64url'); + await this.prisma.session.create({ + data: { + id: hashSessionToken(raw), + userId, + expiresAt: new Date(Date.now() + SESSION_TTL_MS), + userAgent: summarizeUserAgent(userAgent), + }, + }); + return raw; + } + + async validate(raw: string): Promise { + const session = await this.prisma.session.findUnique({ + where: { id: hashSessionToken(raw) }, + include: { user: true }, + }); + if (!session || session.expiresAt <= new Date()) return null; + if (session.user.status === 'DISABLED') return null; + + // Sliding expiration, refreshed at most once per hour. + if (Date.now() - session.lastSeenAt.getTime() > REFRESH_AT_MOST_EVERY_MS) { + await this.prisma.session.update({ + where: { id: session.id }, + data: { lastSeenAt: new Date(), expiresAt: new Date(Date.now() + SESSION_TTL_MS) }, + }); + } + const { user, ...bare } = session; + return { session: bare as Session, user }; + } + + async destroyByRawToken(raw: string): Promise { + await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } }); + } + + async destroyById(sessionId: string, userId: string): Promise { + const result = await this.prisma.session.deleteMany({ + where: { id: sessionId, userId }, + }); + return result.count > 0; + } + + /** Logs the user out everywhere, optionally keeping one session alive. */ + async destroyAllForUser(userId: string, exceptSessionId?: string): Promise { + await this.prisma.session.deleteMany({ + where: { userId, ...(exceptSessionId ? { id: { not: exceptSessionId } } : {}) }, + }); + } + + listForUser(userId: string): Promise { + return this.prisma.session.findMany({ + where: { userId, expiresAt: { gt: new Date() } }, + orderBy: { lastSeenAt: 'desc' }, + }); + } +} + +export function hashSessionToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} + +/** Browser + OS, never the raw string (fingerprinting hygiene). */ +function summarizeUserAgent(ua: string | undefined): string | null { + if (!ua) return null; + const browser = + ua.match(/(Firefox|Edg|OPR|Chrome|Safari)\/[\d.]+/)?.[1]?.replace('Edg', 'Edge') ?? 'Browser'; + const os = ua.match(/\((Windows|Macintosh|X11; Linux|Android|iPhone|iPad)[^)]*\)/)?.[1] ?? ''; + const osName = os + .replace('Macintosh', 'macOS') + .replace('X11; Linux', 'Linux') + .replace(/iPhone|iPad/, 'iOS'); + return osName ? `${browser} · ${osName}` : browser; +} diff --git a/apps/api/src/common/api-exception.filter.ts b/apps/api/src/common/api-exception.filter.ts index 53bc742..7713b3c 100644 --- a/apps/api/src/common/api-exception.filter.ts +++ b/apps/api/src/common/api-exception.filter.ts @@ -27,7 +27,14 @@ export class ApiExceptionFilter implements ExceptionFilter { // Catalogued codes get the localized text; uncatalogued ones keep // the (developer-provided, English) exception message as fallback. const message = translateErrorCode(code, language) ?? exception.message; - response.status(status).json(apiError(code, message)); + // Field-level validation/conflict details pass through untouched — + // they carry i18n keys the client resolves per field. + const payload = exception.getResponse(); + const details = + typeof payload === 'object' && payload !== null && 'details' in payload + ? (payload as { details: Record }).details + : undefined; + response.status(status).json(apiError(code, message, details)); return; } diff --git a/apps/api/src/common/zod-validation.pipe.ts b/apps/api/src/common/zod-validation.pipe.ts new file mode 100644 index 0000000..bb096b6 --- /dev/null +++ b/apps/api/src/common/zod-validation.pipe.ts @@ -0,0 +1,24 @@ +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; +import type { ZodTypeAny, z } from 'zod'; + +/** + * Validates request bodies against a shared Zod schema. Failures become a + * 400 with field-level details: { field: [i18nKey, …] } — the web client + * renders the keys next to the matching inputs. + */ +@Injectable() +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: Schema) {} + + transform(value: unknown): z.infer { + const result = this.schema.safeParse(value); + if (result.success) return result.data; + + const details: Record = {}; + for (const issue of result.error.issues) { + const field = issue.path.join('.') || '_'; + (details[field] ??= []).push(issue.message); + } + throw new BadRequestException({ code: 'bad_request', details }); + } +} diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 2e068e3..2319010 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -2,9 +2,11 @@ import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; import { HealthResponse, healthResponse } from '@dorfteich/shared'; import type { Response } from 'express'; +import { Public } from '../auth/auth.guard'; import { AppConfig } from '../config/app-config.service'; import { ReadinessService } from './readiness.service'; +@Public() @Controller() export class HealthController { constructor( diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b0052a8..9bb4c3e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; +import cookieParser from 'cookie-parser'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; import { apiEnvSchema, parseEnv } from '@dorfteich/shared'; @@ -33,6 +34,7 @@ async function bootstrap(): Promise { // One reverse-proxy hop (Caddy) in front of us: req.ip must reflect the // real client for rate limiting and audit logs. app.set('trust proxy', 1); + app.use(cookieParser()); app.setGlobalPrefix('api/v1'); app.enableShutdownHooks(); diff --git a/apps/api/src/testing/test-app.ts b/apps/api/src/testing/test-app.ts new file mode 100644 index 0000000..0e92efc --- /dev/null +++ b/apps/api/src/testing/test-app.ts @@ -0,0 +1,34 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import cookieParser from 'cookie-parser'; + +import { AppModule } from '../app.module'; + +/** + * Boots the full application for e2e tests, mirroring main.ts middleware. + * Requires TEST_DATABASE_URL; DATABASE_URL is pointed at it so the app + * under test uses the test database. + */ +export async function createTestApp(): Promise { + process.env.NODE_ENV = 'test'; + if (process.env.TEST_DATABASE_URL) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + } + process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent'; + + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + const app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.setGlobalPrefix('api/v1'); + await app.init(); + return app; +} + +/** Extracts the dt_session cookie pair ("name=value") from a response. */ +export function sessionCookieOf(res: { headers: Record }): string { + const header = res.headers['set-cookie']; + const cookies = Array.isArray(header) ? header : [header].filter(Boolean); + const session = (cookies as string[]).find((c) => c.startsWith('dt_session=')); + if (!session) throw new Error('response carries no dt_session cookie'); + return session.split(';')[0]!; +} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index f3cd300..df1f47b 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -49,7 +49,7 @@ export class UsersService { } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const target = (error.meta?.target as string[] | undefined)?.[0] ?? 'username'; - throw new ConflictException({ field: target }); + throw new ConflictException({ details: { [target]: ['validation.taken'] } }); } throw error; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 702516c..9632024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: argon2: specifier: ^0.44.0 version: 0.44.0 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) @@ -81,6 +84,9 @@ importers: '@swc/core': specifier: ^1.10.0 version: 1.15.43 + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@5.0.6) '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -1399,6 +1405,11 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -1897,6 +1908,13 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -4519,6 +4537,10 @@ snapshots: dependencies: '@types/node': 26.1.0 + '@types/cookie-parser@1.4.10(@types/express@5.0.6)': + dependencies: + '@types/express': 5.0.6 + '@types/cookiejar@2.1.5': {} '@types/deep-eql@4.0.2': {} @@ -5091,6 +5113,13 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {}