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