feat(auth): add login_attempts schema, lockout config, dummy-hash helper
All checks were successful
Deploy to Homelab / deploy (push) Successful in 29s

Lays the foundation for brute-force defense: per-username attempt tracking
table, configurable lockout/rate-limit thresholds, and a memoized dummy
bcrypt hash so unknown-user paths can be timed identically to wrong-password
paths in a later step.

Adds @fastify/rate-limit dependency for upcoming per-IP rate-limit on
login routes.
This commit is contained in:
2026-05-03 03:26:26 -07:00
parent d30f40ca71
commit f4eaf88495
6 changed files with 102 additions and 0 deletions

View File

@@ -23,6 +23,12 @@ describe('config', () => {
delete process.env.BASE_URL;
delete process.env.COOKIE_SECURE;
delete process.env.TRUST_PROXY;
delete process.env.LOCKOUT_THRESHOLD;
delete process.env.LOCKOUT_BASE_SECONDS;
delete process.env.LOCKOUT_MAX_SECONDS;
delete process.env.LOGIN_MIN_RESPONSE_MS;
delete process.env.LOGIN_RATE_LIMIT_MAX;
delete process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS;
const { loadConfig } = await import('../../src/config.ts');
const config = loadConfig();
@@ -37,6 +43,12 @@ describe('config', () => {
expect(config.baseUrl).toBe('http://localhost:3000');
expect(config.cookieSecure).toBe(false);
expect(config.trustProxy).toBe(false);
expect(config.lockoutThreshold).toBe(5);
expect(config.lockoutBaseSeconds).toBe(30);
expect(config.lockoutMaxSeconds).toBe(3600);
expect(config.loginMinResponseMs).toBe(350);
expect(config.loginRateLimitMax).toBe(10);
expect(config.loginRateLimitWindowSeconds).toBe(60);
});
it('reads values from env vars', async () => {
@@ -60,6 +72,26 @@ describe('config', () => {
expect(config.maxFileSize).toBe(52428800);
});
it('reads lockout and rate-limit values from env vars', async () => {
process.env.JWT_SECRET = 'my-secret';
process.env.LOCKOUT_THRESHOLD = '3';
process.env.LOCKOUT_BASE_SECONDS = '15';
process.env.LOCKOUT_MAX_SECONDS = '900';
process.env.LOGIN_MIN_RESPONSE_MS = '50';
process.env.LOGIN_RATE_LIMIT_MAX = '20';
process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS = '120';
const { loadConfig } = await import('../../src/config.ts');
const config = loadConfig();
expect(config.lockoutThreshold).toBe(3);
expect(config.lockoutBaseSeconds).toBe(15);
expect(config.lockoutMaxSeconds).toBe(900);
expect(config.loginMinResponseMs).toBe(50);
expect(config.loginRateLimitMax).toBe(20);
expect(config.loginRateLimitWindowSeconds).toBe(120);
});
it('throws when JWT_SECRET is missing', async () => {
delete process.env.JWT_SECRET;
const { loadConfig } = await import('../../src/config.ts');