import { COOKIE_NAME, verifyPassword, createSession, destroySession, setPassword, } from '../auth.js'; import { config } from '../config.js'; const cookieOpts = { path: '/', httpOnly: true, sameSite: 'lax', signed: true, secure: process.env.NODE_ENV === 'production', maxAge: config.sessionDays * 86400, }; const MIN_PASSWORD = 8; const MAX_PASSWORD = 72; // bcrypt silently truncates beyond 72 bytes // In-memory brute-force throttle for the single shared password. Keyed by client // IP; behind the StartOS reverse proxy that collapses to one global lockout, // which is acceptable for a single-user app. State is per process — fine here. const MAX_FAILS = 8; const LOCKOUT_MS = 15 * 60_000; const loginAttempts = new Map(); // ip -> { fails, lockUntil } function lockRemainingMs(ip, now) { const rec = loginAttempts.get(ip); if (!rec) return 0; if (rec.lockUntil > now) return rec.lockUntil - now; // Lockout elapsed — evict so the map can't grow unbounded. Only entries that // actually locked (lockUntil set) are evictable; one still accumulating fails // has lockUntil 0 and must be kept or the counter would reset every attempt. if (rec.lockUntil) loginAttempts.delete(ip); return 0; } function noteLoginFail(ip, now) { const rec = loginAttempts.get(ip) || { fails: 0, lockUntil: 0 }; rec.fails += 1; // On hitting the limit, lock and reset the counter so the next cycle starts fresh. if (rec.fails >= MAX_FAILS) { rec.lockUntil = now + LOCKOUT_MS; rec.fails = 0; } loginAttempts.set(ip, rec); } export default async function authRoutes(app) { app.post('/api/login', async (req, reply) => { const now = Date.now(); const wait = lockRemainingMs(req.ip, now); if (wait > 0) { reply.header('Retry-After', Math.ceil(wait / 1000)); return reply.code(429).send({ error: 'Too many attempts. Try again later.' }); } const { password } = req.body || {}; if (!verifyPassword(password)) { noteLoginFail(req.ip, now); return reply.code(401).send({ error: 'Wrong password' }); } loginAttempts.delete(req.ip); const token = createSession(); reply.setCookie(COOKIE_NAME, token, cookieOpts); return { ok: true }; }); app.post('/api/logout', async (req, reply) => { const raw = req.cookies[COOKIE_NAME]; const unsigned = raw ? reply.unsignCookie(raw) : null; if (unsigned && unsigned.valid) destroySession(unsigned.value); reply.clearCookie(COOKIE_NAME, { path: '/' }); return { ok: true }; }); app.get('/api/me', async () => ({ ok: true })); app.post('/api/password', async (req, reply) => { const { current, next } = req.body || {}; if (!verifyPassword(current)) return reply.code(401).send({ error: 'Wrong current password' }); const pw = String(next || ''); if (pw.length < MIN_PASSWORD) return reply.code(400).send({ error: `New password must be at least ${MIN_PASSWORD} characters` }); if (pw.length > MAX_PASSWORD) return reply.code(400).send({ error: `New password must be at most ${MAX_PASSWORD} characters` }); setPassword(pw); const token = createSession(); reply.setCookie(COOKIE_NAME, token, cookieOpts); return { ok: true }; }); }