import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { randomBytes } from 'node:crypto'; import type { Request, Response, NextFunction } from 'express'; const SALT_ROUNDS = 10; const TOKEN_EXPIRY = '24h'; const MAX_ATTEMPTS = 5; const RATE_WINDOW_MS = 60000; interface AttemptRecord { count: number; firstAttempt: number; } export class AuthManager { private passwordHash: string | null = null; private jwtSecret: string; private attempts = new Map(); private authDisabled = false; constructor() { this.jwtSecret = randomBytes(32).toString('hex'); } disableAuth(): void { this.authDisabled = true; } isAuthDisabled(): boolean { return this.authDisabled; } async setPassword(password: string): Promise { this.passwordHash = await bcrypt.hash(password, SALT_ROUNDS); } async verifyPassword(password: string): Promise { if (!this.passwordHash) return false; return bcrypt.compare(password, this.passwordHash); } generateToken(): string { return jwt.sign({ ts: Date.now() }, this.jwtSecret, { expiresIn: TOKEN_EXPIRY }); } verifyToken(token: string): boolean { if (this.authDisabled) return true; try { jwt.verify(token, this.jwtSecret); return true; } catch { return false; } } isRateLimited(ip: string): boolean { const record = this.attempts.get(ip); if (!record) return false; // Reset if window expired if (Date.now() - record.firstAttempt > RATE_WINDOW_MS) { this.attempts.delete(ip); return false; } return record.count >= MAX_ATTEMPTS; } recordAttempt(ip: string): void { const record = this.attempts.get(ip); if (!record || Date.now() - record.firstAttempt > RATE_WINDOW_MS) { this.attempts.set(ip, { count: 1, firstAttempt: Date.now() }); } else { record.count++; } } resetAttempts(ip: string): void { this.attempts.delete(ip); } loginHandler() { return async (req: Request, res: Response): Promise => { const ip = req.ip || req.socket.remoteAddress || 'unknown'; if (this.isRateLimited(ip)) { res.status(429).json({ error: 'Too many attempts. Try again later.' }); return; } const { password } = req.body as { password?: string }; if (!password) { res.status(400).json({ error: 'Password required' }); return; } this.recordAttempt(ip); const valid = await this.verifyPassword(password); if (!valid) { console.log(`[auth] Failed login from ${ip}`); res.status(401).json({ error: 'Invalid password' }); return; } this.resetAttempts(ip); const token = this.generateToken(); console.log(`[auth] Successful login from ${ip}`); res.json({ token }); }; } cspMiddleware() { return (_req: Request, res: Response, next: NextFunction): void => { res.setHeader( 'Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data:;" ); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Referrer-Policy', 'no-referrer'); next(); }; } } export function generatePassphrase(): string { const words = [ 'alpha', 'bravo', 'cedar', 'delta', 'ember', 'frost', 'grape', 'hatch', 'ivory', 'junco', 'knack', 'lunar', 'maple', 'north', 'orbit', 'pearl', 'quilt', 'ridge', 'solar', 'thorn', 'ultra', 'vivid', 'watch', 'xenon', ]; const picked: string[] = []; const bytes = randomBytes(4); for (let i = 0; i < 4; i++) { picked.push(words[bytes[i] % words.length]); } return picked.join('-'); }