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",
|
"@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",
|
||||||
|
|||||||
@ -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) => ({
|
||||||
|
|||||||
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
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { 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(
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
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) {
|
} 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
29
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user