diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index 5ec1133..ac7c4fd 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -10,6 +10,11 @@ JWT_REFRESH_EXPIRES_IN="7d" PORT=3001 FRONTEND_ORIGIN="http://localhost:3000" +# Refresh-token cookie requires HTTPS by default. Set to "false" only for +# local HTTP development - every real deployment must leave this unset (or +# "true"). +COOKIE_SECURE=false + # Lokales Verzeichnis fuer hochgeladene Dokumente/Bilder (Modul 29). # Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes # Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7. diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index b1cac13..5fd0448 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.7", + "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", "bcryptjs": "^3.0.3", @@ -2664,6 +2665,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index bd08671..c821d88 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -28,6 +28,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.7", + "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", "bcryptjs": "^3.0.3", diff --git a/ApiCore-Backend/prisma/migrations/20260825083604_add_auth_audit_actions/migration.sql b/ApiCore-Backend/prisma/migrations/20260825083604_add_auth_audit_actions/migration.sql new file mode 100644 index 0000000..5adf5f4 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260825083604_add_auth_audit_actions/migration.sql @@ -0,0 +1,12 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "AuditAction" ADD VALUE 'LOGIN'; +ALTER TYPE "AuditAction" ADD VALUE 'LOGIN_FAILED'; +ALTER TYPE "AuditAction" ADD VALUE 'LOGOUT'; +ALTER TYPE "AuditAction" ADD VALUE 'TOKEN_REFRESHED'; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 8ee7514..59825a2 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -79,6 +79,10 @@ enum AuditAction { UPDATE DEACTIVATE DELETE + LOGIN + LOGIN_FAILED + LOGOUT + TOKEN_REFRESHED } model AuditLog { diff --git a/ApiCore-Backend/src/app.config.ts b/ApiCore-Backend/src/app.config.ts index b9895a1..cb3a599 100644 --- a/ApiCore-Backend/src/app.config.ts +++ b/ApiCore-Backend/src/app.config.ts @@ -1,13 +1,13 @@ -import { - INestApplication, - RequestMethod, - ValidationPipe, - VersioningType, -} from '@nestjs/common'; +import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common'; +import { NestExpressApplication } from '@nestjs/platform-express'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; -export function configureApp(app: INestApplication): void { +export function configureApp(app: NestExpressApplication): void { + // Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the + // real client IP from X-Forwarded-For — required for per-client throttling + // to work correctly rather than rate-limiting all traffic together. + app.set('trust proxy', 1); app.use(helmet()); app.use(cookieParser()); diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index aaa7ef1..bfb85d8 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { PrismaModule } from './prisma/prisma.module'; import { HealthModule } from './health/health.module'; import { AuditModule } from './audit/audit.module'; @@ -41,6 +42,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]), PrismaModule, AuditModule, HealthModule, @@ -80,6 +82,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; providers: [ { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: RolesGuard }, + { provide: APP_GUARD, useClass: ThrottlerGuard }, ], }) export class AppModule {} diff --git a/ApiCore-Backend/src/auth/auth.controller.ts b/ApiCore-Backend/src/auth/auth.controller.ts index 92318ac..53b4652 100644 --- a/ApiCore-Backend/src/auth/auth.controller.ts +++ b/ApiCore-Backend/src/auth/auth.controller.ts @@ -9,6 +9,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; import { Public } from './decorators/public.decorator'; import { LoginDto } from './dto/login.dto'; @@ -18,6 +19,9 @@ import { parseDurationToMs } from './util/duration'; const REFRESH_TOKEN_COOKIE = 'refreshToken'; const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth'; +// Stricter than the global default: bounds brute-force/credential-stuffing +// attempts against login and refresh-token guessing specifically. +const AUTH_THROTTLE = { default: { limit: 10, ttl: 60000 } }; @ApiTags('auth') @Controller('auth') @@ -27,7 +31,10 @@ export class AuthController { private setRefreshTokenCookie(res: Response, refreshToken: string): void { res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + // Explicit opt-out for local HTTP dev only; every real deployment must + // set COOKIE_SECURE=true so the refresh-token cookie is never sent + // over plaintext HTTP. + secure: process.env.COOKIE_SECURE !== 'false', sameSite: 'lax', path: REFRESH_TOKEN_COOKIE_PATH, maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'), @@ -49,6 +56,7 @@ export class AuthController { } @Public() + @Throttle(AUTH_THROTTLE) @Post('login') @HttpCode(HttpStatus.OK) async login( @@ -64,6 +72,7 @@ export class AuthController { } @Public() + @Throttle(AUTH_THROTTLE) @Post('refresh') @HttpCode(HttpStatus.OK) async refresh( diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts index ad37990..96c8ad8 100644 --- a/ApiCore-Backend/src/auth/auth.service.spec.ts +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs'; import { AuthService } from './auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; +import { AuditLogService } from '../audit/audit-log.service'; describe('AuthService', () => { let authService: AuthService; @@ -16,6 +17,7 @@ describe('AuthService', () => { }; }; let jwtService: { sign: jest.Mock }; + let auditLog: { record: jest.Mock }; const activeUser = { id: 'user-1', @@ -37,11 +39,13 @@ describe('AuthService', () => { }, }; jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; authService = new AuthService( usersService as unknown as UsersService, prisma as unknown as PrismaService, jwtService as never, + auditLog as unknown as AuditLogService, ); }); @@ -85,6 +89,36 @@ describe('AuthService', () => { [{ data: { userId: string } }], ]; expect(createArg.data.userId).toBe(activeUser.id); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }), + ); + }); + + it('records a LOGIN_FAILED audit entry on wrong password', async () => { + usersService.findByEmail.mockResolvedValue(activeUser); + await expect( + authService.login(activeUser.email, 'wrong-password'), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'LOGIN_FAILED', + userId: activeUser.id, + }), + ); + }); + + it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => { + usersService.findByEmail.mockResolvedValue(null); + await expect( + authService.login('missing@example.com', 'x'), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'LOGIN_FAILED', + entityId: 'missing@example.com', + userId: undefined, + }), + ); }); }); @@ -137,14 +171,39 @@ describe('AuthService', () => { expect(prisma.refreshToken.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'rt-1' } }), ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'TOKEN_REFRESHED', + userId: activeUser.id, + }), + ); }); }); describe('logout', () => { - it('revokes the matching refresh token', async () => { - prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 }); + it('revokes the matching refresh token and records a LOGOUT audit entry', async () => { + prisma.refreshToken.findUnique.mockResolvedValue({ + id: 'rt-1', + userId: activeUser.id, + revokedAt: null, + }); + prisma.refreshToken.update.mockResolvedValue({}); + await authService.logout('some-token'); - expect(prisma.refreshToken.updateMany).toHaveBeenCalled(); + + expect(prisma.refreshToken.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'rt-1' } }), + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'LOGOUT', userId: activeUser.id }), + ); + }); + + it('does nothing when the token is unknown or already revoked', async () => { + prisma.refreshToken.findUnique.mockResolvedValue(null); + await authService.logout('unknown-token'); + expect(prisma.refreshToken.update).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); }); }); }); diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index a8f1062..65b2747 100644 --- a/ApiCore-Backend/src/auth/auth.service.ts +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -4,6 +4,7 @@ import * as bcrypt from 'bcryptjs'; import * as crypto from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; +import { AuditLogService } from '../audit/audit-log.service'; import { AuthenticatedUser } from './types/authenticated-user'; import { parseDurationToMs } from './util/duration'; @@ -20,6 +21,7 @@ export class AuthService { private readonly usersService: UsersService, private readonly prisma: PrismaService, private readonly jwtService: JwtService, + private readonly auditLog: AuditLogService, ) {} private hashRefreshToken(token: string): string { @@ -54,10 +56,22 @@ export class AuthService { async login(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); if (!user || !user.isActive) { + await this.auditLog.record({ + entityType: 'AuthSession', + entityId: user?.id ?? email, + action: 'LOGIN_FAILED', + userId: user?.id, + }); throw new UnauthorizedException('Invalid credentials'); } const passwordMatches = await bcrypt.compare(password, user.passwordHash); if (!passwordMatches) { + await this.auditLog.record({ + entityType: 'AuthSession', + entityId: user.id, + action: 'LOGIN_FAILED', + userId: user.id, + }); throw new UnauthorizedException('Invalid credentials'); } const authenticatedUser: AuthenticatedUser = { @@ -67,6 +81,12 @@ export class AuthService { }; const accessToken = this.signAccessToken(authenticatedUser); const refreshToken = await this.issueRefreshToken(user.id); + await this.auditLog.record({ + entityType: 'AuthSession', + entityId: user.id, + action: 'LOGIN', + userId: user.id, + }); return { accessToken, refreshToken }; } @@ -93,14 +113,32 @@ export class AuthService { }; const accessToken = this.signAccessToken(authenticatedUser); const newRefreshToken = await this.issueRefreshToken(user.id); + await this.auditLog.record({ + entityType: 'AuthSession', + entityId: user.id, + action: 'TOKEN_REFRESHED', + userId: user.id, + }); return { accessToken, refreshToken: newRefreshToken }; } async logout(refreshToken: string): Promise { const tokenHash = this.hashRefreshToken(refreshToken); - await this.prisma.refreshToken.updateMany({ - where: { tokenHash, revokedAt: null }, + const stored = await this.prisma.refreshToken.findUnique({ + where: { tokenHash }, + }); + if (!stored || stored.revokedAt) { + return; + } + await this.prisma.refreshToken.update({ + where: { id: stored.id }, data: { revokedAt: new Date() }, }); + await this.auditLog.record({ + entityType: 'AuthSession', + entityId: stored.userId, + action: 'LOGOUT', + userId: stored.userId, + }); } } diff --git a/ApiCore-Backend/src/main.ts b/ApiCore-Backend/src/main.ts index 51f260e..01e2b23 100644 --- a/ApiCore-Backend/src/main.ts +++ b/ApiCore-Backend/src/main.ts @@ -1,10 +1,11 @@ import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { configureApp } from './app.config'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); configureApp(app); diff --git a/ApiCore-Backend/test/auth-throttle.e2e-spec.ts b/ApiCore-Backend/test/auth-throttle.e2e-spec.ts new file mode 100644 index 0000000..d3f38e9 --- /dev/null +++ b/ApiCore-Backend/test/auth-throttle.e2e-spec.ts @@ -0,0 +1,48 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; + +const LOGIN_THROTTLE_LIMIT = 10; + +describe('Auth login throttling (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const email = `auth-throttle-e2e-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let userId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const user = await prisma.user.create({ + data: { email, passwordHash, name: 'Throttle E2E User', role: 'STAFF' }, + }); + userId = user.id; + }); + + afterAll(async () => { + await prisma.refreshToken.deleteMany({ where: { userId } }); + await prisma.auditLog.deleteMany({ where: { userId } }); + await prisma.user.delete({ where: { id: userId } }); + await app.close(); + }); + + it('rejects login attempts beyond the per-minute limit with 429', async () => { + for (let attempt = 0; attempt < LOGIN_THROTTLE_LIMIT; attempt += 1) { + await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email, password: 'wrong-password' }) + .expect(401); + } + + await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email, password: 'wrong-password' }) + .expect(429); + }); +}); diff --git a/ApiCore-Backend/test/utils/create-test-app.ts b/ApiCore-Backend/test/utils/create-test-app.ts index bdbab74..1183fe9 100644 --- a/ApiCore-Backend/test/utils/create-test-app.ts +++ b/ApiCore-Backend/test/utils/create-test-app.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { App } from 'supertest/types'; import { AppModule } from '../../src/app.module'; import { configureApp } from '../../src/app.config'; @@ -9,7 +10,7 @@ export async function createTestApp(): Promise> { imports: [AppModule], }).compile(); - const app = moduleFixture.createNestApplication>(); + const app = moduleFixture.createNestApplication(); configureApp(app); await app.init(); return app; diff --git a/deploy/k8s/backend/configmap.example.yaml b/deploy/k8s/backend/configmap.example.yaml index cf0a3ef..16de638 100644 --- a/deploy/k8s/backend/configmap.example.yaml +++ b/deploy/k8s/backend/configmap.example.yaml @@ -8,3 +8,4 @@ data: JWT_REFRESH_EXPIRES_IN: "7d" FRONTEND_ORIGIN: "https://apicore.example.com" DOCUMENTS_STORAGE_PATH: "/data/documents" + COOKIE_SECURE: "true" diff --git a/deploy/k8s/backend/ingress.yaml b/deploy/k8s/backend/ingress.yaml index 9cab550..9600a5b 100644 --- a/deploy/k8s/backend/ingress.yaml +++ b/deploy/k8s/backend/ingress.yaml @@ -4,8 +4,15 @@ metadata: name: apicore-backend annotations: nginx.ingress.kubernetes.io/rewrite-target: /$2 + # Requires cert-manager with a ClusterIssuer named "letsencrypt-prod" + # (or swap for whatever issuer/cert mechanism the cluster already uses). + cert-manager.io/cluster-issuer: letsencrypt-prod spec: ingressClassName: nginx + tls: + - hosts: + - api.apicore.example.com + secretName: apicore-backend-tls rules: - host: api.apicore.example.com http: diff --git a/deploy/k8s/frontend/ingress.yaml b/deploy/k8s/frontend/ingress.yaml index 589d9ac..08a91b9 100644 --- a/deploy/k8s/frontend/ingress.yaml +++ b/deploy/k8s/frontend/ingress.yaml @@ -2,8 +2,16 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: apicore-frontend + annotations: + # Requires cert-manager with a ClusterIssuer named "letsencrypt-prod" + # (or swap for whatever issuer/cert mechanism the cluster already uses). + cert-manager.io/cluster-issuer: letsencrypt-prod spec: ingressClassName: nginx + tls: + - hosts: + - apicore.example.com + secretName: apicore-frontend-tls rules: - host: apicore.example.com http: