From 62004483cfaddf6d85e22605c3f7a5b455d7b801 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 27 Aug 2026 00:25:34 +0200 Subject: [PATCH] feat: MFA (TOTP) und Self-Service-Profilseite Benutzer koennen jetzt selbst Zwei-Faktor-Authentifizierung einrichten und ihr Profil verwalten (Name, E-Mail, Passwort, Profilbild). Backend: - Neues MfaModule (src/auth/mfa/): AES-256-GCM-verschluesselte TOTP-Secrets (otplib), QR-Code-Erzeugung, kurzlebige Setup-/ Challenge-JWTs fuer den 2-Schritt-Login-Flow - Neues ProfileModule (src/profile/): PATCH /profile, POST /profile/password, POST/DELETE/GET /profile/avatar, POST /profile/mfa/enroll(/confirm), POST /profile/mfa/disable - alle self-scoped ueber actor.id, ohne @RequirePermission (siehe ALLOWLIST in authorization-coverage.e2e-spec.ts) - AuthService.login() zweistufig: bei aktivem MFA erst { mfaRequired, mfaToken } statt Tokens, neuer Endpunkt POST /auth/mfa/verify schliesst den Flow ab - Admin-Recovery: POST /users/:id/mfa/reset (users:update) fuer verlorene MFA-Geraete, da es keinen E-Mail-Reset-Flow gibt Frontend: - Login-Seite mit zweitem Schritt fuer den TOTP-Code - Neue Profilseite (/profile, ausserhalb des admin-only Settings-Bereichs): Profilbild, Name/E-Mail, Passwort, MFA-Ein-/ Ausrichtung mit QR-Code-Dialog - Header zeigt jetzt Avatar mit Dropdown (Profil/Abmelden) statt nur E-Mail-Text - Admin-Users-Tabelle zeigt MFA-Status + Reset-Button Haertung nach Security-Review (Backend/Frontend-Diff, kein CRITICAL, mehrere MEDIUM behoben): - Access-Token wird bei Passwortwechsel sofort ungueltig (User.passwordChangedAt + JWT-iat-Vergleich in PermissionResolverService.isTokenValid, vorher isUserActive) statt erst nach bis zu 15 Minuten - E-Mail-Aenderung verlangt jetzt aktuelles Passwort - POST /profile/password und /profile/mfa/disable haben jetzt das gleiche strikte Throttling wie Login (SENSITIVE_ACTION_THROTTLE) - Kein stiller leerer Verschluesselungs-Key-Fallback mehr (wirft jetzt explizit) Bewusst zurueckgestellt (dokumentiert, kein aktiver Exploit-Pfad): dedizierter Verschluesselungs-Key fuer MFA-Secrets statt JWT_ACCESS_SECRET-Ableitung, dedizierte users:mfa-reset-Permission statt users:update, separate JWT-Secrets pro Token-Zweck. Verifiziert: tsc/eslint clean, 297/297 Backend- + 31/31 Frontend-Unit-Tests, Build clean auf beiden Projekten. e2e weiterhin durch bekanntes WSL2/Postgres-Problem blockiert. Unabhaengiges Security- und Code-Quality-Review durchgefuehrt (0 CRITICAL, 0 HIGH). --- ApiCore-Backend/package-lock.json | 92 +++++ ApiCore-Backend/package.json | 6 +- .../migration.sql | 8 + .../migration.sql | 2 + ApiCore-Backend/prisma/schema.prisma | 14 + ApiCore-Backend/src/app.module.ts | 2 + ApiCore-Backend/src/auth/auth.controller.ts | 36 +- ApiCore-Backend/src/auth/auth.module.ts | 3 +- ApiCore-Backend/src/auth/auth.service.spec.ts | 100 ++++++ ApiCore-Backend/src/auth/auth.service.ts | 60 +++- .../src/auth/dto/mfa-verify.dto.ts | 14 + .../src/auth/guards/jwt-auth.guard.spec.ts | 27 +- .../src/auth/guards/jwt-auth.guard.ts | 20 +- .../src/auth/mfa/mfa-crypto.util.spec.ts | 37 ++ .../src/auth/mfa/mfa-crypto.util.ts | 53 +++ ApiCore-Backend/src/auth/mfa/mfa.module.ts | 10 + .../src/auth/mfa/mfa.service.spec.ts | 182 ++++++++++ ApiCore-Backend/src/auth/mfa/mfa.service.ts | 108 ++++++ .../src/auth/mfa/totp.util.spec.ts | 48 +++ ApiCore-Backend/src/auth/mfa/totp.util.ts | 32 ++ .../permission-resolver.service.spec.ts | 47 ++- .../permission-resolver.service.ts | 25 +- .../src/auth/strategies/jwt.strategy.ts | 10 +- .../src/auth/types/authenticated-user.ts | 2 + ApiCore-Backend/src/auth/util/throttle.ts | 4 + .../src/profile/avatar-mime-types.ts | 17 + .../src/profile/dto/change-password.dto.ts | 13 + .../src/profile/dto/disable-mfa.dto.ts | 8 + .../src/profile/dto/mfa-confirm.dto.ts | 14 + .../src/profile/dto/update-profile.dto.ts | 22 ++ .../src/profile/profile.controller.ts | 140 ++++++++ ApiCore-Backend/src/profile/profile.module.ts | 11 + .../src/profile/profile.service.spec.ts | 317 ++++++++++++++++++ .../src/profile/profile.service.ts | 239 +++++++++++++ ApiCore-Backend/src/users/users.controller.ts | 6 + .../src/users/users.service.spec.ts | 34 ++ ApiCore-Backend/src/users/users.service.ts | 19 ++ .../test/authorization-coverage.e2e-spec.ts | 8 + ApiCore-Frontend/src/app/(app)/layout.tsx | 44 ++- .../app/(app)/profile/mfa-section.spec.tsx | 68 ++++ .../src/app/(app)/profile/mfa-section.tsx | 232 +++++++++++++ .../src/app/(app)/profile/page.spec.tsx | 90 +++++ .../src/app/(app)/profile/page.tsx | 276 +++++++++++++++ .../app/(app)/settings/users/page.spec.tsx | 2 + .../src/app/(app)/settings/users/page.tsx | 74 ++-- .../src/app/(auth)/login/page.spec.tsx | 46 ++- .../src/app/(auth)/login/page.tsx | 139 ++++++-- .../src/components/user-avatar.spec.tsx | 31 ++ .../src/components/user-avatar.tsx | 59 ++++ ApiCore-Frontend/src/lib/auth/api.ts | 28 +- .../src/lib/auth/auth-context.tsx | 45 ++- ApiCore-Frontend/src/lib/profile/api.ts | 57 ++++ ApiCore-Frontend/src/lib/profile/types.ts | 19 ++ ApiCore-Frontend/src/lib/users/api.ts | 4 + ApiCore-Frontend/src/lib/users/types.ts | 1 + 55 files changed, 2876 insertions(+), 129 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20260826213033_add_mfa_and_avatar/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20260826221315_add_password_changed_at/migration.sql create mode 100644 ApiCore-Backend/src/auth/dto/mfa-verify.dto.ts create mode 100644 ApiCore-Backend/src/auth/mfa/mfa-crypto.util.spec.ts create mode 100644 ApiCore-Backend/src/auth/mfa/mfa-crypto.util.ts create mode 100644 ApiCore-Backend/src/auth/mfa/mfa.module.ts create mode 100644 ApiCore-Backend/src/auth/mfa/mfa.service.spec.ts create mode 100644 ApiCore-Backend/src/auth/mfa/mfa.service.ts create mode 100644 ApiCore-Backend/src/auth/mfa/totp.util.spec.ts create mode 100644 ApiCore-Backend/src/auth/mfa/totp.util.ts create mode 100644 ApiCore-Backend/src/auth/util/throttle.ts create mode 100644 ApiCore-Backend/src/profile/avatar-mime-types.ts create mode 100644 ApiCore-Backend/src/profile/dto/change-password.dto.ts create mode 100644 ApiCore-Backend/src/profile/dto/disable-mfa.dto.ts create mode 100644 ApiCore-Backend/src/profile/dto/mfa-confirm.dto.ts create mode 100644 ApiCore-Backend/src/profile/dto/update-profile.dto.ts create mode 100644 ApiCore-Backend/src/profile/profile.controller.ts create mode 100644 ApiCore-Backend/src/profile/profile.module.ts create mode 100644 ApiCore-Backend/src/profile/profile.service.spec.ts create mode 100644 ApiCore-Backend/src/profile/profile.service.ts create mode 100644 ApiCore-Frontend/src/app/(app)/profile/mfa-section.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/profile/mfa-section.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/profile/page.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/profile/page.tsx create mode 100644 ApiCore-Frontend/src/components/user-avatar.spec.tsx create mode 100644 ApiCore-Frontend/src/components/user-avatar.tsx create mode 100644 ApiCore-Frontend/src/lib/profile/api.ts create mode 100644 ApiCore-Frontend/src/lib/profile/types.ts diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index e9ce368..1f120b4 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -24,6 +24,7 @@ "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", "helmet": "^8.3.0", + "otplib": "^13.5.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pdfkit": "^0.19.1", @@ -2697,6 +2698,74 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@otplib/core": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.5.0.tgz", + "integrity": "sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.5.0.tgz", + "integrity": "sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.5.0", + "@otplib/uri": "13.5.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.5.0.tgz", + "integrity": "sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.5.0", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.5.0.tgz", + "integrity": "sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0", + "@otplib/core": "13.5.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/totp": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.5.0.tgz", + "integrity": "sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.5.0", + "@otplib/hotp": "13.5.0", + "@otplib/uri": "13.5.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.5.0.tgz", + "integrity": "sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.5.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3104,6 +3173,15 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@scure/base": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", + "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", @@ -9676,6 +9754,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/otplib": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.5.0.tgz", + "integrity": "sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.5.0", + "@otplib/hotp": "13.5.0", + "@otplib/plugin-base32-scure": "13.5.0", + "@otplib/plugin-crypto-noble": "13.5.0", + "@otplib/totp": "13.5.0", + "@otplib/uri": "13.5.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index cf99fa0..cda3ae4 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -32,15 +32,16 @@ "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", - "prisma": "^7.9.1", "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", "helmet": "^8.3.0", + "otplib": "^13.5.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pdfkit": "^0.19.1", + "prisma": "^7.9.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -87,6 +88,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "transformIgnorePatterns": [ + "node_modules/(?!(otplib|@otplib|@scure|@noble)/)" + ], "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/ApiCore-Backend/prisma/migrations/20260826213033_add_mfa_and_avatar/migration.sql b/ApiCore-Backend/prisma/migrations/20260826213033_add_mfa_and_avatar/migration.sql new file mode 100644 index 0000000..9c9a156 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260826213033_add_mfa_and_avatar/migration.sql @@ -0,0 +1,8 @@ +-- AlterEnum +ALTER TYPE "AuditAction" ADD VALUE 'MFA_ENABLED'; +ALTER TYPE "AuditAction" ADD VALUE 'MFA_DISABLED'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "mfaEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "mfaSecret" TEXT, +ADD COLUMN "avatarPath" TEXT; diff --git a/ApiCore-Backend/prisma/migrations/20260826221315_add_password_changed_at/migration.sql b/ApiCore-Backend/prisma/migrations/20260826221315_add_password_changed_at/migration.sql new file mode 100644 index 0000000..3cdfa00 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260826221315_add_password_changed_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "passwordChangedAt" TIMESTAMP(3); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 61f1136..7ba5a76 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -76,6 +76,18 @@ model User { updatedAt DateTime @updatedAt deactivatedAt DateTime? + // TOTP-Secret AES-256-GCM-verschluesselt gespeichert (siehe + // src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB. + mfaEnabled Boolean @default(false) + mfaSecret String? + avatarPath String? + + // Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens, + // die vor diesem Zeitpunkt ausgestellt wurden (siehe + // PermissionResolverService.isTokenValid), damit ein gestohlener Token + // nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert. + passwordChangedAt DateTime? + refreshTokens RefreshToken[] inspections Inspection[] @@ -124,6 +136,8 @@ enum AuditAction { LOGIN_FAILED LOGOUT TOKEN_REFRESHED + MFA_ENABLED + MFA_DISABLED } model AuditLog { diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 6039478..284347b 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -38,6 +38,7 @@ import { PublicModule } from './public/public.module'; import { TaskTemplatesModule } from './task-templates/task-templates.module'; import { SwarmControlModule } from './swarm-control/swarm-control.module'; import { WeatherModule } from './weather/weather.module'; +import { ProfileModule } from './profile/profile.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; @@ -83,6 +84,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module'; TaskTemplatesModule, SwarmControlModule, WeatherModule, + ProfileModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/auth/auth.controller.ts b/ApiCore-Backend/src/auth/auth.controller.ts index 66cd70a..dd1bd83 100644 --- a/ApiCore-Backend/src/auth/auth.controller.ts +++ b/ApiCore-Backend/src/auth/auth.controller.ts @@ -17,14 +17,18 @@ import { CurrentUser } from './decorators/current-user.decorator'; import type { AuthenticatedUser } from './types/authenticated-user'; import { LoginDto } from './dto/login.dto'; import { AccessTokenDto } from './dto/access-token.dto'; +import { MfaVerifyDto } from './dto/mfa-verify.dto'; import { AuthService, CurrentUserProfile } from './auth.service'; import { parseDurationToMs } from './util/duration'; +import { SENSITIVE_ACTION_THROTTLE } from './util/throttle'; + +interface MfaRequiredResponse { + mfaRequired: true; + mfaToken: string; +} 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') @@ -59,23 +63,37 @@ export class AuthController { } @Public() - @Throttle(AUTH_THROTTLE) + @Throttle(SENSITIVE_ACTION_THROTTLE) @Post('login') @HttpCode(HttpStatus.OK) async login( @Body() dto: LoginDto, @Res({ passthrough: true }) res: Response, + ): Promise { + const result = await this.authService.login(dto.email, dto.password); + if (result.mfaRequired) { + return { mfaRequired: true, mfaToken: result.mfaToken }; + } + this.setRefreshTokenCookie(res, result.refreshToken); + return { accessToken: result.accessToken }; + } + + @Public() + @Throttle(SENSITIVE_ACTION_THROTTLE) + @Post('mfa/verify') + @HttpCode(HttpStatus.OK) + async verifyMfa( + @Body() dto: MfaVerifyDto, + @Res({ passthrough: true }) res: Response, ): Promise { - const { accessToken, refreshToken } = await this.authService.login( - dto.email, - dto.password, - ); + const { accessToken, refreshToken } = + await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code); this.setRefreshTokenCookie(res, refreshToken); return { accessToken }; } @Public() - @Throttle(AUTH_THROTTLE) + @Throttle(SENSITIVE_ACTION_THROTTLE) @Post('refresh') @HttpCode(HttpStatus.OK) async refresh( diff --git a/ApiCore-Backend/src/auth/auth.module.ts b/ApiCore-Backend/src/auth/auth.module.ts index b3d9048..968932d 100644 --- a/ApiCore-Backend/src/auth/auth.module.ts +++ b/ApiCore-Backend/src/auth/auth.module.ts @@ -5,9 +5,10 @@ import { UsersModule } from '../users/users.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { MfaModule } from './mfa/mfa.module'; @Module({ - imports: [PassportModule, JwtModule.register({}), UsersModule], + imports: [PassportModule, JwtModule.register({}), UsersModule, MfaModule], controllers: [AuthController], providers: [AuthService, JwtStrategy], }) diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts index 4916adc..d3f0215 100644 --- a/ApiCore-Backend/src/auth/auth.service.spec.ts +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -4,6 +4,7 @@ import { AuthService } from './auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { MfaService } from './mfa/mfa.service'; describe('AuthService', () => { let authService: AuthService; @@ -19,16 +20,25 @@ describe('AuthService', () => { let jwtService: { sign: jest.Mock }; let auditLog: { record: jest.Mock }; let permissionResolver: { getPermissions: jest.Mock }; + let mfaService: { + signChallengeToken: jest.Mock; + verifyChallengeToken: jest.Mock; + verifyCode: jest.Mock; + }; const activeUser = { id: 'user-1', email: 'user@example.com', isActive: true, passwordHash: '', + mfaEnabled: false, + mfaSecret: null as string | null, }; beforeEach(async () => { activeUser.passwordHash = await bcrypt.hash('correct-password', 4); + activeUser.mfaEnabled = false; + activeUser.mfaSecret = null; usersService = { findByEmail: jest.fn(), findById: jest.fn() }; prisma = { refreshToken: { @@ -43,6 +53,11 @@ describe('AuthService', () => { permissionResolver = { getPermissions: jest.fn().mockResolvedValue(new Set()), }; + mfaService = { + signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'), + verifyChallengeToken: jest.fn(), + verifyCode: jest.fn(), + }; authService = new AuthService( usersService as unknown as UsersService, @@ -50,6 +65,7 @@ describe('AuthService', () => { jwtService as never, auditLog as unknown as AuditLogService, permissionResolver as never, + mfaService as unknown as MfaService, ); }); @@ -87,6 +103,9 @@ describe('AuthService', () => { 'correct-password', ); + if (result.mfaRequired) { + throw new Error('expected mfaRequired to be false'); + } expect(result.accessToken).toBe('signed.jwt.token'); expect(result.refreshToken).toEqual(expect.any(String)); const [[createArg]] = prisma.refreshToken.create.mock.calls as [ @@ -98,6 +117,29 @@ describe('AuthService', () => { ); }); + it('returns an MFA challenge instead of tokens when MFA is enabled', async () => { + usersService.findByEmail.mockResolvedValue({ + ...activeUser, + mfaEnabled: true, + mfaSecret: 'encrypted-secret', + }); + + const result = await authService.login( + activeUser.email, + 'correct-password', + ); + + if (!result.mfaRequired) { + throw new Error('expected mfaRequired to be true'); + } + expect(result.mfaToken).toBe('mfa-challenge-token'); + expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id); + expect(prisma.refreshToken.create).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalledWith( + expect.objectContaining({ action: 'LOGIN' }), + ); + }); + it('records a LOGIN_FAILED audit entry on wrong password', async () => { usersService.findByEmail.mockResolvedValue(activeUser); await expect( @@ -126,6 +168,64 @@ describe('AuthService', () => { }); }); + describe('verifyMfaAndLogin', () => { + const mfaUser = { + ...activeUser, + mfaEnabled: true, + mfaSecret: 'encrypted-secret', + }; + + it('issues tokens and records LOGIN when the code is valid', async () => { + mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id }); + usersService.findById.mockResolvedValue(mfaUser); + mfaService.verifyCode.mockResolvedValue(true); + prisma.refreshToken.create.mockResolvedValue({}); + + const result = await authService.verifyMfaAndLogin( + 'mfa-challenge-token', + '123456', + ); + + expect(result.accessToken).toBe('signed.jwt.token'); + expect(mfaService.verifyCode).toHaveBeenCalledWith( + mfaUser.mfaSecret, + mfaUser.email, + '123456', + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }), + ); + }); + + it('throws and records LOGIN_FAILED when the code is invalid', async () => { + mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id }); + usersService.findById.mockResolvedValue(mfaUser); + mfaService.verifyCode.mockResolvedValue(false); + + await expect( + authService.verifyMfaAndLogin('mfa-challenge-token', '000000'), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'LOGIN_FAILED', + userId: mfaUser.id, + }), + ); + expect(prisma.refreshToken.create).not.toHaveBeenCalled(); + }); + + it('propagates an expired/invalid challenge token as UnauthorizedException', async () => { + mfaService.verifyChallengeToken.mockImplementation(() => { + throw new UnauthorizedException('expired'); + }); + + await expect( + authService.verifyMfaAndLogin('bad-token', '123456'), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(usersService.findById).not.toHaveBeenCalled(); + }); + }); + describe('refresh', () => { it('throws when the refresh token is unknown', async () => { prisma.refreshToken.findUnique.mockResolvedValue(null); diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index 37ae47c..d22df0b 100644 --- a/ApiCore-Backend/src/auth/auth.service.ts +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -6,7 +6,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { UsersService } from '../users/users.service'; import { AuditLogService } from '../audit/audit-log.service'; import { PermissionResolverService } from './permissions/permission-resolver.service'; -import { AuthenticatedUser } from './types/authenticated-user'; +import { MfaService } from './mfa/mfa.service'; import { parseDurationToMs } from './util/duration'; interface AuthTokens { @@ -14,6 +14,10 @@ interface AuthTokens { refreshToken: string; } +export type LoginResult = + | ({ mfaRequired: false } & AuthTokens) + | { mfaRequired: true; mfaToken: string }; + export interface CurrentUserProfile { id: string; email: string; @@ -21,6 +25,8 @@ export interface CurrentUserProfile { roleKey: string; roleName: string; permissions: string[]; + mfaEnabled: boolean; + hasAvatar: boolean; } const REFRESH_TOKEN_BYTES = 40; @@ -33,6 +39,7 @@ export class AuthService { private readonly jwtService: JwtService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, + private readonly mfaService: MfaService, ) {} async getCurrentUserProfile(userId: string): Promise { @@ -48,6 +55,8 @@ export class AuthService { roleKey: user.role.key, roleName: user.role.name, permissions: Array.from(permissions), + mfaEnabled: user.mfaEnabled, + hasAvatar: user.avatarPath !== null, }; } @@ -55,7 +64,7 @@ export class AuthService { return crypto.createHash('sha256').update(token).digest('hex'); } - private signAccessToken(user: AuthenticatedUser): string { + private signAccessToken(user: { id: string; email: string }): string { const expiresInSeconds = Math.floor( parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000, ); @@ -80,7 +89,7 @@ export class AuthService { return token; } - async login(email: string, password: string): Promise { + async login(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); if (!user || !user.isActive) { await this.auditLog.record({ @@ -101,7 +110,48 @@ export class AuthService { }); throw new UnauthorizedException('Invalid credentials'); } - const authenticatedUser: AuthenticatedUser = { + if (user.mfaEnabled) { + return { + mfaRequired: true, + mfaToken: this.mfaService.signChallengeToken(user.id), + }; + } + const authenticatedUser = { + id: user.id, + email: user.email, + }; + 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 { mfaRequired: false, accessToken, refreshToken }; + } + + async verifyMfaAndLogin(mfaToken: string, code: string): Promise { + const { userId } = this.mfaService.verifyChallengeToken(mfaToken); + const user = await this.usersService.findById(userId); + if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) { + throw new UnauthorizedException('MFA nicht verfügbar'); + } + const isValid = await this.mfaService.verifyCode( + user.mfaSecret, + user.email, + code, + ); + if (!isValid) { + await this.auditLog.record({ + entityType: 'AuthSession', + entityId: user.id, + action: 'LOGIN_FAILED', + userId: user.id, + }); + throw new UnauthorizedException('Code ungültig'); + } + const authenticatedUser = { id: user.id, email: user.email, }; @@ -132,7 +182,7 @@ export class AuthService { where: { id: stored.id }, data: { revokedAt: new Date() }, }); - const authenticatedUser: AuthenticatedUser = { + const authenticatedUser = { id: user.id, email: user.email, }; diff --git a/ApiCore-Backend/src/auth/dto/mfa-verify.dto.ts b/ApiCore-Backend/src/auth/dto/mfa-verify.dto.ts new file mode 100644 index 0000000..00c4f9b --- /dev/null +++ b/ApiCore-Backend/src/auth/dto/mfa-verify.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches, MinLength } from 'class-validator'; + +export class MfaVerifyDto { + @ApiProperty() + @IsString() + @MinLength(1) + mfaToken!: string; + + @ApiProperty() + @IsString() + @Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' }) + code!: string; +} diff --git a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.spec.ts b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.spec.ts index a5b01c5..ec7e307 100644 --- a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.spec.ts +++ b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.spec.ts @@ -6,9 +6,11 @@ import { PermissionResolverService } from '../permissions/permission-resolver.se describe('JwtAuthGuard', () => { let guard: JwtAuthGuard; let reflector: { getAllAndOverride: jest.Mock }; - let permissionResolver: { isUserActive: jest.Mock }; + let permissionResolver: { isTokenValid: jest.Mock }; - function contextWithUser(user: { id: string } | undefined): ExecutionContext { + function contextWithUser( + user: { id: string; tokenIssuedAt?: number } | undefined, + ): ExecutionContext { const request = { user }; return { getHandler: () => ({}), @@ -19,7 +21,7 @@ describe('JwtAuthGuard', () => { beforeEach(() => { reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) }; - permissionResolver = { isUserActive: jest.fn() }; + permissionResolver = { isTokenValid: jest.fn() }; guard = new JwtAuthGuard( reflector as unknown as Reflector, permissionResolver as unknown as PermissionResolverService, @@ -32,11 +34,11 @@ describe('JwtAuthGuard', () => { await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe( true, ); - expect(permissionResolver.isUserActive).not.toHaveBeenCalled(); + expect(permissionResolver.isTokenValid).not.toHaveBeenCalled(); }); - it('allows the request when the JWT is valid and the user is active', async () => { - const context = contextWithUser({ id: 'user-1' }); + it('allows the request when the JWT is valid and the token is still valid for the user', async () => { + const context = contextWithUser({ id: 'user-1', tokenIssuedAt: 1000 }); jest .spyOn( Object.getPrototypeOf(JwtAuthGuard.prototype) as { @@ -45,14 +47,17 @@ describe('JwtAuthGuard', () => { 'canActivate', ) .mockReturnValue(true); - permissionResolver.isUserActive.mockResolvedValue(true); + permissionResolver.isTokenValid.mockResolvedValue(true); await expect(guard.canActivate(context)).resolves.toBe(true); - expect(permissionResolver.isUserActive).toHaveBeenCalledWith('user-1'); + expect(permissionResolver.isTokenValid).toHaveBeenCalledWith( + 'user-1', + 1000, + ); }); - it('throws UnauthorizedException when the authenticated user has been deactivated', async () => { - const context = contextWithUser({ id: 'user-1' }); + it('throws UnauthorizedException when the user is deactivated or the token predates a password change', async () => { + const context = contextWithUser({ id: 'user-1', tokenIssuedAt: 1000 }); jest .spyOn( Object.getPrototypeOf(JwtAuthGuard.prototype) as { @@ -61,7 +66,7 @@ describe('JwtAuthGuard', () => { 'canActivate', ) .mockReturnValue(true); - permissionResolver.isUserActive.mockResolvedValue(false); + permissionResolver.isTokenValid.mockResolvedValue(false); await expect(guard.canActivate(context)).rejects.toBeInstanceOf( UnauthorizedException, diff --git a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts index 091602c..a1671c6 100644 --- a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts +++ b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts @@ -14,13 +14,14 @@ interface RequestWithUser { } /** - * Enforces User.isActive on every authenticated request, not just ones - * gated by @RequirePermission. Without this, a deactivated user's - * still-valid access token would keep working on any route that carries no - * permission decorator until the token's own expiry (up to - * JWT_ACCESS_EXPIRES_IN). isUserActive shares PermissionsGuard's cache, so - * this check costs no extra query beyond what permission resolution already - * does. + * Enforces User.isActive and token freshness (issued after the last + * password change) on every authenticated request, not just ones gated by + * @RequirePermission. Without this, a deactivated user's still-valid access + * token - or one stolen before a password change - would keep working on + * any route that carries no permission decorator until the token's own + * expiry (up to JWT_ACCESS_EXPIRES_IN). isTokenValid shares + * PermissionsGuard's cache, so this check costs no extra query beyond what + * permission resolution already does. */ @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { @@ -45,7 +46,10 @@ export class JwtAuthGuard extends AuthGuard('jwt') { } const request = context.switchToHttp().getRequest(); const user = request.user; - if (!user || !(await this.permissionResolver.isUserActive(user.id))) { + if ( + !user || + !(await this.permissionResolver.isTokenValid(user.id, user.tokenIssuedAt)) + ) { throw new UnauthorizedException(); } return true; diff --git a/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.spec.ts b/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.spec.ts new file mode 100644 index 0000000..ec87a7b --- /dev/null +++ b/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.spec.ts @@ -0,0 +1,37 @@ +import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util'; + +describe('mfa-crypto.util', () => { + const originalSecret = process.env.JWT_ACCESS_SECRET; + + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret'; + }); + + afterEach(() => { + process.env.JWT_ACCESS_SECRET = originalSecret; + }); + + it('round-trips a TOTP secret through encrypt/decrypt', () => { + const plaintext = 'JBSWY3DPEHPK3PXP'; + const ciphertext = encryptMfaSecret(plaintext); + expect(ciphertext).not.toContain(plaintext); + expect(decryptMfaSecret(ciphertext)).toBe(plaintext); + }); + + it('produces a different ciphertext every time (random IV)', () => { + const plaintext = 'JBSWY3DPEHPK3PXP'; + expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext)); + }); + + it('fails to decrypt with the wrong key', () => { + const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP'); + process.env.JWT_ACCESS_SECRET = 'a-different-secret'; + expect(() => decryptMfaSecret(ciphertext)).toThrow(); + }); + + it('rejects a malformed ciphertext', () => { + expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow( + 'Invalid MFA secret ciphertext', + ); + }); +}); diff --git a/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.ts b/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.ts new file mode 100644 index 0000000..001d523 --- /dev/null +++ b/ApiCore-Backend/src/auth/mfa/mfa-crypto.util.ts @@ -0,0 +1,53 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; + +function getKey(): Buffer { + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) { + throw new Error('JWT_ACCESS_SECRET is not set'); + } + return createHash('sha256').update(secret).digest(); +} + +/** + * TOTP secrets must be reversible (unlike passwords/refresh tokens, which + * are one-way hashed) so the server can compute the expected code on every + * login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret + * out of the DB in plaintext without introducing a new secret to manage. + */ +export function encryptMfaSecret(plaintext: string): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, getKey(), iv); + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return [iv, authTag, encrypted] + .map((buffer) => buffer.toString('base64')) + .join('.'); +} + +export function decryptMfaSecret(ciphertext: string): string { + const [ivB64, authTagB64, dataB64] = ciphertext.split('.'); + if (!ivB64 || !authTagB64 || !dataB64) { + throw new Error('Invalid MFA secret ciphertext'); + } + const decipher = createDecipheriv( + ALGORITHM, + getKey(), + Buffer.from(ivB64, 'base64'), + ); + decipher.setAuthTag(Buffer.from(authTagB64, 'base64')); + return Buffer.concat([ + decipher.update(Buffer.from(dataB64, 'base64')), + decipher.final(), + ]).toString('utf8'); +} diff --git a/ApiCore-Backend/src/auth/mfa/mfa.module.ts b/ApiCore-Backend/src/auth/mfa/mfa.module.ts new file mode 100644 index 0000000..aaef36d --- /dev/null +++ b/ApiCore-Backend/src/auth/mfa/mfa.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { MfaService } from './mfa.service'; + +@Module({ + imports: [JwtModule.register({})], + providers: [MfaService], + exports: [MfaService], +}) +export class MfaModule {} diff --git a/ApiCore-Backend/src/auth/mfa/mfa.service.spec.ts b/ApiCore-Backend/src/auth/mfa/mfa.service.spec.ts new file mode 100644 index 0000000..a5bd36f --- /dev/null +++ b/ApiCore-Backend/src/auth/mfa/mfa.service.spec.ts @@ -0,0 +1,182 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { TOTP, NobleCryptoPlugin, ScureBase32Plugin } from 'otplib'; +import { MfaService } from './mfa.service'; +import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util'; +import { generateTotpSecret } from './totp.util'; + +const crypto = new NobleCryptoPlugin(); +const base32 = new ScureBase32Plugin(); + +describe('MfaService', () => { + let mfaService: MfaService; + let jwtService: { sign: jest.Mock; verify: jest.Mock }; + const originalSecret = process.env.JWT_ACCESS_SECRET; + + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret'; + jwtService = { sign: jest.fn(), verify: jest.fn() }; + mfaService = new MfaService(jwtService as unknown as JwtService); + }); + + afterEach(() => { + process.env.JWT_ACCESS_SECRET = originalSecret; + }); + + describe('startEnrollment', () => { + it('returns a secret, QR SVG, otpauth URI, and a signed setup token', async () => { + jwtService.sign.mockReturnValue('signed-setup-token'); + + const enrollment = await mfaService.startEnrollment( + 'user-1', + 'admin@apicore.local', + ); + + expect(enrollment.secret).toMatch(/^[A-Z2-7]+$/); + expect(enrollment.qrCodeSvg).toContain(' { + async function totpCodeFor(secret: string): Promise { + return new TOTP({ secret, crypto, base32 }).generate(); + } + + it('encrypts and returns the secret when the code is valid', async () => { + const secret = generateTotpSecret(); + jwtService.verify.mockReturnValue({ + sub: 'user-1', + secret, + purpose: 'mfa-setup', + }); + const code = await totpCodeFor(secret); + + const { encryptedSecret } = await mfaService.confirmEnrollment( + 'user-1', + 'setup-token', + 'admin@apicore.local', + code, + ); + + expect(decryptMfaSecret(encryptedSecret)).toBe(secret); + }); + + it('throws when the code does not match the secret', async () => { + const secret = generateTotpSecret(); + jwtService.verify.mockReturnValue({ + sub: 'user-1', + secret, + purpose: 'mfa-setup', + }); + + await expect( + mfaService.confirmEnrollment( + 'user-1', + 'setup-token', + 'admin@apicore.local', + '000000', + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('throws when the setup token belongs to a different user', async () => { + jwtService.verify.mockReturnValue({ + sub: 'someone-else', + secret: generateTotpSecret(), + purpose: 'mfa-setup', + }); + + await expect( + mfaService.confirmEnrollment( + 'user-1', + 'setup-token', + 'admin@apicore.local', + '123456', + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('throws when the token is expired or invalid', async () => { + jwtService.verify.mockImplementation(() => { + throw new Error('jwt expired'); + }); + + await expect( + mfaService.confirmEnrollment( + 'user-1', + 'expired-token', + 'admin@apicore.local', + '123456', + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); + + describe('challenge tokens', () => { + it('signs a challenge token for login-time MFA verification', () => { + jwtService.sign.mockReturnValue('signed-challenge-token'); + + const token = mfaService.signChallengeToken('user-1'); + + expect(token).toBe('signed-challenge-token'); + expect(jwtService.sign).toHaveBeenCalledWith( + expect.objectContaining({ sub: 'user-1', purpose: 'mfa-challenge' }), + expect.objectContaining({ expiresIn: '5m' }), + ); + }); + + it('verifies a valid challenge token', () => { + jwtService.verify.mockReturnValue({ + sub: 'user-1', + purpose: 'mfa-challenge', + }); + + expect(mfaService.verifyChallengeToken('token')).toEqual({ + userId: 'user-1', + }); + }); + + it('throws on an expired/invalid challenge token', () => { + jwtService.verify.mockImplementation(() => { + throw new Error('jwt expired'); + }); + + expect(() => mfaService.verifyChallengeToken('bad-token')).toThrow( + UnauthorizedException, + ); + }); + + it('throws when the token purpose does not match', () => { + jwtService.verify.mockReturnValue({ + sub: 'user-1', + purpose: 'mfa-setup', + }); + + expect(() => mfaService.verifyChallengeToken('token')).toThrow( + UnauthorizedException, + ); + }); + }); + + describe('verifyCode', () => { + it('decrypts the stored secret and verifies the code', async () => { + const secret = generateTotpSecret(); + const encryptedSecret = encryptMfaSecret(secret); + const code = await new TOTP({ secret, crypto, base32 }).generate(); + + await expect( + mfaService.verifyCode(encryptedSecret, 'admin@apicore.local', code), + ).resolves.toBe(true); + }); + }); +}); diff --git a/ApiCore-Backend/src/auth/mfa/mfa.service.ts b/ApiCore-Backend/src/auth/mfa/mfa.service.ts new file mode 100644 index 0000000..4680f56 --- /dev/null +++ b/ApiCore-Backend/src/auth/mfa/mfa.service.ts @@ -0,0 +1,108 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as QRCode from 'qrcode'; +import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util'; +import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util'; + +const SETUP_TOKEN_TTL = '10m'; +const CHALLENGE_TOKEN_TTL = '5m'; +const SETUP_TOKEN_PURPOSE = 'mfa-setup'; +const CHALLENGE_TOKEN_PURPOSE = 'mfa-challenge'; + +interface SetupTokenPayload { + sub: string; + secret: string; + purpose: typeof SETUP_TOKEN_PURPOSE; +} + +interface ChallengeTokenPayload { + sub: string; + purpose: typeof CHALLENGE_TOKEN_PURPOSE; +} + +export interface MfaEnrollment { + secret: string; + qrCodeSvg: string; + otpauthUri: string; + setupToken: string; +} + +@Injectable() +export class MfaService { + constructor(private readonly jwtService: JwtService) {} + + async startEnrollment(userId: string, email: string): Promise { + const secret = generateTotpSecret(); + const otpauthUri = buildTotpUri(secret, email); + const qrCodeSvg = await QRCode.toString(otpauthUri, { + type: 'svg', + margin: 1, + width: 256, + }); + const setupToken = this.jwtService.sign( + { sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE }, + { secret: process.env.JWT_ACCESS_SECRET, expiresIn: SETUP_TOKEN_TTL }, + ); + return { secret, qrCodeSvg, otpauthUri, setupToken }; + } + + /** Returns the plaintext secret from a setup token once the enrollment code checks out. */ + async confirmEnrollment( + userId: string, + setupToken: string, + email: string, + code: string, + ): Promise<{ encryptedSecret: string }> { + let payload: SetupTokenPayload; + try { + payload = this.jwtService.verify(setupToken, { + secret: process.env.JWT_ACCESS_SECRET, + }); + } catch { + throw new UnauthorizedException( + 'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten', + ); + } + if (payload.purpose !== SETUP_TOKEN_PURPOSE || payload.sub !== userId) { + throw new UnauthorizedException('Setup-Token ungültig'); + } + const isValid = await verifyTotpCode(payload.secret, email, code); + if (!isValid) { + throw new UnauthorizedException('Code ungültig'); + } + return { encryptedSecret: encryptMfaSecret(payload.secret) }; + } + + signChallengeToken(userId: string): string { + return this.jwtService.sign( + { sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE }, + { secret: process.env.JWT_ACCESS_SECRET, expiresIn: CHALLENGE_TOKEN_TTL }, + ); + } + + verifyChallengeToken(challengeToken: string): { userId: string } { + let payload: ChallengeTokenPayload; + try { + payload = this.jwtService.verify(challengeToken, { + secret: process.env.JWT_ACCESS_SECRET, + }); + } catch { + throw new UnauthorizedException( + 'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden', + ); + } + if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) { + throw new UnauthorizedException('Ungültiger Token'); + } + return { userId: payload.sub }; + } + + async verifyCode( + encryptedSecret: string, + email: string, + code: string, + ): Promise { + const secret = decryptMfaSecret(encryptedSecret); + return verifyTotpCode(secret, email, code); + } +} diff --git a/ApiCore-Backend/src/auth/mfa/totp.util.spec.ts b/ApiCore-Backend/src/auth/mfa/totp.util.spec.ts new file mode 100644 index 0000000..c636917 --- /dev/null +++ b/ApiCore-Backend/src/auth/mfa/totp.util.spec.ts @@ -0,0 +1,48 @@ +import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib'; +import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util'; + +const crypto = new NobleCryptoPlugin(); +const base32 = new ScureBase32Plugin(); + +describe('totp.util', () => { + it('generates a base32 secret', () => { + const secret = generateTotpSecret(); + expect(secret).toMatch(/^[A-Z2-7]+$/); + }); + + it('builds an otpauth:// URI carrying the issuer and label', () => { + const uri = buildTotpUri('JBSWY3DPEHPK3PXP', 'admin@apicore.local'); + expect(uri).toContain('otpauth://totp/'); + expect(uri).toContain('ApiCore'); + expect(uri).toContain('secret=JBSWY3DPEHPK3PXP'); + }); + + it('verifies a code generated for the same secret', async () => { + const secret = generateTotpSecret(); + const totp = new TOTP({ secret, crypto, base32 }); + const code = await totp.generate(); + await expect( + verifyTotpCode(secret, 'admin@apicore.local', code), + ).resolves.toBe(true); + }); + + it('rejects an incorrect code', async () => { + const secret = generateTotpSecret(); + const totp = new TOTP({ secret, crypto, base32 }); + const validCode = await totp.generate(); + const wrongCode = validCode === '000000' ? '111111' : '000000'; + await expect( + verifyTotpCode(secret, 'admin@apicore.local', wrongCode), + ).resolves.toBe(false); + }); + + it('rejects a code generated for a different secret', async () => { + const secretA = generateTotpSecret(); + const secretB = generateTotpSecret(); + const totpB = new TOTP({ secret: secretB, crypto, base32 }); + const codeFromB = await totpB.generate(); + await expect( + verifyTotpCode(secretA, 'admin@apicore.local', codeFromB), + ).resolves.toBe(false); + }); +}); diff --git a/ApiCore-Backend/src/auth/mfa/totp.util.ts b/ApiCore-Backend/src/auth/mfa/totp.util.ts new file mode 100644 index 0000000..56572f9 --- /dev/null +++ b/ApiCore-Backend/src/auth/mfa/totp.util.ts @@ -0,0 +1,32 @@ +import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib'; + +const ISSUER = 'ApiCore'; +// Allows one 30s step of clock drift in either direction between the +// authenticator app and the server, matching common 2FA implementations. +const EPOCH_TOLERANCE_SECONDS = 30; + +const crypto = new NobleCryptoPlugin(); +const base32 = new ScureBase32Plugin(); + +function createTotp(secret: string, label: string): TOTP { + return new TOTP({ secret, label, issuer: ISSUER, crypto, base32 }); +} + +export function generateTotpSecret(): string { + return new TOTP({ crypto, base32 }).generateSecret(); +} + +export function buildTotpUri(secret: string, label: string): string { + return createTotp(secret, label).toURI(); +} + +export async function verifyTotpCode( + secret: string, + label: string, + code: string, +): Promise { + const result = await createTotp(secret, label).verify(code, { + epochTolerance: EPOCH_TOLERANCE_SECONDS, + }); + return result.valid; +} diff --git a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts index 9564598..0ebf42f 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts @@ -8,6 +8,7 @@ describe('PermissionResolverService', () => { function activeUserWithPermissions(...keys: string[]) { return { isActive: true, + passwordChangedAt: null as Date | null, role: { status: 'ACTIVE', permissions: keys.map((key) => ({ permission: { key } })), @@ -93,43 +94,75 @@ describe('PermissionResolverService', () => { expect(prisma.user.findUnique).toHaveBeenCalledTimes(2); }); - describe('isUserActive', () => { - it('returns true for an active user with an active role', async () => { + describe('isTokenValid', () => { + const nowSeconds = Math.floor(Date.now() / 1000); + + it('returns true for an active user with no recorded password change', async () => { prisma.user.findUnique.mockResolvedValue( activeUserWithPermissions('colonies:read'), ); - await expect(resolver.isUserActive('user-1')).resolves.toBe(true); + await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe( + true, + ); }); it('returns false for a deactivated user', async () => { prisma.user.findUnique.mockResolvedValue({ isActive: false, + passwordChangedAt: null, role: { status: 'ACTIVE', permissions: [{ permission: { key: 'colonies:read' } }], }, }); - await expect(resolver.isUserActive('user-1')).resolves.toBe(false); + await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe( + false, + ); }); it('returns false when the role is inactive', async () => { prisma.user.findUnique.mockResolvedValue({ isActive: true, + passwordChangedAt: null, role: { status: 'INACTIVE', permissions: [{ permission: { key: 'colonies:read' } }], }, }); - await expect(resolver.isUserActive('user-1')).resolves.toBe(false); + await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe( + false, + ); }); it('returns false when the user does not exist', async () => { prisma.user.findUnique.mockResolvedValue(null); - await expect(resolver.isUserActive('missing')).resolves.toBe(false); + await expect(resolver.isTokenValid('missing', nowSeconds)).resolves.toBe( + false, + ); + }); + + it('returns false when the token was issued before the last password change', async () => { + const entry = activeUserWithPermissions('colonies:read'); + entry.passwordChangedAt = new Date((nowSeconds + 60) * 1000); + prisma.user.findUnique.mockResolvedValue(entry); + + await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe( + false, + ); + }); + + it('returns true when the token was issued after the last password change', async () => { + const entry = activeUserWithPermissions('colonies:read'); + entry.passwordChangedAt = new Date((nowSeconds - 60) * 1000); + prisma.user.findUnique.mockResolvedValue(entry); + + await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe( + true, + ); }); it('shares the cached entry with getPermissions instead of re-querying', async () => { @@ -138,7 +171,7 @@ describe('PermissionResolverService', () => { ); await resolver.getPermissions('user-1'); - await resolver.isUserActive('user-1'); + await resolver.isTokenValid('user-1', nowSeconds); expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); }); diff --git a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts index d35f2de..8cb4703 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts @@ -4,6 +4,7 @@ import { PermissionKey } from './permission-catalog'; interface CacheEntry { isActive: boolean; + passwordChangedAt: Date | null; permissions: ReadonlySet; expiresAt: number; } @@ -16,13 +17,14 @@ const CACHE_TTL_MS = 30_000; * role's grants takes effect without forcing a re-login), with a short TTL * cache to keep that from becoming a query-per-request cost. Call * invalidateUser/invalidateAll after any write to Role, Permission, - * RolePermission, or User.roleId so the change is visible immediately - * instead of waiting out the cache window. + * RolePermission, User.roleId, or User.passwordChangedAt so the change is + * visible immediately instead of waiting out the cache window. * - * isUserActive shares the same cached entry: JwtAuthGuard calls it on every + * isTokenValid shares the same cached entry: JwtAuthGuard calls it on every * authenticated request (including routes with no @RequirePermission) so a * deactivated user's still-valid access token stops working within the - * cache TTL instead of staying usable until the token expires. + * cache TTL instead of staying usable until the token expires, and so does + * a token issued before the user's last password change. */ @Injectable() export class PermissionResolverService { @@ -35,9 +37,18 @@ export class PermissionResolverService { return entry.permissions; } - async isUserActive(userId: string): Promise { + async isTokenValid( + userId: string, + tokenIssuedAtSeconds: number, + ): Promise { const entry = await this.getEntry(userId); - return entry.isActive; + if (!entry.isActive) { + return false; + } + if (!entry.passwordChangedAt) { + return true; + } + return tokenIssuedAtSeconds * 1000 >= entry.passwordChangedAt.getTime(); } async hasAny( @@ -74,6 +85,7 @@ export class PermissionResolverService { where: { id: userId }, select: { isActive: true, + passwordChangedAt: true, role: { select: { status: true, @@ -92,6 +104,7 @@ export class PermissionResolverService { : new Set(); return { isActive: isEligible, + passwordChangedAt: user?.passwordChangedAt ?? null, permissions, expiresAt: Date.now() + CACHE_TTL_MS, }; diff --git a/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts b/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts index e7aec45..7902ead 100644 --- a/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts +++ b/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts @@ -6,6 +6,10 @@ import { AuthenticatedUser } from '../types/authenticated-user'; interface AccessTokenPayload { sub: string; email: string; + // Standard JWT claim, added automatically by jsonwebtoken.sign(); used to + // reject tokens issued before the user's last password change (see + // PermissionResolverService.isTokenValid). + iat: number; } @Injectable() @@ -19,6 +23,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } validate(payload: AccessTokenPayload): AuthenticatedUser { - return { id: payload.sub, email: payload.email }; + return { + id: payload.sub, + email: payload.email, + tokenIssuedAt: payload.iat, + }; } } diff --git a/ApiCore-Backend/src/auth/types/authenticated-user.ts b/ApiCore-Backend/src/auth/types/authenticated-user.ts index fd99764..9bfa779 100644 --- a/ApiCore-Backend/src/auth/types/authenticated-user.ts +++ b/ApiCore-Backend/src/auth/types/authenticated-user.ts @@ -1,4 +1,6 @@ export interface AuthenticatedUser { id: string; email: string; + /** JWT `iat` claim (seconds since epoch) - see PermissionResolverService.isTokenValid. */ + tokenIssuedAt: number; } diff --git a/ApiCore-Backend/src/auth/util/throttle.ts b/ApiCore-Backend/src/auth/util/throttle.ts new file mode 100644 index 0000000..b652175 --- /dev/null +++ b/ApiCore-Backend/src/auth/util/throttle.ts @@ -0,0 +1,4 @@ +// Stricter than the global default: bounds brute-force/credential-stuffing +// attempts against any endpoint that verifies a password or MFA code +// (login, refresh, MFA verify, change-password, MFA disable). +export const SENSITIVE_ACTION_THROTTLE = { default: { limit: 10, ttl: 60000 } }; diff --git a/ApiCore-Backend/src/profile/avatar-mime-types.ts b/ApiCore-Backend/src/profile/avatar-mime-types.ts new file mode 100644 index 0000000..6b283e5 --- /dev/null +++ b/ApiCore-Backend/src/profile/avatar-mime-types.ts @@ -0,0 +1,17 @@ +export const ALLOWED_AVATAR_MIME_TYPES: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', +}; + +export function isAllowedAvatarMimeType(mimeType: string): boolean { + return mimeType in ALLOWED_AVATAR_MIME_TYPES; +} + +const MIME_TYPE_BY_EXTENSION: Record = Object.fromEntries( + Object.entries(ALLOWED_AVATAR_MIME_TYPES).map(([mime, ext]) => [ext, mime]), +); + +export function mimeTypeForExtension(extension: string): string { + return MIME_TYPE_BY_EXTENSION[extension] ?? 'application/octet-stream'; +} diff --git a/ApiCore-Backend/src/profile/dto/change-password.dto.ts b/ApiCore-Backend/src/profile/dto/change-password.dto.ts new file mode 100644 index 0000000..a2921d1 --- /dev/null +++ b/ApiCore-Backend/src/profile/dto/change-password.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength } from 'class-validator'; + +export class ChangePasswordDto { + @ApiProperty() + @IsString() + currentPassword!: string; + + @ApiProperty() + @IsString() + @MinLength(8) + newPassword!: string; +} diff --git a/ApiCore-Backend/src/profile/dto/disable-mfa.dto.ts b/ApiCore-Backend/src/profile/dto/disable-mfa.dto.ts new file mode 100644 index 0000000..cb7389b --- /dev/null +++ b/ApiCore-Backend/src/profile/dto/disable-mfa.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class DisableMfaDto { + @ApiProperty() + @IsString() + currentPassword!: string; +} diff --git a/ApiCore-Backend/src/profile/dto/mfa-confirm.dto.ts b/ApiCore-Backend/src/profile/dto/mfa-confirm.dto.ts new file mode 100644 index 0000000..2dc9498 --- /dev/null +++ b/ApiCore-Backend/src/profile/dto/mfa-confirm.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, Matches, MinLength } from 'class-validator'; + +export class MfaConfirmDto { + @ApiProperty() + @IsString() + @MinLength(1) + setupToken!: string; + + @ApiProperty() + @IsString() + @Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' }) + code!: string; +} diff --git a/ApiCore-Backend/src/profile/dto/update-profile.dto.ts b/ApiCore-Backend/src/profile/dto/update-profile.dto.ts new file mode 100644 index 0000000..696387d --- /dev/null +++ b/ApiCore-Backend/src/profile/dto/update-profile.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; + +export class UpdateProfileDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MinLength(1) + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ + description: 'Required when changing email; ignored otherwise', + }) + @IsOptional() + @IsString() + currentPassword?: string; +} diff --git a/ApiCore-Backend/src/profile/profile.controller.ts b/ApiCore-Backend/src/profile/profile.controller.ts new file mode 100644 index 0000000..f85b470 --- /dev/null +++ b/ApiCore-Backend/src/profile/profile.controller.ts @@ -0,0 +1,140 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + Patch, + Post, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import type { Response } from 'express'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle'; +import { isAllowedAvatarMimeType } from './avatar-mime-types'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { DisableMfaDto } from './dto/disable-mfa.dto'; +import { MfaConfirmDto } from './dto/mfa-confirm.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ProfileService } from './profile.service'; + +const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024; + +/** + * Deliberately its own top-level path, not nested under /users/:id - + * "/users/me" would collide with UsersController's PATCH/GET ":id" route + * (id="me") depending on module registration order. Every route here is + * self-scoped (no @RequirePermission): the resource acted on is always the + * caller's own account (actor.id), never a path param, so there is no admin + * permission that applies. Listed in + * test/authorization-coverage.e2e-spec.ts's ALLOWLIST. + */ +@ApiTags('profile') +@ApiBearerAuth() +@Controller('profile') +export class ProfileController { + constructor(private readonly profileService: ProfileService) {} + + @Patch() + updateProfile( + @Body() dto: UpdateProfileDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.profileService.updateProfile(actor.id, dto); + } + + @Throttle(SENSITIVE_ACTION_THROTTLE) + @Post('password') + @HttpCode(204) + changePassword( + @Body() dto: ChangePasswordDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.profileService.changePassword( + actor.id, + dto.currentPassword, + dto.newPassword, + ); + } + + @Post('avatar') + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: MAX_AVATAR_SIZE_BYTES }, + fileFilter: (_req, file, callback) => { + if (!isAllowedAvatarMimeType(file.mimetype)) { + callback( + new BadRequestException('Nicht unterstützter Dateityp'), + false, + ); + return; + } + callback(null, true); + }, + }), + ) + @HttpCode(204) + uploadAvatar( + @UploadedFile() file: Express.Multer.File | undefined, + @CurrentUser() actor: AuthenticatedUser, + ) { + if (!file) { + throw new BadRequestException('No file uploaded'); + } + return this.profileService.uploadAvatar(actor.id, file); + } + + @Delete('avatar') + @HttpCode(204) + deleteAvatar(@CurrentUser() actor: AuthenticatedUser) { + return this.profileService.deleteAvatar(actor.id); + } + + @Get('avatar') + async getAvatar( + @CurrentUser() actor: AuthenticatedUser, + @Res() res: Response, + ): Promise { + const { buffer, mimeType } = await this.profileService.getAvatar(actor.id); + res.setHeader('Content-Type', mimeType); + res.setHeader('Content-Disposition', 'inline'); + res.send(buffer); + } + + @Post('mfa/enroll') + enrollMfa(@CurrentUser() actor: AuthenticatedUser) { + return this.profileService.enrollMfa(actor.id, actor.email); + } + + @Post('mfa/enroll/confirm') + @HttpCode(204) + confirmMfaEnrollment( + @Body() dto: MfaConfirmDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.profileService.confirmMfaEnrollment( + actor.id, + actor.email, + dto.setupToken, + dto.code, + ); + } + + @Throttle(SENSITIVE_ACTION_THROTTLE) + @Post('mfa/disable') + @HttpCode(204) + disableMfa( + @Body() dto: DisableMfaDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.profileService.disableMfa(actor.id, dto.currentPassword); + } +} diff --git a/ApiCore-Backend/src/profile/profile.module.ts b/ApiCore-Backend/src/profile/profile.module.ts new file mode 100644 index 0000000..c2f69b7 --- /dev/null +++ b/ApiCore-Backend/src/profile/profile.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MfaModule } from '../auth/mfa/mfa.module'; +import { ProfileController } from './profile.controller'; +import { ProfileService } from './profile.service'; + +@Module({ + imports: [MfaModule], + controllers: [ProfileController], + providers: [ProfileService], +}) +export class ProfileModule {} diff --git a/ApiCore-Backend/src/profile/profile.service.spec.ts b/ApiCore-Backend/src/profile/profile.service.spec.ts new file mode 100644 index 0000000..2639194 --- /dev/null +++ b/ApiCore-Backend/src/profile/profile.service.spec.ts @@ -0,0 +1,317 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import * as fs from 'node:fs/promises'; +import { ProfileService } from './profile.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { MfaService } from '../auth/mfa/mfa.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; + +jest.mock('node:fs/promises'); + +describe('ProfileService', () => { + let service: ProfileService; + let prisma: { + user: { + findUnique: jest.Mock; + update: jest.Mock; + }; + refreshToken: { updateMany: jest.Mock }; + }; + let auditLog: { record: jest.Mock }; + let mfaService: { + startEnrollment: jest.Mock; + confirmEnrollment: jest.Mock; + }; + let permissionResolver: { invalidateUser: jest.Mock }; + + const baseUser = { + id: 'user-1', + email: 'existing@example.com', + name: 'Existing User', + passwordHash: '', + avatarPath: null as string | null, + mfaEnabled: false, + mfaSecret: null as string | null, + }; + + beforeEach(async () => { + baseUser.passwordHash = await bcrypt.hash('correct-password', 4); + baseUser.avatarPath = null; + baseUser.mfaEnabled = false; + baseUser.mfaSecret = null; + prisma = { + user: { findUnique: jest.fn(), update: jest.fn() }, + refreshToken: { updateMany: jest.fn().mockResolvedValue({}) }, + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() }; + permissionResolver = { invalidateUser: jest.fn() }; + service = new ProfileService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + mfaService as unknown as MfaService, + permissionResolver as unknown as PermissionResolverService, + ); + (fs.mkdir as jest.Mock).mockResolvedValue(undefined); + (fs.writeFile as jest.Mock).mockResolvedValue(undefined); + (fs.readFile as jest.Mock).mockResolvedValue(Buffer.from('data')); + (fs.unlink as jest.Mock).mockResolvedValue(undefined); + }); + + describe('updateProfile', () => { + it('throws NotFoundException when the user does not exist', async () => { + prisma.user.findUnique.mockResolvedValue(null); + await expect( + service.updateProfile('missing', { name: 'New' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws UnauthorizedException when changing email without the correct current password', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + await expect( + service.updateProfile('user-1', { + email: 'taken@example.com', + currentPassword: 'wrong-password', + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('throws ConflictException when the new email is already in use', async () => { + prisma.user.findUnique + .mockResolvedValueOnce(baseUser) + .mockResolvedValueOnce({ id: 'someone-else' }); + await expect( + service.updateProfile('user-1', { + email: 'taken@example.com', + currentPassword: 'correct-password', + }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('updates name and email and records an audit entry', async () => { + prisma.user.findUnique + .mockResolvedValueOnce(baseUser) + .mockResolvedValueOnce(null); + prisma.user.update.mockResolvedValue({ + ...baseUser, + name: 'New Name', + email: 'new@example.com', + }); + + const result = await service.updateProfile('user-1', { + name: 'New Name', + email: 'new@example.com', + currentPassword: 'correct-password', + }); + + expect(result).toEqual({ + id: 'user-1', + name: 'New Name', + email: 'new@example.com', + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'User', action: 'UPDATE' }), + ); + }); + }); + + describe('changePassword', () => { + it('throws UnauthorizedException when the current password is wrong', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + await expect( + service.changePassword('user-1', 'wrong-password', 'new-password123'), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('hashes the new password and revokes all active refresh tokens', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + prisma.user.update.mockResolvedValue({}); + + await service.changePassword( + 'user-1', + 'correct-password', + 'new-password123', + ); + + const [[updateArg]] = prisma.user.update.mock.calls as [ + [{ data: { passwordHash: string; passwordChangedAt: Date } }], + ]; + expect(updateArg.data.passwordHash).not.toBe(baseUser.passwordHash); + expect(updateArg.data.passwordChangedAt).toBeInstanceOf(Date); + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user-1', revokedAt: null }, + }), + ); + expect(permissionResolver.invalidateUser).toHaveBeenCalledWith('user-1'); + }); + }); + + describe('uploadAvatar', () => { + const file = { + buffer: Buffer.from('binary'), + originalname: 'me.jpg', + mimetype: 'image/jpeg', + size: 1234, + } as Express.Multer.File; + + it('rejects an unsupported mime type', async () => { + await expect( + service.uploadAvatar('user-1', { + ...file, + mimetype: 'application/zip', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('writes the file, updates avatarPath, and deletes the old file if present', async () => { + prisma.user.findUnique.mockResolvedValue({ + ...baseUser, + avatarPath: '/uploads/avatars/old.jpg', + }); + prisma.user.update.mockResolvedValue({}); + + await service.uploadAvatar('user-1', file); + + expect(fs.writeFile).toHaveBeenCalled(); + expect(fs.unlink).toHaveBeenCalledWith('/uploads/avatars/old.jpg'); + const [[updateArg]] = prisma.user.update.mock.calls as [ + [{ data: { avatarPath: string } }], + ]; + expect(updateArg.data.avatarPath).toContain('avatars'); + }); + }); + + describe('deleteAvatar', () => { + it('does nothing when there is no avatar', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + await service.deleteAvatar('user-1'); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('unlinks the file and clears avatarPath', async () => { + prisma.user.findUnique.mockResolvedValue({ + ...baseUser, + avatarPath: '/uploads/avatars/me.jpg', + }); + prisma.user.update.mockResolvedValue({}); + + await service.deleteAvatar('user-1'); + + expect(fs.unlink).toHaveBeenCalledWith('/uploads/avatars/me.jpg'); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { avatarPath: null } }), + ); + }); + }); + + describe('getAvatar', () => { + it('throws NotFoundException when no avatar is set', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + await expect(service.getAvatar('user-1')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('reads the file and infers the mime type from the extension', async () => { + prisma.user.findUnique.mockResolvedValue({ + ...baseUser, + avatarPath: '/uploads/avatars/me.png', + }); + + const result = await service.getAvatar('user-1'); + + expect(result.mimeType).toBe('image/png'); + expect(result.buffer).toEqual(Buffer.from('data')); + }); + }); + + describe('enrollMfa', () => { + it('throws ConflictException when MFA is already enabled', async () => { + prisma.user.findUnique.mockResolvedValue({ + ...baseUser, + mfaEnabled: true, + }); + await expect( + service.enrollMfa('user-1', baseUser.email), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('delegates to MfaService.startEnrollment', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + mfaService.startEnrollment.mockResolvedValue({ + secret: 'secret', + qrCodeSvg: '', + otpauthUri: 'otpauth://totp/x', + setupToken: 'token', + }); + + const result = await service.enrollMfa('user-1', baseUser.email); + + expect(mfaService.startEnrollment).toHaveBeenCalledWith( + 'user-1', + baseUser.email, + ); + expect(result.setupToken).toBe('token'); + }); + }); + + describe('confirmMfaEnrollment', () => { + it('persists the encrypted secret and records MFA_ENABLED', async () => { + mfaService.confirmEnrollment.mockResolvedValue({ + encryptedSecret: 'encrypted', + }); + prisma.user.update.mockResolvedValue({}); + + await service.confirmMfaEnrollment( + 'user-1', + baseUser.email, + 'setup-token', + '123456', + ); + + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { mfaEnabled: true, mfaSecret: 'encrypted' }, + }), + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'MFA_ENABLED' }), + ); + }); + }); + + describe('disableMfa', () => { + it('throws UnauthorizedException when the current password is wrong', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + await expect( + service.disableMfa('user-1', 'wrong-password'), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('clears MFA fields and records MFA_DISABLED', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + prisma.user.update.mockResolvedValue({}); + + await service.disableMfa('user-1', 'correct-password'); + + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { mfaEnabled: false, mfaSecret: null }, + }), + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'MFA_DISABLED' }), + ); + }); + }); +}); diff --git a/ApiCore-Backend/src/profile/profile.service.ts b/ApiCore-Backend/src/profile/profile.service.ts new file mode 100644 index 0000000..f91889c --- /dev/null +++ b/ApiCore-Backend/src/profile/profile.service.ts @@ -0,0 +1,239 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { + ALLOWED_AVATAR_MIME_TYPES, + mimeTypeForExtension, +} from './avatar-mime-types'; +import { UpdateProfileDto } from './dto/update-profile.dto'; + +const BCRYPT_SALT_ROUNDS = 12; + +export interface ProfileSummary { + id: string; + email: string; + name: string; +} + +function avatarStorageRoot(): string { + return path.join( + process.env.DOCUMENTS_STORAGE_PATH ?? './uploads', + 'avatars', + ); +} + +@Injectable() +export class ProfileService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + private readonly mfaService: MfaService, + private readonly permissionResolver: PermissionResolverService, + ) {} + + private async requireUser(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + return user; + } + + async updateProfile( + userId: string, + dto: UpdateProfileDto, + ): Promise { + const before = await this.requireUser(userId); + if (dto.email && dto.email !== before.email) { + const currentMatches = + dto.currentPassword && + (await bcrypt.compare(dto.currentPassword, before.passwordHash)); + if (!currentMatches) { + throw new UnauthorizedException( + 'Aktuelles Passwort erforderlich, um die E-Mail-Adresse zu ändern', + ); + } + const existing = await this.prisma.user.findUnique({ + where: { email: dto.email }, + }); + if (existing) { + throw new ConflictException('Email already in use'); + } + } + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { name: dto.name, email: dto.email }, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: userId, + action: 'UPDATE', + userId, + beforeData: { name: before.name, email: before.email }, + afterData: { name: user.name, email: user.email }, + }); + return { id: user.id, email: user.email, name: user.name }; + } + + async changePassword( + userId: string, + currentPassword: string, + newPassword: string, + ): Promise { + const user = await this.requireUser(userId); + const currentMatches = await bcrypt.compare( + currentPassword, + user.passwordHash, + ); + if (!currentMatches) { + throw new UnauthorizedException('Aktuelles Passwort ist falsch'); + } + const passwordHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS); + await this.prisma.user.update({ + where: { id: userId }, + data: { passwordHash, passwordChangedAt: new Date() }, + }); + // A changed password should invalidate any session issued before the + // change, on this device or any other: refresh tokens are revoked + // outright, and the permission cache is cleared so JwtAuthGuard picks + // up the new passwordChangedAt within this request cycle instead of + // waiting out the cache TTL - that's what makes a still-live access + // token stop working immediately (see PermissionResolverService). + await this.prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + this.permissionResolver.invalidateUser(userId); + await this.auditLog.record({ + entityType: 'User', + entityId: userId, + action: 'UPDATE', + userId, + }); + } + + async uploadAvatar(userId: string, file: Express.Multer.File): Promise { + if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) { + throw new BadRequestException('Nicht unterstützter Dateityp'); + } + const user = await this.requireUser(userId); + const extension = ALLOWED_AVATAR_MIME_TYPES[file.mimetype]; + const directory = avatarStorageRoot(); + await fs.mkdir(directory, { recursive: true }); + const avatarPath = path.join( + directory, + `${crypto.randomUUID()}${extension}`, + ); + await fs.writeFile(avatarPath, file.buffer); + if (user.avatarPath) { + await fs.unlink(user.avatarPath).catch(() => undefined); + } + await this.prisma.user.update({ + where: { id: userId }, + data: { avatarPath }, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: userId, + action: 'UPDATE', + userId, + }); + } + + async deleteAvatar(userId: string): Promise { + const user = await this.requireUser(userId); + if (!user.avatarPath) { + return; + } + await fs.unlink(user.avatarPath).catch(() => undefined); + await this.prisma.user.update({ + where: { id: userId }, + data: { avatarPath: null }, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: userId, + action: 'UPDATE', + userId, + }); + } + + async getAvatar( + userId: string, + ): Promise<{ buffer: Buffer; mimeType: string }> { + const user = await this.requireUser(userId); + if (!user.avatarPath) { + throw new NotFoundException('No avatar set'); + } + const buffer = await fs.readFile(user.avatarPath); + return { + buffer, + mimeType: mimeTypeForExtension(path.extname(user.avatarPath)), + }; + } + + async enrollMfa(userId: string, email: string): Promise { + const user = await this.requireUser(userId); + if (user.mfaEnabled) { + throw new ConflictException('MFA is already enabled'); + } + return this.mfaService.startEnrollment(userId, email); + } + + async confirmMfaEnrollment( + userId: string, + email: string, + setupToken: string, + code: string, + ): Promise { + const { encryptedSecret } = await this.mfaService.confirmEnrollment( + userId, + setupToken, + email, + code, + ); + await this.prisma.user.update({ + where: { id: userId }, + data: { mfaEnabled: true, mfaSecret: encryptedSecret }, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: userId, + action: 'MFA_ENABLED', + userId, + }); + } + + async disableMfa(userId: string, currentPassword: string): Promise { + const user = await this.requireUser(userId); + const currentMatches = await bcrypt.compare( + currentPassword, + user.passwordHash, + ); + if (!currentMatches) { + throw new UnauthorizedException('Aktuelles Passwort ist falsch'); + } + await this.prisma.user.update({ + where: { id: userId }, + data: { mfaEnabled: false, mfaSecret: null }, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: userId, + action: 'MFA_DISABLED', + userId, + }); + } +} diff --git a/ApiCore-Backend/src/users/users.controller.ts b/ApiCore-Backend/src/users/users.controller.ts index efffabd..59e191d 100644 --- a/ApiCore-Backend/src/users/users.controller.ts +++ b/ApiCore-Backend/src/users/users.controller.ts @@ -52,4 +52,10 @@ export class UsersController { activate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { return this.usersService.activate(id, actor.id); } + + @RequirePermission('users:update') + @Post(':id/mfa/reset') + resetMfa(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.usersService.resetMfa(id, actor.id); + } } diff --git a/ApiCore-Backend/src/users/users.service.spec.ts b/ApiCore-Backend/src/users/users.service.spec.ts index ba747c8..93fbb3a 100644 --- a/ApiCore-Backend/src/users/users.service.spec.ts +++ b/ApiCore-Backend/src/users/users.service.spec.ts @@ -280,6 +280,40 @@ describe('UsersService', () => { expect(permissionResolver.invalidateUser).not.toHaveBeenCalled(); }); + it('resetMfa clears mfaEnabled/mfaSecret and records a MFA_DISABLED audit entry', async () => { + prisma.user.findUnique.mockResolvedValue(existingUser); + prisma.user.update.mockResolvedValue({ + ...existingUser, + mfaEnabled: false, + mfaSecret: null, + }); + + await usersService.resetMfa(existingUser.id, adminUser.id); + + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: existingUser.id }, + data: { mfaEnabled: false, mfaSecret: null }, + }), + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'User', + entityId: existingUser.id, + action: 'MFA_DISABLED', + userId: adminUser.id, + }), + ); + }); + + it('resetMfa throws NotFoundException when the user does not exist', async () => { + prisma.user.findUnique.mockResolvedValue(null); + await expect( + usersService.resetMfa('missing', adminUser.id), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + it('update throws ConflictException when reassigning the last active user away from a system role', async () => { prisma.user.findUnique.mockResolvedValue(adminUser); prisma.role.findUnique.mockResolvedValue(staffRole); diff --git a/ApiCore-Backend/src/users/users.service.ts b/ApiCore-Backend/src/users/users.service.ts index 1a76811..0d97789 100644 --- a/ApiCore-Backend/src/users/users.service.ts +++ b/ApiCore-Backend/src/users/users.service.ts @@ -29,6 +29,7 @@ export interface UserSummary { isActive: boolean; createdAt: Date; deactivatedAt: Date | null; + mfaEnabled: boolean; } @Injectable() @@ -50,6 +51,7 @@ export class UsersService { isActive: user.isActive, createdAt: user.createdAt, deactivatedAt: user.deactivatedAt, + mfaEnabled: user.mfaEnabled, }; } @@ -200,6 +202,23 @@ export class UsersService { return this.toSummary(user); } + /** Recovery path for a user who lost their MFA device (no email-reset flow exists in this project). */ + async resetMfa(id: string, actingUserId?: string): Promise { + await this.findById(id); + const user = await this.prisma.user.update({ + where: { id }, + data: { mfaEnabled: false, mfaSecret: null }, + ...USER_WITH_ROLE, + }); + await this.auditLog.record({ + entityType: 'User', + entityId: id, + action: 'MFA_DISABLED', + userId: actingUserId, + }); + return this.toSummary(user); + } + async activate(id: string, actingUserId?: string): Promise { const before = await this.findById(id); if (before.isActive) { diff --git a/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts b/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts index d12ffe1..5494e33 100644 --- a/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts +++ b/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts @@ -18,6 +18,14 @@ const ALLOWLIST = new Set([ 'NotificationsController.findAll', 'NotificationsController.markRead', 'AuthController.me', + 'ProfileController.updateProfile', + 'ProfileController.changePassword', + 'ProfileController.uploadAvatar', + 'ProfileController.deleteAvatar', + 'ProfileController.getAvatar', + 'ProfileController.enrollMfa', + 'ProfileController.confirmMfaEnrollment', + 'ProfileController.disableMfa', ]); describe('Authorization coverage (e2e)', () => { diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx index 5a018dc..41ba65f 100644 --- a/ApiCore-Frontend/src/app/(app)/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -7,7 +7,15 @@ import { Hexagon } from "lucide-react"; import { useAuth } from "@/lib/auth/auth-context"; import { NotificationBell } from "@/components/notification-bell"; import { OfflineSyncBadge } from "@/components/offline-sync-badge"; -import { Button } from "@/components/ui/button"; +import { UserAvatar } from "@/components/user-avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Sidebar } from "./sidebar"; import { BottomNav } from "./bottom-nav"; import { QuickAddMenu } from "./quick-add-menu"; @@ -47,18 +55,28 @@ export default function AppLayout({ children }: { children: ReactNode }) {
- - {user.email} - - + + + + + + + {user.email} + + + }> + Profil + + { + void logout().then(() => router.push("/login")); + }} + > + Abmelden + + +
diff --git a/ApiCore-Frontend/src/app/(app)/profile/mfa-section.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/mfa-section.spec.tsx new file mode 100644 index 0000000..4ce912f --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/mfa-section.spec.tsx @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import { MfaSection } from "./mfa-section"; +import { confirmMfaEnrollment, disableMfa, enrollMfa } from "@/lib/profile/api"; + +vi.mock("@/lib/profile/api", () => ({ + enrollMfa: vi.fn(), + confirmMfaEnrollment: vi.fn(), + disableMfa: vi.fn(), +})); + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("MfaSection", () => { + beforeEach(() => { + vi.mocked(enrollMfa).mockReset(); + vi.mocked(confirmMfaEnrollment).mockReset(); + vi.mocked(disableMfa).mockReset(); + }); + + it("shows inactive status with an enable button when MFA is off", () => { + renderWithClient(); + expect(screen.getByText("Inaktiv")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Einrichten" })).toBeInTheDocument(); + }); + + it("starts enrollment, shows the QR code, and confirms with a code", async () => { + vi.mocked(enrollMfa).mockResolvedValueOnce({ + secret: "SECRET", + qrCodeSvg: "", + otpauthUri: "otpauth://totp/x", + setupToken: "setup-token", + }); + vi.mocked(confirmMfaEnrollment).mockResolvedValueOnce(undefined); + const onChanged = vi.fn(); + renderWithClient(); + + await userEvent.click(screen.getByRole("button", { name: "Einrichten" })); + await vi.waitFor(() => expect(enrollMfa).toHaveBeenCalled()); + + const codeInput = await screen.findByLabelText("Code"); + await userEvent.type(codeInput, "123456"); + await userEvent.click(screen.getByRole("button", { name: "Bestätigen" })); + + expect(confirmMfaEnrollment).toHaveBeenCalledWith("setup-token", "123456"); + await vi.waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); + + it("disables MFA after confirming the current password", async () => { + vi.mocked(disableMfa).mockResolvedValueOnce(undefined); + const onChanged = vi.fn(); + renderWithClient(); + + await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" })); + const passwordInput = await screen.findByLabelText("Passwort"); + await userEvent.type(passwordInput, "correct-password"); + await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" })); + + expect(disableMfa).toHaveBeenCalledWith("correct-password"); + await vi.waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/profile/mfa-section.tsx b/ApiCore-Frontend/src/app/(app)/profile/mfa-section.tsx new file mode 100644 index 0000000..6d568a2 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/mfa-section.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { ShieldCheck } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { confirmMfaEnrollment, disableMfa, enrollMfa } from "@/lib/profile/api"; +import { MfaEnrollment } from "@/lib/profile/types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +function EnrollDialog({ onEnabled }: { onEnabled: () => void }) { + const [open, setOpen] = useState(false); + const [enrollment, setEnrollment] = useState(null); + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + + const startMutation = useMutation({ + mutationFn: enrollMfa, + onSuccess: (result) => setEnrollment(result), + onError: () => setError("MFA-Einrichtung konnte nicht gestartet werden."), + }); + + const confirmMutation = useMutation({ + mutationFn: () => { + if (!enrollment) { + throw new Error("no enrollment"); + } + return confirmMfaEnrollment(enrollment.setupToken, code); + }, + onSuccess: () => { + toast.success("MFA aktiviert"); + onEnabled(); + handleClose(); + }, + onError: () => setError("Code ungültig."), + }); + + function handleTrigger() { + setError(null); + setEnrollment(null); + setCode(""); + setOpen(true); + startMutation.mutate(); + } + + function handleClose() { + setOpen(false); + setEnrollment(null); + setCode(""); + setError(null); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + confirmMutation.mutate(); + } + + return ( + (next ? undefined : handleClose())}> + + + + MFA einrichten + + QR-Code mit deiner Authenticator-App scannen, dann den angezeigten Code eingeben. + + + {enrollment ? ( +
+
+
+ + setCode(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + + + + + + ) : ( +

+ {startMutation.isPending ? "Lädt…" : error} +

+ )} + +
+ ); +} + +function DisableDialog({ onDisabled }: { onDisabled: () => void }) { + const [open, setOpen] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(null); + + const disableMutation = useMutation({ + mutationFn: () => disableMfa(currentPassword), + onSuccess: () => { + toast.success("MFA deaktiviert"); + onDisabled(); + handleClose(); + }, + onError: () => setError("Passwort ist falsch."), + }); + + function handleClose() { + setOpen(false); + setCurrentPassword(""); + setError(null); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + disableMutation.mutate(); + } + + return ( + (next ? undefined : handleClose())}> + + + + MFA deaktivieren + Zur Bestätigung aktuelles Passwort eingeben. + +
+
+ + setCurrentPassword(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+
+ ); +} + +export function MfaSection({ + mfaEnabled, + onChanged, +}: { + mfaEnabled: boolean; + onChanged: () => void; +}) { + return ( + + + + + Zwei-Faktor-Authentifizierung + + + Zusätzlicher Code aus einer Authenticator-App bei jeder Anmeldung. + + + + + {mfaEnabled ? "Aktiv" : "Inaktiv"} + + {mfaEnabled ? ( + + ) : ( + + )} + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.spec.tsx new file mode 100644 index 0000000..9fb8d1b --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/page.spec.tsx @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import ProfilePage from "./page"; +import { updateProfile, changePassword, uploadAvatar } from "@/lib/profile/api"; + +vi.mock("@/lib/profile/api", () => ({ + updateProfile: vi.fn(), + changePassword: vi.fn(), + uploadAvatar: vi.fn(), + deleteAvatar: vi.fn(), + fetchAvatarUrl: vi.fn().mockResolvedValue(null), + enrollMfa: vi.fn(), + confirmMfaEnrollment: vi.fn(), + disableMfa: vi.fn(), +})); + +const refreshUserMock = vi.fn(); +const mockUser = { + id: "user-1", + email: "existing@example.com", + name: "Existing User", + roleKey: "STAFF", + roleName: "Mitarbeiter", + permissions: [] as string[], + mfaEnabled: false, + hasAvatar: false, +}; +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock }), +})); + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("ProfilePage", () => { + beforeEach(() => { + vi.mocked(updateProfile).mockReset(); + vi.mocked(changePassword).mockReset(); + vi.mocked(uploadAvatar).mockReset(); + refreshUserMock.mockReset(); + }); + + it("submits the updated name and email", async () => { + vi.mocked(updateProfile).mockResolvedValueOnce({ + id: "user-1", + name: "New Name", + email: "new@example.com", + }); + renderWithClient(); + + const nameInput = screen.getByLabelText("Name"); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "New Name"); + await userEvent.click(screen.getByRole("button", { name: "Speichern" })); + + expect(updateProfile).toHaveBeenCalledWith({ + name: "New Name", + email: "existing@example.com", + }); + await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled()); + }); + + it("submits a password change", async () => { + vi.mocked(changePassword).mockResolvedValueOnce(undefined); + renderWithClient(); + + await userEvent.type(screen.getByLabelText("Aktuelles Passwort"), "old-password"); + await userEvent.type(screen.getByLabelText("Neues Passwort"), "new-password123"); + await userEvent.click(screen.getByRole("button", { name: "Passwort ändern" })); + + expect(changePassword).toHaveBeenCalledWith("old-password", "new-password123"); + }); + + it("uploads a selected avatar file", async () => { + vi.mocked(uploadAvatar).mockResolvedValueOnce(undefined); + renderWithClient(); + + const file = new File(["binary"], "me.jpg", { type: "image/jpeg" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await userEvent.upload(fileInput, file); + + expect(vi.mocked(uploadAvatar).mock.calls[0]?.[0]).toBe(file); + await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled()); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.tsx new file mode 100644 index 0000000..666616c --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/page.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { ChangeEvent, FormEvent, useRef, useState } from "react"; +import { UserRound } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useAuth } from "@/lib/auth/auth-context"; +import { + changePassword, + deleteAvatar, + updateProfile, + uploadAvatar, +} from "@/lib/profile/api"; +import { PageHeader } from "@/components/page-header"; +import { UserAvatar } from "@/components/user-avatar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { MfaSection } from "./mfa-section"; + +function AvatarSection() { + const { user, refreshUser } = useAuth(); + const fileInputRef = useRef(null); + + const uploadMutation = useMutation({ + mutationFn: uploadAvatar, + onSuccess: async () => { + await refreshUser(); + toast.success("Profilbild aktualisiert"); + }, + onError: () => toast.error("Profilbild konnte nicht hochgeladen werden"), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteAvatar, + onSuccess: async () => { + await refreshUser(); + toast.success("Profilbild entfernt"); + }, + onError: () => toast.error("Profilbild konnte nicht entfernt werden"), + }); + + function handleFileChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) { + uploadMutation.mutate(file); + } + } + + if (!user) { + return null; + } + + return ( + + + Profilbild + + + +
+ + + {user.hasAvatar ? ( + + ) : null} +
+
+
+ ); +} + +function ProfileInfoSection() { + const { user, refreshUser } = useAuth(); + const [name, setName] = useState(user?.name ?? ""); + const [email, setEmail] = useState(user?.email ?? ""); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(null); + const emailChanged = email !== user?.email; + + const mutation = useMutation({ + mutationFn: () => updateProfile({ name, email, currentPassword: currentPassword || undefined }), + onSuccess: async () => { + setCurrentPassword(""); + await refreshUser(); + toast.success("Profil aktualisiert"); + }, + onError: () => + setError( + emailChanged + ? "Speichern fehlgeschlagen. Passwort falsch oder E-Mail schon vergeben." + : "Speichern fehlgeschlagen.", + ), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate(); + } + + return ( + + + Profil + Name und E-Mail-Adresse + + +
+
+ + setName(event.target.value)} + /> +
+
+ + setEmail(event.target.value)} + /> +
+ {emailChanged ? ( +
+ + setCurrentPassword(event.target.value)} + /> +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+ ); +} + +function PasswordSection() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: () => changePassword(currentPassword, newPassword), + onSuccess: () => { + setCurrentPassword(""); + setNewPassword(""); + toast.success("Passwort geändert", { + description: "Andere angemeldete Geräte wurden abgemeldet.", + }); + }, + onError: () => setError("Aktuelles Passwort ist falsch."), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate(); + } + + return ( + + + Passwort + + +
+
+ + setCurrentPassword(event.target.value)} + /> +
+
+ + setNewPassword(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+ ); +} + +export default function ProfilePage() { + const { user, refreshUser } = useAuth(); + + if (!user) { + return null; + } + + return ( +
+ +
+ + + + void refreshUser()} /> +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx index 30e6b0a..5d152d6 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx @@ -10,6 +10,7 @@ vi.mock("@/lib/users/api", () => ({ deactivateUser: vi.fn(), activateUser: vi.fn(), createUser: vi.fn(), + resetUserMfa: vi.fn(), })); function renderWithClient(ui: ReactElement) { @@ -35,6 +36,7 @@ describe("UsersPage", () => { isActive: true, createdAt: "", deactivatedAt: null, + mfaEnabled: false, }, ]); diff --git a/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx index f2470b2..80733b5 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx @@ -3,7 +3,7 @@ import { Users } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { activateUser, deactivateUser, fetchUsers } from "@/lib/users/api"; +import { activateUser, deactivateUser, fetchUsers, resetUserMfa } from "@/lib/users/api"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ConfirmDialog } from "@/components/confirm-dialog"; @@ -46,6 +46,15 @@ export default function UsersPage() { onError: () => toast.error("Benutzer konnte nicht aktiviert werden"), }); + const resetMfaMutation = useMutation({ + mutationFn: resetUserMfa, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["users"] }); + toast.success("MFA zurückgesetzt"); + }, + onError: () => toast.error("MFA konnte nicht zurückgesetzt werden"), + }); + return (
E-Mail Rolle Status + MFA Aktion @@ -89,28 +99,48 @@ export default function UsersPage() { {user.isActive ? "Aktiv" : "Deaktiviert"} + + + {user.mfaEnabled ? "Aktiv" : "Inaktiv"} + + - {user.isActive ? ( - deactivateMutation.mutate(user.id)} - /> - ) : ( - - )} +
+ {user.mfaEnabled ? ( + resetMfaMutation.mutate(user.id)} + /> + ) : null} + {user.isActive ? ( + deactivateMutation.mutate(user.id)} + /> + ) : ( + + )} +
))} diff --git a/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx b/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx index dac6aa8..18d923b 100644 --- a/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx @@ -9,18 +9,26 @@ vi.mock("next/navigation", () => ({ })); const loginMock = vi.fn(); +const verifyMfaMock = vi.fn(); vi.mock("@/lib/auth/auth-context", () => ({ - useAuth: () => ({ login: loginMock, user: null, isLoading: false, logout: vi.fn() }), + useAuth: () => ({ + login: loginMock, + verifyMfa: verifyMfaMock, + user: null, + isLoading: false, + logout: vi.fn(), + }), })); describe("LoginPage", () => { beforeEach(() => { pushMock.mockReset(); loginMock.mockReset(); + verifyMfaMock.mockReset(); }); it("submits credentials and redirects on success", async () => { - loginMock.mockResolvedValueOnce(undefined); + loginMock.mockResolvedValueOnce({ mfaRequired: false }); render(); await userEvent.type(screen.getByLabelText("E-Mail"), "admin@apicore.local"); @@ -41,4 +49,38 @@ describe("LoginPage", () => { expect(await screen.findByRole("alert")).toHaveTextContent(/fehlgeschlagen/i); }); + + it("shows the code step and verifies MFA when required", async () => { + loginMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "mfa-token" }); + verifyMfaMock.mockResolvedValueOnce(undefined); + render(); + + await userEvent.type(screen.getByLabelText("E-Mail"), "admin@apicore.local"); + await userEvent.type(screen.getByLabelText("Passwort"), "ChangeMe123!"); + await userEvent.click(screen.getByRole("button", { name: /anmelden/i })); + + expect(pushMock).not.toHaveBeenCalled(); + const codeInput = await screen.findByLabelText("Code"); + await userEvent.type(codeInput, "123456"); + await userEvent.click(screen.getByRole("button", { name: /bestätigen/i })); + + expect(verifyMfaMock).toHaveBeenCalledWith("mfa-token", "123456"); + expect(pushMock).toHaveBeenCalledWith("/dashboard"); + }); + + it("shows an error message when the MFA code is invalid", async () => { + loginMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "mfa-token" }); + verifyMfaMock.mockRejectedValueOnce(new Error("nope")); + render(); + + await userEvent.type(screen.getByLabelText("E-Mail"), "admin@apicore.local"); + await userEvent.type(screen.getByLabelText("Passwort"), "ChangeMe123!"); + await userEvent.click(screen.getByRole("button", { name: /anmelden/i })); + + const codeInput = await screen.findByLabelText("Code"); + await userEvent.type(codeInput, "000000"); + await userEvent.click(screen.getByRole("button", { name: /bestätigen/i })); + + expect(await screen.findByRole("alert")).toHaveTextContent(/ungültig/i); + }); }); diff --git a/ApiCore-Frontend/src/app/(auth)/login/page.tsx b/ApiCore-Frontend/src/app/(auth)/login/page.tsx index cfe7363..34d3fe6 100644 --- a/ApiCore-Frontend/src/app/(auth)/login/page.tsx +++ b/ApiCore-Frontend/src/app/(auth)/login/page.tsx @@ -18,19 +18,25 @@ import { export default function LoginPage() { const router = useRouter(); - const { login } = useAuth(); + const { login, verifyMfa } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [mfaToken, setMfaToken] = useState(null); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - async function handleSubmit(event: FormEvent) { + async function handleCredentialsSubmit(event: FormEvent) { event.preventDefault(); setError(null); setIsSubmitting(true); try { - await login(email, password); - router.push("/dashboard"); + const result = await login(email, password); + if (result.mfaRequired) { + setMfaToken(result.mfaToken); + } else { + router.push("/dashboard"); + } } catch { setError("Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen."); } finally { @@ -38,6 +44,23 @@ export default function LoginPage() { } } + async function handleMfaSubmit(event: FormEvent) { + event.preventDefault(); + if (!mfaToken) { + return; + } + setError(null); + setIsSubmitting(true); + try { + await verifyMfa(mfaToken, code); + router.push("/dashboard"); + } catch { + setError("Code ungültig. Bitte erneut versuchen."); + } finally { + setIsSubmitting(false); + } + } + return (
@@ -52,41 +75,83 @@ export default function LoginPage() { ApiCore
- Digitale Imkereiverwaltung für Wabenhain + + {mfaToken + ? "Code aus deiner Authenticator-App eingeben" + : "Digitale Imkereiverwaltung für Wabenhain"} + -
-
- - setEmail(event.target.value)} - /> -
-
- - setPassword(event.target.value)} - /> -
- {error ? ( -

- {error} -

- ) : null} - -
+ {mfaToken ? ( +
+
+ + setCode(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + + +
+ ) : ( +
+
+ + setEmail(event.target.value)} + /> +
+
+ + setPassword(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ )}
diff --git a/ApiCore-Frontend/src/components/user-avatar.spec.tsx b/ApiCore-Frontend/src/components/user-avatar.spec.tsx new file mode 100644 index 0000000..7fd4377 --- /dev/null +++ b/ApiCore-Frontend/src/components/user-avatar.spec.tsx @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { UserAvatar } from "./user-avatar"; +import { fetchAvatarUrl } from "@/lib/profile/api"; + +vi.mock("@/lib/profile/api", () => ({ + fetchAvatarUrl: vi.fn(), +})); + +describe("UserAvatar", () => { + beforeEach(() => { + vi.mocked(fetchAvatarUrl).mockReset(); + }); + + it("shows initials and does not fetch when there is no avatar", () => { + render(); + + expect(screen.getByText("AB")).toBeInTheDocument(); + expect(fetchAvatarUrl).not.toHaveBeenCalled(); + }); + + it("fetches the avatar blob when hasAvatar is true", async () => { + // base-ui's AvatarImage only swaps in once the browser fires a real + // image load event, which jsdom never does for a blob: URL - so this + // asserts on our own fetch call, not on what base-ui renders from it. + vi.mocked(fetchAvatarUrl).mockResolvedValueOnce("blob:mock-url"); + render(); + + await vi.waitFor(() => expect(fetchAvatarUrl).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/ApiCore-Frontend/src/components/user-avatar.tsx b/ApiCore-Frontend/src/components/user-avatar.tsx new file mode 100644 index 0000000..12eabf9 --- /dev/null +++ b/ApiCore-Frontend/src/components/user-avatar.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { fetchAvatarUrl } from "@/lib/profile/api"; + +function initials(name: string): string { + return name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); +} + +export function UserAvatar({ + name, + hasAvatar, + size = "default", +}: { + name: string; + hasAvatar: boolean; + size?: "default" | "sm" | "lg"; +}) { + const [avatarUrl, setAvatarUrl] = useState(null); + + useEffect(() => { + if (!hasAvatar) { + return; + } + let cancelled = false; + let objectUrl: string | null = null; + void fetchAvatarUrl().then((url) => { + if (cancelled) { + if (url) { + URL.revokeObjectURL(url); + } + return; + } + objectUrl = url; + setAvatarUrl(url); + }); + return () => { + cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [hasAvatar]); + + const showImage = hasAvatar && avatarUrl; + + return ( + + {showImage ? : null} + {initials(name) || "?"} + + ); +} diff --git a/ApiCore-Frontend/src/lib/auth/api.ts b/ApiCore-Frontend/src/lib/auth/api.ts index 7606ab8..c3dd636 100644 --- a/ApiCore-Frontend/src/lib/auth/api.ts +++ b/ApiCore-Frontend/src/lib/auth/api.ts @@ -4,7 +4,18 @@ export interface AccessTokenResponse { accessToken: string; } -export async function loginRequest(email: string, password: string): Promise { +export interface MfaRequiredResponse { + mfaRequired: true; + mfaToken: string; +} + +export type LoginResponse = AccessTokenResponse | MfaRequiredResponse; + +export function isMfaRequired(response: LoginResponse): response is MfaRequiredResponse { + return 'mfaRequired' in response && response.mfaRequired; +} + +export async function loginRequest(email: string, password: string): Promise { const response = await fetch(`${API_URL}/auth/login`, { method: 'POST', credentials: 'include', @@ -14,6 +25,19 @@ export async function loginRequest(email: string, password: string): Promise; +} + +export async function verifyMfaRequest(mfaToken: string, code: string): Promise { + const response = await fetch(`${API_URL}/auth/mfa/verify`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mfaToken, code }), + }); + if (!response.ok) { + throw new Error('Code ungültig. Bitte erneut versuchen.'); + } return response.json() as Promise; } @@ -56,6 +80,8 @@ export interface CurrentUserProfile { roleKey: string; roleName: string; permissions: string[]; + mfaEnabled: boolean; + hasAvatar: boolean; } export async function getCurrentUserRequest(accessToken: string): Promise { diff --git a/ApiCore-Frontend/src/lib/auth/auth-context.tsx b/ApiCore-Frontend/src/lib/auth/auth-context.tsx index c856f30..c23975e 100644 --- a/ApiCore-Frontend/src/lib/auth/auth-context.tsx +++ b/ApiCore-Frontend/src/lib/auth/auth-context.tsx @@ -1,8 +1,15 @@ 'use client'; import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react'; -import { getCurrentUserRequest, loginRequest, logoutRequest, refreshRequest } from './api'; -import { setAccessToken as storeAccessToken } from './token-store'; +import { + getCurrentUserRequest, + isMfaRequired, + loginRequest, + logoutRequest, + refreshRequest, + verifyMfaRequest, +} from './api'; +import { getAccessToken, setAccessToken as storeAccessToken } from './token-store'; export interface AuthUser { id: string; @@ -11,14 +18,20 @@ export interface AuthUser { roleKey: string; roleName: string; permissions: string[]; + mfaEnabled: boolean; + hasAvatar: boolean; } +export type LoginOutcome = { mfaRequired: false } | { mfaRequired: true; mfaToken: string }; + interface AuthContextValue { user: AuthUser | null; isLoading: boolean; - login: (email: string, password: string) => Promise; + login: (email: string, password: string) => Promise; + verifyMfa: (mfaToken: string, code: string) => Promise; logout: () => Promise; hasPermission: (permission: string) => boolean; + refreshUser: () => Promise; } const AuthContext = createContext(undefined); @@ -49,8 +62,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); }, []); - const login = useCallback(async (email: string, password: string) => { + const login = useCallback(async (email: string, password: string): Promise => { const result = await loginRequest(email, password); + if (isMfaRequired(result)) { + return { mfaRequired: true, mfaToken: result.mfaToken }; + } + setUser(await applyAccessToken(result.accessToken)); + return { mfaRequired: false }; + }, []); + + const verifyMfa = useCallback(async (mfaToken: string, code: string) => { + const result = await verifyMfaRequest(mfaToken, code); setUser(await applyAccessToken(result.accessToken)); }, []); @@ -65,8 +87,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { [user], ); + // Re-fetches the profile with the access token already held in memory - + // no need to rotate the refresh-token cookie just to reflect a self-edit + // (name/email/avatar/MFA change) in the cached user. + const refreshUser = useCallback(async () => { + const token = getAccessToken(); + if (!token) { + return; + } + setUser(await applyAccessToken(token)); + }, []); + return ( - + {children} ); diff --git a/ApiCore-Frontend/src/lib/profile/api.ts b/ApiCore-Frontend/src/lib/profile/api.ts new file mode 100644 index 0000000..ed9104b --- /dev/null +++ b/ApiCore-Frontend/src/lib/profile/api.ts @@ -0,0 +1,57 @@ +import { apiFetch, apiFetchBlob, apiFetchFormData } from "@/lib/auth/api-client"; +import { MfaEnrollment, ProfileSummary, UpdateProfileInput } from "./types"; + +export function updateProfile(input: UpdateProfileInput): Promise { + return apiFetch("/profile", { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export function changePassword(currentPassword: string, newPassword: string): Promise { + return apiFetch("/profile/password", { + method: "POST", + body: JSON.stringify({ currentPassword, newPassword }), + }); +} + +export function uploadAvatar(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + return apiFetchFormData("/profile/avatar", formData); +} + +export function deleteAvatar(): Promise { + return apiFetch("/profile/avatar", { method: "DELETE" }); +} + +// Fetches the avatar as a blob (the endpoint requires the caller's own +// Bearer token, so a plain can't hit it directly) and hands +// back an object URL for use as an source. Returns null when the user +// has no avatar (404) or the request otherwise fails. +export async function fetchAvatarUrl(): Promise { + try { + const { blob } = await apiFetchBlob("/profile/avatar", "avatar"); + return URL.createObjectURL(blob); + } catch { + return null; + } +} + +export function enrollMfa(): Promise { + return apiFetch("/profile/mfa/enroll", { method: "POST" }); +} + +export function confirmMfaEnrollment(setupToken: string, code: string): Promise { + return apiFetch("/profile/mfa/enroll/confirm", { + method: "POST", + body: JSON.stringify({ setupToken, code }), + }); +} + +export function disableMfa(currentPassword: string): Promise { + return apiFetch("/profile/mfa/disable", { + method: "POST", + body: JSON.stringify({ currentPassword }), + }); +} diff --git a/ApiCore-Frontend/src/lib/profile/types.ts b/ApiCore-Frontend/src/lib/profile/types.ts new file mode 100644 index 0000000..f311b98 --- /dev/null +++ b/ApiCore-Frontend/src/lib/profile/types.ts @@ -0,0 +1,19 @@ +export interface ProfileSummary { + id: string; + email: string; + name: string; +} + +export interface UpdateProfileInput { + name?: string; + email?: string; + /** Required by the backend when email is being changed; ignored otherwise. */ + currentPassword?: string; +} + +export interface MfaEnrollment { + secret: string; + qrCodeSvg: string; + otpauthUri: string; + setupToken: string; +} diff --git a/ApiCore-Frontend/src/lib/users/api.ts b/ApiCore-Frontend/src/lib/users/api.ts index 5bc6d86..636492f 100644 --- a/ApiCore-Frontend/src/lib/users/api.ts +++ b/ApiCore-Frontend/src/lib/users/api.ts @@ -19,3 +19,7 @@ export function deactivateUser(id: string): Promise { export function activateUser(id: string): Promise { return apiFetch(`/users/${id}/activate`, { method: "POST" }); } + +export function resetUserMfa(id: string): Promise { + return apiFetch(`/users/${id}/mfa/reset`, { method: "POST" }); +} diff --git a/ApiCore-Frontend/src/lib/users/types.ts b/ApiCore-Frontend/src/lib/users/types.ts index 68e7963..64e421a 100644 --- a/ApiCore-Frontend/src/lib/users/types.ts +++ b/ApiCore-Frontend/src/lib/users/types.ts @@ -8,6 +8,7 @@ export interface User { isActive: boolean; createdAt: string; deactivatedAt: string | null; + mfaEnabled: boolean; } export interface CreateUserInput {