feat: add admin authenticator 2FA and configurable login email alerts
This commit is contained in:
1 parent
bee3b990f9
commit
52cc865140
21 files changed
+1961
-20
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
authenticator,
|
||||
createRecoveryCodes,
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
recoveryIndex,
|
||||
totpCounter,
|
||||
} from '../../src/lib/auth-crypto';
|
||||
import { loginContext } from '../../src/lib/login-context';
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
describe('Admin two-factor primitives', () => {
|
||||
const secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
|
||||
it('matches RFC 6238 SHA1 vectors truncated to six digits', () => {
|
||||
expect(authenticator(secret).generate({ timestamp: 59000 })).toBe('287082');
|
||||
expect(authenticator(secret).generate({ timestamp: 1111111109000 })).toBe('081804');
|
||||
});
|
||||
it('accepts only the adjacent time window and prevents replay', () => {
|
||||
const time = 1700000000000,
|
||||
counter = Math.floor(time / 30000);
|
||||
const code = authenticator(secret).generate({ timestamp: time });
|
||||
expect(totpCounter(secret, code, -1, time)).toBe(counter);
|
||||
expect(totpCounter(secret, code, counter, time)).toBeNull();
|
||||
expect(
|
||||
totpCounter(secret, authenticator(secret).generate({ timestamp: time - 90000 }), -1, time),
|
||||
).toBeNull();
|
||||
expect(totpCounter(secret, 'abcdef', -1, time)).toBeNull();
|
||||
});
|
||||
it('encrypts secrets with authenticated purpose binding and randomized IVs', () => {
|
||||
vi.stubEnv('AUTH_ENCRYPTION_KEY', 'ab'.repeat(32));
|
||||
const encrypted = encryptSecret(secret, 'totp:admin');
|
||||
expect(encrypted).not.toContain(secret);
|
||||
expect(encryptSecret(secret, 'totp:admin')).not.toBe(encrypted);
|
||||
expect(decryptSecret(encrypted, 'totp:admin')).toBe(secret);
|
||||
expect(() => decryptSecret(encrypted, 'smtp')).toThrow();
|
||||
const parts = encrypted.split('.');
|
||||
const data = Buffer.from(parts[2], 'base64url');
|
||||
data[0] ^= 1;
|
||||
parts[2] = data.toString('base64url');
|
||||
expect(() => decryptSecret(parts.join('.'), 'totp:admin')).toThrow();
|
||||
});
|
||||
it('requires a persistent 256-bit encryption key', () => {
|
||||
vi.stubEnv('AUTH_ENCRYPTION_KEY', '');
|
||||
expect(() => encryptSecret(secret, 'test')).toThrow(/AUTH_ENCRYPTION_KEY/);
|
||||
});
|
||||
it('generates independent recovery codes and checks their hashes', () => {
|
||||
const { codes, hashes } = createRecoveryCodes();
|
||||
expect(new Set(codes).size).toBe(10);
|
||||
expect(recoveryIndex(hashes, codes[2].toLowerCase())).toBe(2);
|
||||
expect(
|
||||
recoveryIndex(
|
||||
hashes.filter((_, n) => n !== 2),
|
||||
codes[2],
|
||||
),
|
||||
).toBe(-1);
|
||||
expect(recoveryIndex(hashes, '000000')).toBe(-1);
|
||||
expect(hashes).not.toContain(codes[0]);
|
||||
});
|
||||
});
|
||||
describe('Reported login origin', () => {
|
||||
it('does not trust the client-controlled first forwarded address', () => {
|
||||
vi.stubEnv('TRUST_PROXY', 'false');
|
||||
const context = loginContext(
|
||||
new Request('https://hamham.test', {
|
||||
headers: { 'x-forwarded-for': '1.2.3.4, 2001:db8::1', 'user-agent': 'Test browser' },
|
||||
}),
|
||||
);
|
||||
expect(context.ip).toBe('2001:db8::1');
|
||||
expect(context.ipSource).toContain('nicht verifiziert');
|
||||
expect(
|
||||
loginContext(
|
||||
new Request('https://hamham.test', { headers: { 'x-forwarded-for': 'not-an-ip' } }),
|
||||
).ip,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user