feat: MFA (TOTP) und Self-Service-Profilseite
Backend Image / build-and-push (push) Successful in 2m57s
Frontend Image / build-and-push (push) Successful in 2m30s

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:
Nick Szaunig committed 2026-08-27 00:25:34 +02:00
1 parent 6776bdfe73
commit 62004483cf
55 files changed
+2876 -129

No files matched your search

+2
View File
@@ -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: [
+27 -9
View File
@@ -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(
+2 -1
View File
@@ -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);
+55 -5
View File
@@ -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);
});
});
});
+108
View File
@@ -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);
});
});
+32
View File
@@ -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) {