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:
parent
f00fb19f32
commit
bed9fc9307
@ -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",
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
56
apps/api/src/auth/auth-tokens.service.ts
Normal file
56
apps/api/src/auth/auth-tokens.service.ts
Normal 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');
|
||||
}
|
||||
127
apps/api/src/auth/auth.controller.ts
Normal file
127
apps/api/src/auth/auth.controller.ts
Normal 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: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
193
apps/api/src/auth/auth.e2e.db.test.ts
Normal file
193
apps/api/src/auth/auth.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
101
apps/api/src/auth/auth.guard.ts
Normal file
101
apps/api/src/auth/auth.guard.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/api/src/auth/auth.module.ts
Normal file
25
apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||
160
apps/api/src/auth/auth.service.ts
Normal file
160
apps/api/src/auth/auth.service.ts
Normal 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';
|
||||
}
|
||||
98
apps/api/src/auth/sessions.service.ts
Normal file
98
apps/api/src/auth/sessions.service.ts
Normal 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;
|
||||
}
|
||||
@ -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<string, string[]> }).details
|
||||
: undefined;
|
||||
response.status(status).json(apiError(code, message, details));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
24
apps/api/src/common/zod-validation.pipe.ts
Normal file
24
apps/api/src/common/zod-validation.pipe.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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<void> {
|
||||
// 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();
|
||||
|
||||
|
||||
34
apps/api/src/testing/test-app.ts
Normal file
34
apps/api/src/testing/test-app.ts
Normal 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]!;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user