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
@@ -13,6 +13,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service';
|
||||
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import {
|
||||
@@ -45,8 +46,17 @@ export class ProfileService {
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
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) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
@@ -230,7 +240,11 @@ export class ProfileService {
|
||||
email: string,
|
||||
setupToken: string,
|
||||
code: string,
|
||||
currentPassword: string,
|
||||
): 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(
|
||||
userId,
|
||||
setupToken,
|
||||
@@ -239,8 +253,13 @@ export class ProfileService {
|
||||
);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaEnabled: true, mfaSecret: encryptedSecret },
|
||||
data: {
|
||||
mfaEnabled: true,
|
||||
mfaSecret: encryptedSecret,
|
||||
mfaLastTimeStep: null,
|
||||
},
|
||||
});
|
||||
await this.revokeSessions(userId);
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
@@ -249,19 +268,21 @@ export class ProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
/**
|
||||
* Turning MFA off needs both factors: with the password alone, MFA would
|
||||
* protect nothing against someone who already knows it.
|
||||
*/
|
||||
async disableMfa(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
mfaCode: string,
|
||||
): Promise<void> {
|
||||
await this.reauth.verify(userId, { password: currentPassword, mfaCode });
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaEnabled: false, mfaSecret: null },
|
||||
data: { mfaEnabled: false, mfaSecret: null, mfaLastTimeStep: null },
|
||||
});
|
||||
await this.revokeSessions(userId);
|
||||
await this.auditLog.record({
|
||||
entityType: 'User',
|
||||
entityId: userId,
|
||||
|
||||
Reference in new issue
Block a user