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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user