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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:27:47 +02:00
1 parent 79d20ee64e
commit 5c666fe49a
22 files changed
+494 -91

No files matched your search

+36 -18
View File
@@ -7,6 +7,7 @@ import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service';
import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { parseDurationToMs } from './util/duration';
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
@@ -32,6 +33,19 @@ export interface CurrentUserProfile {
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()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
@@ -43,6 +57,7 @@ export class AuthService {
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
private readonly mfaService: MfaService,
private readonly accountGuard: AccountGuardService,
) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
@@ -91,35 +106,36 @@ export class AuthService {
async login(email: string, password: string): Promise<LoginResult> {
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) {
this.logger.warn(`Login failed: no user with email ${email}`);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: email,
action: 'LOGIN_FAILED',
});
throw new UnauthorizedException('Invalid credentials');
this.logger.warn('Login failed: unknown email');
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (!user.isActive) {
this.logger.warn(`Login failed: user ${user.id} is deactivated`);
if (!user.isActive || this.accountGuard.isLocked(user)) {
this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException('Invalid credentials');
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) {
this.logger.warn(`Login failed: wrong password for user ${user.id}`);
await this.accountGuard.recordFailure(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
action: 'LOGIN_FAILED',
userId: user.id,
});
throw new UnauthorizedException('Invalid credentials');
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (user.mfaEnabled) {
return {
@@ -133,6 +149,7 @@ export class AuthService {
};
const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id);
await this.accountGuard.recordSuccess(user.id);
await this.auditLog.record({
entityType: 'AuthSession',
entityId: user.id,
@@ -180,15 +197,15 @@ export class AuthService {
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 (this.accountGuard.isLocked(user)) {
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
const isValid = await this.accountGuard.consumeTotp(user, code);
if (!isValid) {
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({
entityType: 'AuthSession',
entityId: user.id,
@@ -197,6 +214,7 @@ export class AuthService {
});
throw new UnauthorizedException('Code ungültig');
}
await this.accountGuard.recordSuccess(user.id);
const authenticatedUser = {
id: user.id,
email: user.email,