feat(auth): add login_attempts schema, lockout config, dummy-hash helper
All checks were successful
Deploy to Homelab / deploy (push) Successful in 29s
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:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@fastify/formbody": "^8.0.2",
|
"@fastify/formbody": "^8.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
@@ -705,6 +706,27 @@
|
|||||||
"ipaddr.js": "^2.1.0"
|
"ipaddr.js": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/rate-limit": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.2",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"toad-cache": "^3.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/send": {
|
"node_modules/@fastify/send": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@fastify/formbody": "^8.0.2",
|
"@fastify/formbody": "^8.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export interface Config {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
cookieSecure: boolean;
|
cookieSecure: boolean;
|
||||||
trustProxy: boolean;
|
trustProxy: boolean;
|
||||||
|
lockoutThreshold: number;
|
||||||
|
lockoutBaseSeconds: number;
|
||||||
|
lockoutMaxSeconds: number;
|
||||||
|
loginMinResponseMs: number;
|
||||||
|
loginRateLimitMax: number;
|
||||||
|
loginRateLimitWindowSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
@@ -30,5 +36,11 @@ export function loadConfig(): Config {
|
|||||||
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
|
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
|
||||||
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||||
trustProxy: process.env.TRUST_PROXY === 'true',
|
trustProxy: process.env.TRUST_PROXY === 'true',
|
||||||
|
lockoutThreshold: parseInt(process.env.LOCKOUT_THRESHOLD ?? '5', 10),
|
||||||
|
lockoutBaseSeconds: parseInt(process.env.LOCKOUT_BASE_SECONDS ?? '30', 10),
|
||||||
|
lockoutMaxSeconds: parseInt(process.env.LOCKOUT_MAX_SECONDS ?? '3600', 10),
|
||||||
|
loginMinResponseMs: parseInt(process.env.LOGIN_MIN_RESPONSE_MS ?? '350', 10),
|
||||||
|
loginRateLimitMax: parseInt(process.env.LOGIN_RATE_LIMIT_MAX ?? '10', 10),
|
||||||
|
loginRateLimitWindowSeconds: parseInt(process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS ?? '60', 10),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export function initDb(dbPath: string): Database.Database {
|
|||||||
stored_name TEXT NOT NULL,
|
stored_name TEXT NOT NULL,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_failed_at TEXT,
|
||||||
|
locked_until TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked_until
|
||||||
|
ON login_attempts(locked_until);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
25
src/services/dummy-hash.ts
Normal file
25
src/services/dummy-hash.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { hashPassword } from './auth.ts';
|
||||||
|
|
||||||
|
let cachedHash: Promise<string> | null = null;
|
||||||
|
|
||||||
|
function getDummyHash(): Promise<string> {
|
||||||
|
if (!cachedHash) {
|
||||||
|
// Hash a value no caller will ever submit (cryptographically random
|
||||||
|
// string generated once at module init). Cost factor matches real users
|
||||||
|
// because hashPassword uses the same SALT_ROUNDS.
|
||||||
|
const seed = `dummy:${Date.now()}:${Math.random()}:${process.pid}`;
|
||||||
|
cachedHash = hashPassword(seed);
|
||||||
|
}
|
||||||
|
return cachedHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAgainstDummy(password: string): Promise<boolean> {
|
||||||
|
const hash = await getDummyHash();
|
||||||
|
await bcrypt.compare(password, hash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resetDummyHashForTests(): void {
|
||||||
|
cachedHash = null;
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ describe('config', () => {
|
|||||||
delete process.env.BASE_URL;
|
delete process.env.BASE_URL;
|
||||||
delete process.env.COOKIE_SECURE;
|
delete process.env.COOKIE_SECURE;
|
||||||
delete process.env.TRUST_PROXY;
|
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 { loadConfig } = await import('../../src/config.ts');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -37,6 +43,12 @@ describe('config', () => {
|
|||||||
expect(config.baseUrl).toBe('http://localhost:3000');
|
expect(config.baseUrl).toBe('http://localhost:3000');
|
||||||
expect(config.cookieSecure).toBe(false);
|
expect(config.cookieSecure).toBe(false);
|
||||||
expect(config.trustProxy).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 () => {
|
it('reads values from env vars', async () => {
|
||||||
@@ -60,6 +72,26 @@ describe('config', () => {
|
|||||||
expect(config.maxFileSize).toBe(52428800);
|
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 () => {
|
it('throws when JWT_SECRET is missing', async () => {
|
||||||
delete process.env.JWT_SECRET;
|
delete process.env.JWT_SECRET;
|
||||||
const { loadConfig } = await import('../../src/config.ts');
|
const { loadConfig } = await import('../../src/config.ts');
|
||||||
|
|||||||
Reference in New Issue
Block a user