- 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]>
95 lines
3.1 KiB
TypeScript
95 lines
3.1 KiB
TypeScript
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);
|
|
});
|
|
});
|