Add authentication: signup, verification, sessions, password reset

AuthModule implements the M1 core as one coherent unit:

Signup (#13): POST signup/verify-email/resend-verification with shared
Zod validation (field-level error details), double opt-in via hashed
single-use tokens (24h, superseding reissue), registration_mode
enforcement, and per-IP rate limits.

Sessions (#14): opaque 32-byte cookie tokens stored as SHA-256 row
ids, sliding 30-day expiry (refresh at most hourly), global AuthGuard
with @Public() opt-out attaching the user to every request, CSRF
origin check on mutating requests, per-account login backoff (5/15min,
reset on success), generic 401 for wrong-vs-unknown credentials,
logout with immediate invalidation, GET /auth/me.

Reset (#15): forgot-password without account enumeration, one-hour
single-use tokens, reset destroys all existing sessions.

A 14-case supertest e2e suite drives every flow against the test
database, reading verification/reset links from the mail outbox.

Closes #13
Closes #14
Closes #15

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-05 05:22:31 +02:00
parent f00fb19f32
commit bed9fc9307
16 changed files with 864 additions and 2 deletions

View File

@ -20,6 +20,7 @@
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@prisma/client": "^6.3.0", "@prisma/client": "^6.3.0",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"i18next": "^26.3.4", "i18next": "^26.3.4",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
@ -33,6 +34,7 @@
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/testing": "^11.0.0", "@nestjs/testing": "^11.0.0",
"@swc/core": "^1.10.0", "@swc/core": "^1.10.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core'; import { APP_FILTER } from '@nestjs/core';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { AuthModule } from './auth/auth.module';
import { ApiExceptionFilter } from './common/api-exception.filter'; import { ApiExceptionFilter } from './common/api-exception.filter';
import { AppConfig } from './config/app-config.service'; import { AppConfig } from './config/app-config.service';
import { ConfigModule } from './config/config.module'; import { ConfigModule } from './config/config.module';
@ -18,6 +19,7 @@ import { UsersModule } from './users/users.module';
RateLimitModule, RateLimitModule,
MailModule, MailModule,
UsersModule, UsersModule,
AuthModule,
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({
inject: [AppConfig], inject: [AppConfig],
useFactory: (config: AppConfig) => ({ useFactory: (config: AppConfig) => ({

View File

@ -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<string> {
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<string | null> {
// 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');
}

View File

@ -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<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);
}
@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'],
);
this.setSessionCookie(response, sessionToken);
return toCurrentUser(user);
}
@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: '/' });
}
@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);
}
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: '/',
});
}
}

View File

@ -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<string> {
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);
});
});

View File

@ -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<AuthedRequest>().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<boolean> {
const request = context.switchToHttp().getRequest<AuthedRequest>();
const rawToken = (request.cookies as Record<string, string> | 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<boolean>(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' });
}
}
}

View File

@ -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 {}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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';
}

View File

@ -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<string> {
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<ValidatedSession | null> {
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<void> {
await this.prisma.session.deleteMany({ where: { id: hashSessionToken(raw) } });
}
async destroyById(sessionId: string, userId: string): Promise<boolean> {
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<void> {
await this.prisma.session.deleteMany({
where: { userId, ...(exceptSessionId ? { id: { not: exceptSessionId } } : {}) },
});
}
listForUser(userId: string): Promise<Session[]> {
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;
}

View File

@ -27,7 +27,14 @@ export class ApiExceptionFilter implements ExceptionFilter {
// Catalogued codes get the localized text; uncatalogued ones keep // Catalogued codes get the localized text; uncatalogued ones keep
// the (developer-provided, English) exception message as fallback. // the (developer-provided, English) exception message as fallback.
const message = translateErrorCode(code, language) ?? exception.message; 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<string, string[]> }).details
: undefined;
response.status(status).json(apiError(code, message, details));
return; return;
} }

View File

@ -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<Schema extends ZodTypeAny> implements PipeTransform {
constructor(private readonly schema: Schema) {}
transform(value: unknown): z.infer<Schema> {
const result = this.schema.safeParse(value);
if (result.success) return result.data;
const details: Record<string, string[]> = {};
for (const issue of result.error.issues) {
const field = issue.path.join('.') || '_';
(details[field] ??= []).push(issue.message);
}
throw new BadRequestException({ code: 'bad_request', details });
}
}

View File

@ -2,9 +2,11 @@ import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
import { HealthResponse, healthResponse } from '@dorfteich/shared'; import { HealthResponse, healthResponse } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
import { Public } from '../auth/auth.guard';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { ReadinessService } from './readiness.service'; import { ReadinessService } from './readiness.service';
@Public()
@Controller() @Controller()
export class HealthController { export class HealthController {
constructor( constructor(

View File

@ -1,5 +1,6 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { apiEnvSchema, parseEnv } from '@dorfteich/shared'; import { apiEnvSchema, parseEnv } from '@dorfteich/shared';
@ -33,6 +34,7 @@ async function bootstrap(): Promise<void> {
// One reverse-proxy hop (Caddy) in front of us: req.ip must reflect the // One reverse-proxy hop (Caddy) in front of us: req.ip must reflect the
// real client for rate limiting and audit logs. // real client for rate limiting and audit logs.
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.use(cookieParser());
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
app.enableShutdownHooks(); app.enableShutdownHooks();

View File

@ -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<INestApplication> {
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, unknown> }): 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]!;
}

View File

@ -49,7 +49,7 @@ export class UsersService {
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const target = (error.meta?.target as string[] | undefined)?.[0] ?? 'username'; const target = (error.meta?.target as string[] | undefined)?.[0] ?? 'username';
throw new ConflictException({ field: target }); throw new ConflictException({ details: { [target]: ['validation.taken'] } });
} }
throw error; throw error;
} }

29
pnpm-lock.yaml generated
View File

@ -47,6 +47,9 @@ importers:
argon2: argon2:
specifier: ^0.44.0 specifier: ^0.44.0
version: 0.44.0 version: 0.44.0
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
i18next: i18next:
specifier: ^26.3.4 specifier: ^26.3.4
version: 26.3.4(typescript@5.9.3) version: 26.3.4(typescript@5.9.3)
@ -81,6 +84,9 @@ importers:
'@swc/core': '@swc/core':
specifier: ^1.10.0 specifier: ^1.10.0
version: 1.15.43 version: 1.15.43
'@types/cookie-parser':
specifier: ^1.4.10
version: 1.4.10(@types/express@5.0.6)
'@types/express': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.6 version: 5.0.6
@ -1399,6 +1405,11 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie-parser@1.4.10':
resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==}
peerDependencies:
'@types/express': '*'
'@types/cookiejar@2.1.5': '@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
@ -1897,6 +1908,13 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 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: cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'} engines: {node: '>=6.6.0'}
@ -4519,6 +4537,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 26.1.0 '@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/cookiejar@2.1.5': {}
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
@ -5091,6 +5113,13 @@ snapshots:
convert-source-map@2.0.0: {} 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-signature@1.2.2: {}
cookie@0.7.2: {} cookie@0.7.2: {}