fix(auth): lock accounts after failed attempts, one-time TOTP codes, stronger MFA changes
- Per-account lockout: five failed attempts (password, MFA code or a step-up confirmation) lock the account for 15 minutes, on top of the per-IP throttle - guesses spread over many addresses no longer help. - A TOTP code is accepted once: the time step is stored and that step or any earlier one is refused (also between two concurrent requests). - Login runs bcrypt on every path and answers unknown email, wrong password, deactivated and locked accounts with the same message; failed logins for unknown emails no longer write attacker-chosen text into the audit log. - Turning MFA off needs password and a current code; turning it on needs the password. Both end all other sessions. - New audit action ACCOUNT_LOCKED. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
79d20ee64e
commit
5c666fe49a
22 files changed
+494
-91
No files matched your search
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-- TOTP replay protection and per-account lockout after failed attempts.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "mfaLastTimeStep" INTEGER;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "failedAuthAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "authLockedUntil" TIMESTAMP(3);
|
||||||
|
ALTER TYPE "AuditAction" ADD VALUE 'ACCOUNT_LOCKED';
|
||||||
@@ -80,6 +80,14 @@ model User {
|
|||||||
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
|
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
|
||||||
mfaEnabled Boolean @default(false)
|
mfaEnabled Boolean @default(false)
|
||||||
mfaSecret String?
|
mfaSecret String?
|
||||||
|
// Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer)
|
||||||
|
// wird danach nie wieder akzeptiert (Replay-Schutz).
|
||||||
|
mfaLastTimeStep Int?
|
||||||
|
|
||||||
|
// Fehlversuche (Passwort, MFA-Code, Bestaetigung) seit dem letzten Erfolg;
|
||||||
|
// ab 5 ist das Konto bis authLockedUntil gesperrt.
|
||||||
|
failedAuthAttempts Int @default(0)
|
||||||
|
authLockedUntil DateTime?
|
||||||
avatarPath String?
|
avatarPath String?
|
||||||
|
|
||||||
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
|
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
|
||||||
@@ -168,6 +176,7 @@ enum AuditAction {
|
|||||||
TOKEN_REFRESHED
|
TOKEN_REFRESHED
|
||||||
MFA_ENABLED
|
MFA_ENABLED
|
||||||
MFA_DISABLED
|
MFA_DISABLED
|
||||||
|
ACCOUNT_LOCKED
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { MfaService } from './mfa/mfa.service';
|
import { MfaService } from './mfa/mfa.service';
|
||||||
|
import { AccountGuardService } from './mfa/account-guard.service';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
@@ -26,7 +27,12 @@ describe('AuthService', () => {
|
|||||||
let mfaService: {
|
let mfaService: {
|
||||||
signChallengeToken: jest.Mock;
|
signChallengeToken: jest.Mock;
|
||||||
verifyChallengeToken: jest.Mock;
|
verifyChallengeToken: jest.Mock;
|
||||||
verifyCode: jest.Mock;
|
};
|
||||||
|
let accountGuard: {
|
||||||
|
isLocked: jest.Mock;
|
||||||
|
recordFailure: jest.Mock;
|
||||||
|
recordSuccess: jest.Mock;
|
||||||
|
consumeTotp: jest.Mock;
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeUser = {
|
const activeUser = {
|
||||||
@@ -60,7 +66,12 @@ describe('AuthService', () => {
|
|||||||
mfaService = {
|
mfaService = {
|
||||||
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
||||||
verifyChallengeToken: jest.fn(),
|
verifyChallengeToken: jest.fn(),
|
||||||
verifyCode: jest.fn(),
|
};
|
||||||
|
accountGuard = {
|
||||||
|
isLocked: jest.fn().mockReturnValue(false),
|
||||||
|
recordFailure: jest.fn().mockResolvedValue(undefined),
|
||||||
|
recordSuccess: jest.fn().mockResolvedValue(undefined),
|
||||||
|
consumeTotp: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
authService = new AuthService(
|
authService = new AuthService(
|
||||||
@@ -70,6 +81,7 @@ describe('AuthService', () => {
|
|||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
permissionResolver as never,
|
permissionResolver as never,
|
||||||
mfaService as unknown as MfaService,
|
mfaService as unknown as MfaService,
|
||||||
|
accountGuard as unknown as AccountGuardService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,21 +169,40 @@ describe('AuthService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => {
|
it('writes nothing attacker-chosen to the audit log for an unknown email', async () => {
|
||||||
usersService.findByEmail.mockResolvedValue(null);
|
usersService.findByEmail.mockResolvedValue(null);
|
||||||
await expect(
|
await expect(
|
||||||
authService.login('[email protected]', 'x'),
|
authService.login('[email protected]', 'x'),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
expect.objectContaining({
|
});
|
||||||
action: 'LOGIN_FAILED',
|
|
||||||
entityId: '[email protected]',
|
it('counts a wrong password against the account', async () => {
|
||||||
}),
|
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||||
);
|
await expect(
|
||||||
const [[recordArg]] = auditLog.record.mock.calls as [
|
authService.login(activeUser.email, 'wrong-password'),
|
||||||
[{ userId?: string }],
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
];
|
expect(accountGuard.recordFailure).toHaveBeenCalledWith(activeUser.id);
|
||||||
expect(recordArg).not.toHaveProperty('userId');
|
});
|
||||||
|
|
||||||
|
it('refuses a locked account even with the right password, with the same message', async () => {
|
||||||
|
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||||
|
accountGuard.isLocked.mockReturnValue(true);
|
||||||
|
|
||||||
|
const lockedMessage = await authService
|
||||||
|
.login(activeUser.email, 'correct-password')
|
||||||
|
.then(() => 'no error')
|
||||||
|
.catch((error: Error) => error.message);
|
||||||
|
|
||||||
|
accountGuard.isLocked.mockReturnValue(false);
|
||||||
|
const wrongMessage = await authService
|
||||||
|
.login(activeUser.email, 'wrong-password')
|
||||||
|
.then(() => 'no error')
|
||||||
|
.catch((error: Error) => error.message);
|
||||||
|
|
||||||
|
expect(lockedMessage).not.toBe('no error');
|
||||||
|
expect(lockedMessage).toBe(wrongMessage);
|
||||||
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +216,7 @@ describe('AuthService', () => {
|
|||||||
it('issues tokens and records LOGIN when the code is valid', async () => {
|
it('issues tokens and records LOGIN when the code is valid', async () => {
|
||||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
||||||
usersService.findById.mockResolvedValue(mfaUser);
|
usersService.findById.mockResolvedValue(mfaUser);
|
||||||
mfaService.verifyCode.mockResolvedValue(true);
|
accountGuard.consumeTotp.mockResolvedValue(true);
|
||||||
prisma.refreshToken.create.mockResolvedValue({});
|
prisma.refreshToken.create.mockResolvedValue({});
|
||||||
|
|
||||||
const result = await authService.verifyMfaAndLogin(
|
const result = await authService.verifyMfaAndLogin(
|
||||||
@@ -194,11 +225,8 @@ describe('AuthService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.accessToken).toBe('signed.jwt.token');
|
expect(result.accessToken).toBe('signed.jwt.token');
|
||||||
expect(mfaService.verifyCode).toHaveBeenCalledWith(
|
expect(accountGuard.consumeTotp).toHaveBeenCalledWith(mfaUser, '123456');
|
||||||
mfaUser.mfaSecret,
|
expect(accountGuard.recordSuccess).toHaveBeenCalledWith(mfaUser.id);
|
||||||
mfaUser.email,
|
|
||||||
'123456',
|
|
||||||
);
|
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
|
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
|
||||||
);
|
);
|
||||||
@@ -207,7 +235,7 @@ describe('AuthService', () => {
|
|||||||
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
|
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
|
||||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
||||||
usersService.findById.mockResolvedValue(mfaUser);
|
usersService.findById.mockResolvedValue(mfaUser);
|
||||||
mfaService.verifyCode.mockResolvedValue(false);
|
accountGuard.consumeTotp.mockResolvedValue(false);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
|
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { UsersService } from '../users/users.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { PermissionResolverService } from './permissions/permission-resolver.service';
|
import { PermissionResolverService } from './permissions/permission-resolver.service';
|
||||||
import { MfaService } from './mfa/mfa.service';
|
import { MfaService } from './mfa/mfa.service';
|
||||||
|
import { AccountGuardService } from './mfa/account-guard.service';
|
||||||
import { parseDurationToMs } from './util/duration';
|
import { parseDurationToMs } from './util/duration';
|
||||||
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
|
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
|
||||||
|
|
||||||
@@ -32,6 +33,19 @@ export interface CurrentUserProfile {
|
|||||||
|
|
||||||
const REFRESH_TOKEN_BYTES = 40;
|
const REFRESH_TOKEN_BYTES = 40;
|
||||||
|
|
||||||
|
// Same answer for unknown email, wrong password, deactivated or locked
|
||||||
|
// account - nothing in the response tells which one it was.
|
||||||
|
const INVALID_CREDENTIALS =
|
||||||
|
'E-Mail oder Passwort falsch. Nach mehreren Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.';
|
||||||
|
|
||||||
|
// Hash of a random value nobody knows, at the same cost as real password
|
||||||
|
// hashes; compared against when there is no real hash, so every login
|
||||||
|
// costs the same time.
|
||||||
|
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
|
||||||
|
crypto.randomBytes(32).toString('hex'),
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly logger = new Logger(AuthService.name);
|
private readonly logger = new Logger(AuthService.name);
|
||||||
@@ -43,6 +57,7 @@ export class AuthService {
|
|||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly permissionResolver: PermissionResolverService,
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
private readonly mfaService: MfaService,
|
private readonly mfaService: MfaService,
|
||||||
|
private readonly accountGuard: AccountGuardService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
|
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
|
||||||
@@ -91,35 +106,36 @@ export class AuthService {
|
|||||||
|
|
||||||
async login(email: string, password: string): Promise<LoginResult> {
|
async login(email: string, password: string): Promise<LoginResult> {
|
||||||
const user = await this.usersService.findByEmail(email);
|
const user = await this.usersService.findByEmail(email);
|
||||||
|
// bcrypt runs on every path - unknown, deactivated, locked or real - so
|
||||||
|
// the response time does not reveal which accounts exist.
|
||||||
|
const passwordMatches = await bcrypt.compare(
|
||||||
|
password,
|
||||||
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
|
);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.logger.warn(`Login failed: no user with email ${email}`);
|
this.logger.warn('Login failed: unknown email');
|
||||||
await this.auditLog.record({
|
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||||
entityType: 'AuthSession',
|
|
||||||
entityId: email,
|
|
||||||
action: 'LOGIN_FAILED',
|
|
||||||
});
|
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
|
||||||
}
|
}
|
||||||
if (!user.isActive) {
|
if (!user.isActive || this.accountGuard.isLocked(user)) {
|
||||||
this.logger.warn(`Login failed: user ${user.id} is deactivated`);
|
this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'AuthSession',
|
entityType: 'AuthSession',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
action: 'LOGIN_FAILED',
|
action: 'LOGIN_FAILED',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
|
|
||||||
if (!passwordMatches) {
|
if (!passwordMatches) {
|
||||||
this.logger.warn(`Login failed: wrong password for user ${user.id}`);
|
this.logger.warn(`Login failed: wrong password for user ${user.id}`);
|
||||||
|
await this.accountGuard.recordFailure(user.id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'AuthSession',
|
entityType: 'AuthSession',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
action: 'LOGIN_FAILED',
|
action: 'LOGIN_FAILED',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
if (user.mfaEnabled) {
|
if (user.mfaEnabled) {
|
||||||
return {
|
return {
|
||||||
@@ -133,6 +149,7 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
const accessToken = this.signAccessToken(authenticatedUser);
|
const accessToken = this.signAccessToken(authenticatedUser);
|
||||||
const refreshToken = await this.issueRefreshToken(user.id);
|
const refreshToken = await this.issueRefreshToken(user.id);
|
||||||
|
await this.accountGuard.recordSuccess(user.id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'AuthSession',
|
entityType: 'AuthSession',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
@@ -180,15 +197,15 @@ export class AuthService {
|
|||||||
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
|
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
|
||||||
throw new UnauthorizedException('MFA nicht verfügbar');
|
throw new UnauthorizedException('MFA nicht verfügbar');
|
||||||
}
|
}
|
||||||
const isValid = await this.mfaService.verifyCode(
|
if (this.accountGuard.isLocked(user)) {
|
||||||
user.mfaSecret,
|
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||||
user.email,
|
}
|
||||||
code,
|
const isValid = await this.accountGuard.consumeTotp(user, code);
|
||||||
);
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`MFA verification failed: wrong code for user ${user.id}`,
|
`MFA verification failed: wrong or reused code for user ${user.id}`,
|
||||||
);
|
);
|
||||||
|
await this.accountGuard.recordFailure(user.id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'AuthSession',
|
entityType: 'AuthSession',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
@@ -197,6 +214,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
throw new UnauthorizedException('Code ungültig');
|
throw new UnauthorizedException('Code ungültig');
|
||||||
}
|
}
|
||||||
|
await this.accountGuard.recordSuccess(user.id);
|
||||||
const authenticatedUser = {
|
const authenticatedUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../../audit/audit-log.service';
|
||||||
|
import { MfaService } from './mfa.service';
|
||||||
|
|
||||||
|
/** After this many failed attempts in a row the account is locked ... */
|
||||||
|
export const MAX_FAILED_AUTH_ATTEMPTS = 5;
|
||||||
|
/** ... for this long. */
|
||||||
|
export const AUTH_LOCK_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface GuardedUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
mfaSecret: string | null;
|
||||||
|
mfaLastTimeStep: number | null;
|
||||||
|
authLockedUntil: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-level brute-force protection, on top of the per-IP throttle:
|
||||||
|
* an attacker spreading guesses over many addresses still only gets five
|
||||||
|
* tries per account per 15 minutes - for the password, the MFA code and
|
||||||
|
* step-up confirmations alike. A TOTP code is accepted once: its time step
|
||||||
|
* is recorded, and that step or any earlier one is refused afterwards.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AccountGuardService {
|
||||||
|
private readonly logger = new Logger(AccountGuardService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly mfaService: MfaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isLocked(user: Pick<GuardedUser, 'authLockedUntil'>): boolean {
|
||||||
|
return Boolean(user.authLockedUntil && user.authLockedUntil > new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordFailure(userId: string): Promise<void> {
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { failedAuthAttempts: { increment: 1 } },
|
||||||
|
select: { failedAuthAttempts: true },
|
||||||
|
});
|
||||||
|
if (updated.failedAuthAttempts >= MAX_FAILED_AUTH_ATTEMPTS) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
authLockedUntil: new Date(Date.now() + AUTH_LOCK_MS),
|
||||||
|
failedAuthAttempts: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.warn(`Account ${userId} locked after repeated failures`);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'AuthSession',
|
||||||
|
entityId: userId,
|
||||||
|
action: 'ACCOUNT_LOCKED',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordSuccess(userId: string): Promise<void> {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { failedAuthAttempts: 0, authLockedUntil: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only for a valid code whose time step is newer than the last one
|
||||||
|
* used - and only for one of two concurrent requests with the same code.
|
||||||
|
*/
|
||||||
|
async consumeTotp(user: GuardedUser, code: string): Promise<boolean> {
|
||||||
|
if (!user.mfaSecret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const timeStep = await this.mfaService.verifyCodeStep(
|
||||||
|
user.mfaSecret,
|
||||||
|
user.email,
|
||||||
|
code,
|
||||||
|
user.mfaLastTimeStep ?? undefined,
|
||||||
|
);
|
||||||
|
if (timeStep === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const claimed = await this.prisma.user.updateMany({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
OR: [{ mfaLastTimeStep: null }, { mfaLastTimeStep: { lt: timeStep } }],
|
||||||
|
},
|
||||||
|
data: { mfaLastTimeStep: timeStep },
|
||||||
|
});
|
||||||
|
return claimed.count === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { MfaService } from './mfa.service';
|
import { MfaService } from './mfa.service';
|
||||||
|
import { AccountGuardService } from './account-guard.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({})],
|
imports: [JwtModule.register({})],
|
||||||
providers: [MfaService],
|
providers: [MfaService, AccountGuardService],
|
||||||
exports: [MfaService],
|
exports: [MfaService, AccountGuardService],
|
||||||
})
|
})
|
||||||
export class MfaModule {}
|
export class MfaModule {}
|
||||||
@@ -2,7 +2,12 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||||
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
|
import {
|
||||||
|
buildTotpUri,
|
||||||
|
generateTotpSecret,
|
||||||
|
verifyTotpCode,
|
||||||
|
verifyTotpStep,
|
||||||
|
} from './totp.util';
|
||||||
import {
|
import {
|
||||||
TOKEN_AUDIENCE,
|
TOKEN_AUDIENCE,
|
||||||
jwtSignOptions,
|
jwtSignOptions,
|
||||||
@@ -104,6 +109,21 @@ export class MfaService {
|
|||||||
return { userId: payload.sub };
|
return { userId: payload.sub };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
|
||||||
|
async verifyCodeStep(
|
||||||
|
encryptedSecret: string,
|
||||||
|
email: string,
|
||||||
|
code: string,
|
||||||
|
afterTimeStep?: number,
|
||||||
|
): Promise<number | null> {
|
||||||
|
return verifyTotpStep(
|
||||||
|
decryptMfaSecret(encryptedSecret),
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
afterTimeStep,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async verifyCode(
|
async verifyCode(
|
||||||
encryptedSecret: string,
|
encryptedSecret: string,
|
||||||
email: string,
|
email: string,
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ export function buildTotpUri(secret: string, label: string): string {
|
|||||||
return createTotp(secret, label).toURI();
|
return createTotp(secret, label).toURI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time step a code belongs to, or null if it is wrong - or not newer
|
||||||
|
* than afterTimeStep (a code that was already used, or an older one).
|
||||||
|
*/
|
||||||
|
export async function verifyTotpStep(
|
||||||
|
secret: string,
|
||||||
|
label: string,
|
||||||
|
code: string,
|
||||||
|
afterTimeStep?: number,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const result = await createTotp(secret, label).verify(code, {
|
||||||
|
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
||||||
|
afterTimeStep,
|
||||||
|
});
|
||||||
|
return result.valid ? result.timeStep : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyTotpCode(
|
export async function verifyTotpCode(
|
||||||
secret: string,
|
secret: string,
|
||||||
label: string,
|
label: string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { MfaService } from '../mfa/mfa.service';
|
import { AccountGuardService } from '../mfa/account-guard.service';
|
||||||
|
|
||||||
export interface ReauthProof {
|
export interface ReauthProof {
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -18,25 +18,33 @@ export interface ReauthProof {
|
|||||||
export class ReauthService {
|
export class ReauthService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly mfaService: MfaService,
|
private readonly accountGuard: AccountGuardService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async verify(userId: string, proof: ReauthProof): Promise<void> {
|
async verify(userId: string, proof: ReauthProof): Promise<void> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
passwordHash: true,
|
passwordHash: true,
|
||||||
mfaEnabled: true,
|
mfaEnabled: true,
|
||||||
mfaSecret: true,
|
mfaSecret: true,
|
||||||
|
mfaLastTimeStep: true,
|
||||||
|
authLockedUntil: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const passwordMatches =
|
const passwordMatches =
|
||||||
Boolean(user?.isActive) &&
|
|
||||||
typeof proof.password === 'string' &&
|
typeof proof.password === 'string' &&
|
||||||
(await bcrypt.compare(proof.password, user!.passwordHash));
|
(await bcrypt.compare(proof.password, user.passwordHash));
|
||||||
if (!user || !passwordMatches) {
|
if (!passwordMatches) {
|
||||||
|
await this.accountGuard.recordFailure(user.id);
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
||||||
);
|
);
|
||||||
@@ -44,17 +52,14 @@ export class ReauthService {
|
|||||||
if (user.mfaEnabled) {
|
if (user.mfaEnabled) {
|
||||||
const codeMatches =
|
const codeMatches =
|
||||||
typeof proof.mfaCode === 'string' &&
|
typeof proof.mfaCode === 'string' &&
|
||||||
user.mfaSecret !== null &&
|
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
||||||
(await this.mfaService.verifyCode(
|
|
||||||
user.mfaSecret,
|
|
||||||
user.email,
|
|
||||||
proof.mfaCode,
|
|
||||||
));
|
|
||||||
if (!codeMatches) {
|
if (!codeMatches) {
|
||||||
|
await this.accountGuard.recordFailure(user.id);
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await this.accountGuard.recordSuccess(user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsString } from 'class-validator';
|
import { IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
export class DisableMfaDto {
|
export class DisableMfaDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
currentPassword!: string;
|
currentPassword!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Aktueller Code aus der Authenticator-App' })
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{6}$/, { message: 'mfaCode must be a 6-digit number' })
|
||||||
|
mfaCode!: string;
|
||||||
}
|
}
|
||||||
@@ -11,4 +11,9 @@ export class MfaConfirmDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Aktuelles Passwort zur Bestätigung' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
currentPassword!: string;
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,7 @@ export class ProfileController {
|
|||||||
actor.email,
|
actor.email,
|
||||||
dto.setupToken,
|
dto.setupToken,
|
||||||
dto.code,
|
dto.code,
|
||||||
|
dto.currentPassword,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +125,10 @@ export class ProfileController {
|
|||||||
@Body() dto: DisableMfaDto,
|
@Body() dto: DisableMfaDto,
|
||||||
@CurrentUser() actor: AuthenticatedUser,
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
) {
|
) {
|
||||||
return this.profileService.disableMfa(actor.id, dto.currentPassword);
|
return this.profileService.disableMfa(
|
||||||
|
actor.id,
|
||||||
|
dto.currentPassword,
|
||||||
|
dto.mfaCode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MfaModule } from '../auth/mfa/mfa.module';
|
import { MfaModule } from '../auth/mfa/mfa.module';
|
||||||
|
import { ReauthModule } from '../auth/reauth/reauth.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { ProfileController } from './profile.controller';
|
import { ProfileController } from './profile.controller';
|
||||||
import { ProfileService } from './profile.service';
|
import { ProfileService } from './profile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MfaModule, UsersModule],
|
imports: [MfaModule, ReauthModule, UsersModule],
|
||||||
controllers: [ProfileController],
|
controllers: [ProfileController],
|
||||||
providers: [ProfileService],
|
providers: [ProfileService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ProfileService } from './profile.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { MfaService } from '../auth/mfa/mfa.service';
|
import { MfaService } from '../auth/mfa/mfa.service';
|
||||||
|
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ describe('ProfileService', () => {
|
|||||||
mfaSecret: null as string | null,
|
mfaSecret: null as string | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let reauth: { verify: jest.Mock };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
baseUser.passwordHash = await bcrypt.hash('correct-password', 4);
|
baseUser.passwordHash = await bcrypt.hash('correct-password', 4);
|
||||||
baseUser.avatarPath = null;
|
baseUser.avatarPath = null;
|
||||||
@@ -64,12 +67,14 @@ describe('ProfileService', () => {
|
|||||||
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
|
mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() };
|
||||||
permissionResolver = { invalidateUser: jest.fn() };
|
permissionResolver = { invalidateUser: jest.fn() };
|
||||||
usersService = { deactivate: jest.fn().mockResolvedValue(undefined) };
|
usersService = { deactivate: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
reauth = { verify: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new ProfileService(
|
service = new ProfileService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
mfaService as unknown as MfaService,
|
mfaService as unknown as MfaService,
|
||||||
permissionResolver as unknown as PermissionResolverService,
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
usersService as unknown as UsersService,
|
usersService as unknown as UsersService,
|
||||||
|
reauth as unknown as ReauthService,
|
||||||
);
|
);
|
||||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
@@ -322,13 +327,22 @@ describe('ProfileService', () => {
|
|||||||
baseUser.email,
|
baseUser.email,
|
||||||
'setup-token',
|
'setup-token',
|
||||||
'123456',
|
'123456',
|
||||||
|
'correct-password',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(reauth.verify).toHaveBeenCalledWith('user-1', {
|
||||||
|
password: 'correct-password',
|
||||||
|
});
|
||||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: { mfaEnabled: true, mfaSecret: 'encrypted' },
|
data: {
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: 'encrypted',
|
||||||
|
mfaLastTimeStep: null,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ action: 'MFA_ENABLED' }),
|
expect.objectContaining({ action: 'MFA_ENABLED' }),
|
||||||
);
|
);
|
||||||
@@ -336,11 +350,15 @@ describe('ProfileService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('disableMfa', () => {
|
describe('disableMfa', () => {
|
||||||
it('throws UnauthorizedException when the current password is wrong', async () => {
|
it('needs both factors and changes nothing when the proof fails', async () => {
|
||||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
reauth.verify.mockRejectedValue(new UnauthorizedException());
|
||||||
await expect(
|
await expect(
|
||||||
service.disableMfa('user-1', 'wrong-password'),
|
service.disableMfa('user-1', 'wrong-password', '000000'),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(reauth.verify).toHaveBeenCalledWith('user-1', {
|
||||||
|
password: 'wrong-password',
|
||||||
|
mfaCode: '000000',
|
||||||
|
});
|
||||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,13 +366,14 @@ describe('ProfileService', () => {
|
|||||||
prisma.user.findUnique.mockResolvedValue(baseUser);
|
prisma.user.findUnique.mockResolvedValue(baseUser);
|
||||||
prisma.user.update.mockResolvedValue({});
|
prisma.user.update.mockResolvedValue({});
|
||||||
|
|
||||||
await service.disableMfa('user-1', 'correct-password');
|
await service.disableMfa('user-1', 'correct-password', '123456');
|
||||||
|
|
||||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: { mfaEnabled: false, mfaSecret: null },
|
data: { mfaEnabled: false, mfaSecret: null, mfaLastTimeStep: null },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ action: 'MFA_DISABLED' }),
|
expect.objectContaining({ action: 'MFA_DISABLED' }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { matchesFileSignature } from '../common/file-signature';
|
import { matchesFileSignature } from '../common/file-signature';
|
||||||
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
||||||
|
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import {
|
import {
|
||||||
@@ -45,8 +46,17 @@ export class ProfileService {
|
|||||||
private readonly mfaService: MfaService,
|
private readonly mfaService: MfaService,
|
||||||
private readonly permissionResolver: PermissionResolverService,
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
|
private readonly reauth: ReauthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** MFA changed: every refresh token ends, the current access token runs out. */
|
||||||
|
private async revokeSessions(userId: string): Promise<void> {
|
||||||
|
await this.prisma.refreshToken.updateMany({
|
||||||
|
where: { userId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async requireUser(userId: string) {
|
private async requireUser(userId: string) {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -230,7 +240,11 @@ export class ProfileService {
|
|||||||
email: string,
|
email: string,
|
||||||
setupToken: string,
|
setupToken: string,
|
||||||
code: string,
|
code: string,
|
||||||
|
currentPassword: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// A stolen access token alone must not be able to put the attacker's
|
||||||
|
// own authenticator on the account.
|
||||||
|
await this.reauth.verify(userId, { password: currentPassword });
|
||||||
const { encryptedSecret } = await this.mfaService.confirmEnrollment(
|
const { encryptedSecret } = await this.mfaService.confirmEnrollment(
|
||||||
userId,
|
userId,
|
||||||
setupToken,
|
setupToken,
|
||||||
@@ -239,8 +253,13 @@ export class ProfileService {
|
|||||||
);
|
);
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { mfaEnabled: true, mfaSecret: encryptedSecret },
|
data: {
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: encryptedSecret,
|
||||||
|
mfaLastTimeStep: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
await this.revokeSessions(userId);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: userId,
|
entityId: userId,
|
||||||
@@ -249,19 +268,21 @@ export class ProfileService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async disableMfa(userId: string, currentPassword: string): Promise<void> {
|
/**
|
||||||
const user = await this.requireUser(userId);
|
* Turning MFA off needs both factors: with the password alone, MFA would
|
||||||
const currentMatches = await bcrypt.compare(
|
* protect nothing against someone who already knows it.
|
||||||
currentPassword,
|
*/
|
||||||
user.passwordHash,
|
async disableMfa(
|
||||||
);
|
userId: string,
|
||||||
if (!currentMatches) {
|
currentPassword: string,
|
||||||
throw new UnauthorizedException('Aktuelles Passwort ist falsch');
|
mfaCode: string,
|
||||||
}
|
): Promise<void> {
|
||||||
|
await this.reauth.verify(userId, { password: currentPassword, mfaCode });
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { mfaEnabled: false, mfaSecret: null },
|
data: { mfaEnabled: false, mfaSecret: null, mfaLastTimeStep: null },
|
||||||
});
|
});
|
||||||
|
await this.revokeSessions(userId);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
entityId: userId,
|
entityId: userId,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util';
|
||||||
|
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-level brute-force protection and one-time TOTP codes, against a
|
||||||
|
* real database. Each test uses its own account, because the per-IP
|
||||||
|
* throttle (10/min on login) is shared by the whole suite.
|
||||||
|
*/
|
||||||
|
describe('Account guard (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
|
||||||
|
const login = (email: string, pw: string) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.send({ email, password: pw });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks an account after five wrong passwords, even for the right one', async () => {
|
||||||
|
const email = `lockout-${suffix}@example.com`;
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 4),
|
||||||
|
name: 'Lockout',
|
||||||
|
role: { connect: { key: 'READONLY' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await login(email, 'wrong-password-123').expect(401);
|
||||||
|
}
|
||||||
|
const locked = await login(email, password).expect(401);
|
||||||
|
const wrong = await login(`nobody-${suffix}@example.com`, password).expect(
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
// Same message for "locked" and "no such account".
|
||||||
|
expect((locked.body as { message: string }).message).toBe(
|
||||||
|
(wrong.body as { message: string }).message,
|
||||||
|
);
|
||||||
|
|
||||||
|
const stored = await prisma.user.findUniqueOrThrow({ where: { email } });
|
||||||
|
expect(stored.authLockedUntil?.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a TOTP code only once', async () => {
|
||||||
|
const email = `totp-${suffix}@example.com`;
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 4),
|
||||||
|
name: 'TOTP',
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: encryptMfaSecret(secret),
|
||||||
|
role: { connect: { key: 'READONLY' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const code = await new TOTP({
|
||||||
|
secret,
|
||||||
|
crypto: new NobleCryptoPlugin(),
|
||||||
|
base32: new ScureBase32Plugin(),
|
||||||
|
}).generate();
|
||||||
|
|
||||||
|
const first = await login(email, password).expect(200);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/mfa/verify')
|
||||||
|
.send({ mfaToken: (first.body as { mfaToken: string }).mfaToken, code })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const second = await login(email, password).expect(200);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/mfa/verify')
|
||||||
|
.send({ mfaToken: (second.body as { mfaToken: string }).mfaToken, code })
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,13 +46,18 @@ describe("MfaSection", () => {
|
|||||||
|
|
||||||
const codeInput = await screen.findByLabelText("Code");
|
const codeInput = await screen.findByLabelText("Code");
|
||||||
await userEvent.type(codeInput, "123456");
|
await userEvent.type(codeInput, "123456");
|
||||||
|
await userEvent.type(screen.getByLabelText("Passwort"), "correct-password");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
||||||
|
|
||||||
expect(confirmMfaEnrollment).toHaveBeenCalledWith("setup-token", "123456");
|
expect(confirmMfaEnrollment).toHaveBeenCalledWith(
|
||||||
|
"setup-token",
|
||||||
|
"123456",
|
||||||
|
"correct-password",
|
||||||
|
);
|
||||||
await vi.waitFor(() => expect(onChanged).toHaveBeenCalled());
|
await vi.waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables MFA after confirming the current password", async () => {
|
it("disables MFA after confirming the current password and a code", async () => {
|
||||||
vi.mocked(disableMfa).mockResolvedValueOnce(undefined);
|
vi.mocked(disableMfa).mockResolvedValueOnce(undefined);
|
||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
renderWithClient(<MfaSection mfaEnabled onChanged={onChanged} />);
|
renderWithClient(<MfaSection mfaEnabled onChanged={onChanged} />);
|
||||||
@@ -60,9 +65,10 @@ describe("MfaSection", () => {
|
|||||||
await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" }));
|
await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" }));
|
||||||
const passwordInput = await screen.findByLabelText("Passwort");
|
const passwordInput = await screen.findByLabelText("Passwort");
|
||||||
await userEvent.type(passwordInput, "correct-password");
|
await userEvent.type(passwordInput, "correct-password");
|
||||||
|
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "654321");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" }));
|
await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" }));
|
||||||
|
|
||||||
expect(disableMfa).toHaveBeenCalledWith("correct-password");
|
expect(disableMfa).toHaveBeenCalledWith("correct-password", "654321");
|
||||||
await vi.waitFor(() => expect(onChanged).toHaveBeenCalled());
|
await vi.waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -26,10 +26,17 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
function messageOf(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digitsOnly = (value: string) => value.replace(/\D/g, "").slice(0, 6);
|
||||||
|
|
||||||
function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [enrollment, setEnrollment] = useState<MfaEnrollment | null>(null);
|
const [enrollment, setEnrollment] = useState<MfaEnrollment | null>(null);
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const startMutation = useMutation({
|
const startMutation = useMutation({
|
||||||
@@ -43,20 +50,21 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
|||||||
if (!enrollment) {
|
if (!enrollment) {
|
||||||
throw new Error("no enrollment");
|
throw new Error("no enrollment");
|
||||||
}
|
}
|
||||||
return confirmMfaEnrollment(enrollment.setupToken, code);
|
return confirmMfaEnrollment(enrollment.setupToken, code, currentPassword);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("MFA aktiviert");
|
toast.success("MFA aktiviert. Andere Geräte müssen sich neu anmelden.");
|
||||||
onEnabled();
|
onEnabled();
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
onError: () => setError("Code ungültig."),
|
onError: (err) => setError(messageOf(err, "Code oder Passwort ungültig.")),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleTrigger() {
|
function handleTrigger() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setEnrollment(null);
|
setEnrollment(null);
|
||||||
setCode("");
|
setCode("");
|
||||||
|
setCurrentPassword("");
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
startMutation.mutate();
|
startMutation.mutate();
|
||||||
}
|
}
|
||||||
@@ -65,6 +73,7 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
setEnrollment(null);
|
setEnrollment(null);
|
||||||
setCode("");
|
setCode("");
|
||||||
|
setCurrentPassword("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +92,8 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>MFA einrichten</DialogTitle>
|
<DialogTitle>MFA einrichten</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
QR-Code mit deiner Authenticator-App scannen, dann den angezeigten Code eingeben.
|
1. QR-Code mit deiner Authenticator-App scannen. 2. Den angezeigten Code und dein
|
||||||
|
Passwort eingeben.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{enrollment ? (
|
{enrollment ? (
|
||||||
@@ -104,7 +114,18 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
required
|
required
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(event) => setCode(event.target.value)}
|
onChange={(event) => setCode(digitsOnly(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="mfa-enroll-password">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="mfa-enroll-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -134,21 +155,23 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
|
|||||||
function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
|
function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [mfaCode, setMfaCode] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const disableMutation = useMutation({
|
const disableMutation = useMutation({
|
||||||
mutationFn: () => disableMfa(currentPassword),
|
mutationFn: () => disableMfa(currentPassword, mfaCode),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("MFA deaktiviert");
|
toast.success("MFA deaktiviert. Andere Geräte müssen sich neu anmelden.");
|
||||||
onDisabled();
|
onDisabled();
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
onError: () => setError("Passwort ist falsch."),
|
onError: (err) => setError(messageOf(err, "Passwort oder Code ist falsch.")),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setCurrentPassword("");
|
setCurrentPassword("");
|
||||||
|
setMfaCode("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +189,9 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>MFA deaktivieren</DialogTitle>
|
<DialogTitle>MFA deaktivieren</DialogTitle>
|
||||||
<DialogDescription>Zur Bestätigung aktuelles Passwort eingeben.</DialogDescription>
|
<DialogDescription>
|
||||||
|
Zur Bestätigung aktuelles Passwort und den Code aus der Authenticator-App eingeben.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -180,6 +205,19 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
|
|||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="disable-mfa-code">Code aus der Authenticator-App</Label>
|
||||||
|
<Input
|
||||||
|
id="disable-mfa-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={(event) => setMfaCode(digitsOnly(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p role="alert" className="text-sm text-destructive">
|
<p role="alert" className="text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe("LoginPage", () => {
|
|||||||
await userEvent.type(screen.getByLabelText("Passwort"), "wrong-password");
|
await userEvent.type(screen.getByLabelText("Passwort"), "wrong-password");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
|
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
|
||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(/fehlgeschlagen/i);
|
expect(await screen.findByRole("alert")).toHaveTextContent(/E-Mail oder Passwort falsch/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the code step and verifies MFA when required", async () => {
|
it("shows the code step and verifies MFA when required", async () => {
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ function LoginPageContent() {
|
|||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen.");
|
setError(
|
||||||
|
"E-Mail oder Passwort falsch. Nach 5 Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,9 @@ function LoginPageContent() {
|
|||||||
await verifyMfa(mfaToken, code);
|
await verifyMfa(mfaToken, code);
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Code ungültig. Bitte erneut versuchen.");
|
setError(
|
||||||
|
"Code ungültig oder schon benutzt. Bitte den nächsten Code aus der App eingeben.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const AUDIT_ACTION_LABELS: Record<string, string> = {
|
|||||||
TOKEN_REFRESHED: "Token erneuert",
|
TOKEN_REFRESHED: "Token erneuert",
|
||||||
MFA_ENABLED: "MFA aktiviert",
|
MFA_ENABLED: "MFA aktiviert",
|
||||||
MFA_DISABLED: "MFA deaktiviert",
|
MFA_DISABLED: "MFA deaktiviert",
|
||||||
|
ACCOUNT_LOCKED: "Konto vorübergehend gesperrt",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function auditActionLabel(action: string): string {
|
export function auditActionLabel(action: string): string {
|
||||||
|
|||||||
@@ -65,16 +65,20 @@ export function enrollMfa(): Promise<MfaEnrollment> {
|
|||||||
return apiFetch<MfaEnrollment>("/profile/mfa/enroll", { method: "POST" });
|
return apiFetch<MfaEnrollment>("/profile/mfa/enroll", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmMfaEnrollment(setupToken: string, code: string): Promise<void> {
|
export function confirmMfaEnrollment(
|
||||||
|
setupToken: string,
|
||||||
|
code: string,
|
||||||
|
currentPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
return apiFetch<void>("/profile/mfa/enroll/confirm", {
|
return apiFetch<void>("/profile/mfa/enroll/confirm", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ setupToken, code }),
|
body: JSON.stringify({ setupToken, code, currentPassword }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disableMfa(currentPassword: string): Promise<void> {
|
export function disableMfa(currentPassword: string, mfaCode: string): Promise<void> {
|
||||||
return apiFetch<void>("/profile/mfa/disable", {
|
return apiFetch<void>("/profile/mfa/disable", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ currentPassword }),
|
body: JSON.stringify({ currentPassword, mfaCode }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user