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).
This commit is contained in:
1 parent
6776bdfe73
commit
62004483cf
55 files changed
+2876
-129
No files matched your search
Generated
+92
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "passwordChangedAt" TIMESTAMP(3);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<AccessTokenDto | MfaRequiredResponse> {
|
||||
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<AccessTokenDto> {
|
||||
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(
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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: '[email protected]',
|
||||
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);
|
||||
|
||||
@@ -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<CurrentUserProfile> {
|
||||
@@ -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<AuthTokens> {
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
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<AuthTokens> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RequestWithUser>();
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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',
|
||||
'[email protected]',
|
||||
);
|
||||
|
||||
expect(enrollment.secret).toMatch(/^[A-Z2-7]+$/);
|
||||
expect(enrollment.qrCodeSvg).toContain('<svg');
|
||||
expect(enrollment.otpauthUri).toContain('otpauth://totp/');
|
||||
expect(enrollment.setupToken).toBe('signed-setup-token');
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sub: 'user-1',
|
||||
secret: enrollment.secret,
|
||||
purpose: 'mfa-setup',
|
||||
}),
|
||||
expect.objectContaining({ expiresIn: '10m' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmEnrollment', () => {
|
||||
async function totpCodeFor(secret: string): Promise<string> {
|
||||
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',
|
||||
'[email protected]',
|
||||
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',
|
||||
'[email protected]',
|
||||
'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',
|
||||
'[email protected]',
|
||||
'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',
|
||||
'[email protected]',
|
||||
'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, '[email protected]', code),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<MfaEnrollment> {
|
||||
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<SetupTokenPayload>(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<ChallengeTokenPayload>(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<boolean> {
|
||||
const secret = decryptMfaSecret(encryptedSecret);
|
||||
return verifyTotpCode(secret, email, code);
|
||||
}
|
||||
}
|
||||
@@ -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', '[email protected]');
|
||||
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, '[email protected]', 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, '[email protected]', 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, '[email protected]', codeFromB),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
const result = await createTotp(secret, label).verify(code, {
|
||||
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
||||
});
|
||||
return result.valid;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PermissionKey } from './permission-catalog';
|
||||
|
||||
interface CacheEntry {
|
||||
isActive: boolean;
|
||||
passwordChangedAt: Date | null;
|
||||
permissions: ReadonlySet<string>;
|
||||
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<boolean> {
|
||||
async isTokenValid(
|
||||
userId: string,
|
||||
tokenIssuedAtSeconds: number,
|
||||
): Promise<boolean> {
|
||||
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<string>();
|
||||
return {
|
||||
isActive: isEligible,
|
||||
passwordChangedAt: user?.passwordChangedAt ?? null,
|
||||
permissions,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
/** JWT `iat` claim (seconds since epoch) - see PermissionResolverService.isTokenValid. */
|
||||
tokenIssuedAt: number;
|
||||
}
|
||||
@@ -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 } };
|
||||
@@ -0,0 +1,17 @@
|
||||
export const ALLOWED_AVATAR_MIME_TYPES: Record<string, string> = {
|
||||
'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<string, string> = 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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DisableMfaDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
});
|
||||
|
||||
const result = await service.updateProfile('user-1', {
|
||||
name: 'New Name',
|
||||
email: '[email protected]',
|
||||
currentPassword: 'correct-password',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'user-1',
|
||||
name: 'New Name',
|
||||
email: '[email protected]',
|
||||
});
|
||||
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: '<svg></svg>',
|
||||
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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ProfileSummary> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<MfaEnrollment> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<UserSummary> {
|
||||
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<UserSummary> {
|
||||
const before = await this.findById(id);
|
||||
if (before.isActive) {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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 }) {
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<OfflineSyncBadge />
|
||||
<NotificationBell />
|
||||
<span className="hidden max-w-40 truncate text-muted-foreground md:inline">
|
||||
{user.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void logout().then(() => router.push("/login"));
|
||||
}}
|
||||
>
|
||||
Abmelden
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full">
|
||||
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
|
||||
{user.email}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem render={<Link href="/profile" />}>
|
||||
Profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
void logout().then(() => router.push("/login"));
|
||||
}}
|
||||
>
|
||||
Abmelden
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto pb-16 sm:pb-0">
|
||||
|
||||
@@ -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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
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(<MfaSection mfaEnabled={false} onChanged={vi.fn()} />);
|
||||
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: "<svg><rect /></svg>",
|
||||
otpauthUri: "otpauth://totp/x",
|
||||
setupToken: "setup-token",
|
||||
});
|
||||
vi.mocked(confirmMfaEnrollment).mockResolvedValueOnce(undefined);
|
||||
const onChanged = vi.fn();
|
||||
renderWithClient(<MfaSection mfaEnabled={false} onChanged={onChanged} />);
|
||||
|
||||
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(<MfaSection mfaEnabled onChanged={onChanged} />);
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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<MfaEnrollment | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
confirmMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
|
||||
<Button type="button" onClick={handleTrigger}>
|
||||
Einrichten
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>MFA einrichten</DialogTitle>
|
||||
<DialogDescription>
|
||||
QR-Code mit deiner Authenticator-App scannen, dann den angezeigten Code eingeben.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{enrollment ? (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div
|
||||
className="mx-auto [&_svg]:size-48"
|
||||
// Server-generated from a URI we built ourselves (qrcode npm
|
||||
// package), not user input - safe to render directly.
|
||||
dangerouslySetInnerHTML={{ __html: enrollment.qrCodeSvg }}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mfa-code">Code</Label>
|
||||
<Input
|
||||
id="mfa-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
required
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={confirmMutation.isPending}>
|
||||
{confirmMutation.isPending ? "Prüfe…" : "Bestätigen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{startMutation.isPending ? "Lädt…" : error}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
disableMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? undefined : handleClose())}>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
||||
Deaktivieren
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>MFA deaktivieren</DialogTitle>
|
||||
<DialogDescription>Zur Bestätigung aktuelles Passwort eingeben.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="disable-mfa-password">Passwort</Label>
|
||||
<Input
|
||||
id="disable-mfa-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={disableMutation.isPending}>
|
||||
{disableMutation.isPending ? "Wird deaktiviert…" : "Deaktivieren"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function MfaSection({
|
||||
mfaEnabled,
|
||||
onChanged,
|
||||
}: {
|
||||
mfaEnabled: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4.5" />
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Zusätzlicher Code aus einer Authenticator-App bei jeder Anmeldung.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-3">
|
||||
<Badge variant={mfaEnabled ? "default" : "secondary"}>
|
||||
{mfaEnabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
{mfaEnabled ? (
|
||||
<DisableDialog onDisabled={onChanged} />
|
||||
) : (
|
||||
<EnrollDialog onEnabled={onChanged} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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: "[email protected]",
|
||||
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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
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: "[email protected]",
|
||||
});
|
||||
renderWithClient(<ProfilePage />);
|
||||
|
||||
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: "[email protected]",
|
||||
});
|
||||
await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("submits a password change", async () => {
|
||||
vi.mocked(changePassword).mockResolvedValueOnce(undefined);
|
||||
renderWithClient(<ProfilePage />);
|
||||
|
||||
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(<ProfilePage />);
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) {
|
||||
uploadMutation.mutate(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profilbild</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-4">
|
||||
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="lg" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploadMutation.isPending ? "Lädt hoch…" : "Bild wählen"}
|
||||
</Button>
|
||||
{user.hasAvatar ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
Entfernen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profil</CardTitle>
|
||||
<CardDescription>Name und E-Mail-Adresse</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="profile-name">Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="profile-email">E-Mail</Label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{emailChanged ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="profile-current-password">
|
||||
Aktuelles Passwort (zur Bestätigung der neuen E-Mail)
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" className="self-start" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordSection() {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Passwort</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="current-password">Aktuelles Passwort</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">Neues Passwort</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" className="self-start" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Ändert…" : "Passwort ändern"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
icon={UserRound}
|
||||
title="Mein Profil"
|
||||
description="Persönliche Daten, Passwort und Sicherheit"
|
||||
gradient="var(--gradient-sage)"
|
||||
/>
|
||||
<div className="grid gap-4 sm:max-w-xl">
|
||||
<AvatarSection />
|
||||
<ProfileInfoSection />
|
||||
<PasswordSection />
|
||||
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
@@ -75,6 +84,7 @@ export default function UsersPage() {
|
||||
<TableHead>E-Mail</TableHead>
|
||||
<TableHead>Rolle</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>MFA</TableHead>
|
||||
<TableHead className="text-right">Aktion</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -89,28 +99,48 @@ export default function UsersPage() {
|
||||
{user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.mfaEnabled ? "default" : "outline"}>
|
||||
{user.mfaEnabled ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{user.isActive ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={deactivateMutation.isPending}
|
||||
title="Benutzer deaktivieren?"
|
||||
description={`„${user.name}“ kann sich danach nicht mehr anmelden. Der Zugang kann jederzeit über „Aktivieren“ wiederhergestellt werden.`}
|
||||
confirmLabel="Deaktivieren"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(user.id)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={activateMutation.isPending}
|
||||
onClick={() => activateMutation.mutate(user.id)}
|
||||
>
|
||||
Aktivieren
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
{user.mfaEnabled ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="MFA zurücksetzen"
|
||||
triggerVariant="outline"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={resetMfaMutation.isPending}
|
||||
title="MFA zurücksetzen?"
|
||||
description={`„${user.name}“ muss sich beim nächsten Login ohne Zwei-Faktor-Code anmelden und kann MFA im eigenen Profil neu einrichten. Nutze das nur, wenn das Gerät verloren ging.`}
|
||||
confirmLabel="Zurücksetzen"
|
||||
isPending={resetMfaMutation.isPending}
|
||||
onConfirm={() => resetMfaMutation.mutate(user.id)}
|
||||
/>
|
||||
) : null}
|
||||
{user.isActive ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={deactivateMutation.isPending}
|
||||
title="Benutzer deaktivieren?"
|
||||
description={`„${user.name}“ kann sich danach nicht mehr anmelden. Der Zugang kann jederzeit über „Aktivieren“ wiederhergestellt werden.`}
|
||||
confirmLabel="Deaktivieren"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(user.id)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={activateMutation.isPending}
|
||||
onClick={() => activateMutation.mutate(user.id)}
|
||||
>
|
||||
Aktivieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -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(<LoginPage />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("E-Mail"), "[email protected]");
|
||||
@@ -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(<LoginPage />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("E-Mail"), "[email protected]");
|
||||
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(<LoginPage />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText("E-Mail"), "[email protected]");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
async function handleCredentialsSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
||||
<HoneycombPattern />
|
||||
@@ -52,41 +75,83 @@ export default function LoginPage() {
|
||||
</span>
|
||||
<CardTitle className="text-lg">ApiCore</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Digitale Imkereiverwaltung für Wabenhain</CardDescription>
|
||||
<CardDescription>
|
||||
{mfaToken
|
||||
? "Code aus deiner Authenticator-App eingeben"
|
||||
: "Digitale Imkereiverwaltung für Wabenhain"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Anmelden…" : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
{mfaToken ? (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleMfaSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="code">Code</Label>
|
||||
<Input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Prüfe…" : "Bestätigen"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMfaToken(null);
|
||||
setCode("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleCredentialsSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Anmelden…" : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
@@ -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(<UserAvatar name="Anna Berger" hasAvatar={false} />);
|
||||
|
||||
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(<UserAvatar name="Anna Berger" hasAvatar />);
|
||||
|
||||
await vi.waitFor(() => expect(fetchAvatarUrl).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(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 (
|
||||
<Avatar size={size}>
|
||||
{showImage ? <AvatarImage src={avatarUrl} alt={name} /> : null}
|
||||
<AvatarFallback>{initials(name) || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,18 @@ export interface AccessTokenResponse {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export async function loginRequest(email: string, password: string): Promise<AccessTokenResponse> {
|
||||
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<LoginResponse> {
|
||||
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<Acc
|
||||
if (!response.ok) {
|
||||
throw new Error('Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen.');
|
||||
}
|
||||
return response.json() as Promise<LoginResponse>;
|
||||
}
|
||||
|
||||
export async function verifyMfaRequest(mfaToken: string, code: string): Promise<AccessTokenResponse> {
|
||||
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<AccessTokenResponse>;
|
||||
}
|
||||
|
||||
@@ -56,6 +80,8 @@ export interface CurrentUserProfile {
|
||||
roleKey: string;
|
||||
roleName: string;
|
||||
permissions: string[];
|
||||
mfaEnabled: boolean;
|
||||
hasAvatar: boolean;
|
||||
}
|
||||
|
||||
export async function getCurrentUserRequest(accessToken: string): Promise<CurrentUserProfile> {
|
||||
|
||||
@@ -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<void>;
|
||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(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<LoginOutcome> => {
|
||||
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 (
|
||||
<AuthContext.Provider value={{ user, isLoading, login, logout, hasPermission }}>
|
||||
<AuthContext.Provider
|
||||
value={{ user, isLoading, login, verifyMfa, logout, hasPermission, refreshUser }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<ProfileSummary> {
|
||||
return apiFetch<ProfileSummary>("/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
return apiFetch<void>("/profile/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadAvatar(file: File): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return apiFetchFormData<void>("/profile/avatar", formData);
|
||||
}
|
||||
|
||||
export function deleteAvatar(): Promise<void> {
|
||||
return apiFetch<void>("/profile/avatar", { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Fetches the avatar as a blob (the endpoint requires the caller's own
|
||||
// Bearer token, so a plain <img src="..."> can't hit it directly) and hands
|
||||
// back an object URL for use as an <img> source. Returns null when the user
|
||||
// has no avatar (404) or the request otherwise fails.
|
||||
export async function fetchAvatarUrl(): Promise<string | null> {
|
||||
try {
|
||||
const { blob } = await apiFetchBlob("/profile/avatar", "avatar");
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function enrollMfa(): Promise<MfaEnrollment> {
|
||||
return apiFetch<MfaEnrollment>("/profile/mfa/enroll", { method: "POST" });
|
||||
}
|
||||
|
||||
export function confirmMfaEnrollment(setupToken: string, code: string): Promise<void> {
|
||||
return apiFetch<void>("/profile/mfa/enroll/confirm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ setupToken, code }),
|
||||
});
|
||||
}
|
||||
|
||||
export function disableMfa(currentPassword: string): Promise<void> {
|
||||
return apiFetch<void>("/profile/mfa/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,3 +19,7 @@ export function deactivateUser(id: string): Promise<User> {
|
||||
export function activateUser(id: string): Promise<User> {
|
||||
return apiFetch<User>(`/users/${id}/activate`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function resetUserMfa(id: string): Promise<User> {
|
||||
return apiFetch<User>(`/users/${id}/mfa/reset`, { method: "POST" });
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface User {
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
deactivatedAt: string | null;
|
||||
mfaEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
|
||||
Reference in new issue
Block a user