Files
ApiCore/ApiCore-Backend/test/account-guard.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 45efb08369 fix(auth): one-time SSO and MFA tokens, permission changes effective on every replica
- Every JWT carries a jti. The SSO completion token and the MFA challenge
  (once a code was accepted) are recorded as used in ConsumedToken; a
  replayed request or copied cookie cannot open a second session. Used
  ids are cleaned up after they expire.
- The token-validity check (active user, active role, password change)
  reads fresh on every request and refreshes the permission cache, so a
  deactivation or role change made on another replica applies to the very
  next request instead of up to 30 s later.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 07:24:28 +02:00

106 lines
3.6 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';
import { OneTimeTokenService } from '../src/auth/one-time-token.service';
/**
* 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);
});
it('lets a one-time token be used exactly once', async () => {
const oneTimeTokens = app.get(OneTimeTokenService);
const claims = {
jti: `jti-${suffix}`,
exp: Math.floor(Date.now() / 1000) + 60,
};
await expect(oneTimeTokens.consume(claims)).resolves.toBeUndefined();
await expect(oneTimeTokens.consume(claims)).rejects.toThrow();
});
});