const express = require('express'); const router = express.Router(); const { generateToken, verifyToken, verifyPassword, getCredentials } = require('../utils/auth.js'); const COOKIE_NAME = 'libreportal_token'; const COOKIE_OPTS = { httpOnly: true, sameSite: 'strict', maxAge: 30 * 24 * 60 * 60 * 1000 }; // Per-IP login rate limit: 10 attempts per 15 minutes. Lockout doubles each subsequent // trip so a sustained attacker hits exponentially long waits. const LOGIN_WINDOW_MS = 15 * 60 * 1000; const LOGIN_MAX_ATTEMPTS = 10; const loginAttempts = new Map(); // ip -> { count, firstAttempt, lockedUntil } function checkLoginRate(ip) { const now = Date.now(); const entry = loginAttempts.get(ip); if (!entry) return { allowed: true }; if (entry.lockedUntil && now < entry.lockedUntil) { return { allowed: false, retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) }; } if (now - entry.firstAttempt > LOGIN_WINDOW_MS) { loginAttempts.delete(ip); } return { allowed: true }; } function recordFailedLogin(ip) { const now = Date.now(); const entry = loginAttempts.get(ip) || { count: 0, firstAttempt: now, lockedUntil: 0 }; entry.count += 1; if (entry.count >= LOGIN_MAX_ATTEMPTS) { const prevLockMs = entry.lockedUntil ? entry.lockedUntil - entry.firstAttempt : 0; const lockMs = Math.max(LOGIN_WINDOW_MS, prevLockMs * 2); entry.lockedUntil = now + lockMs; } loginAttempts.set(ip, entry); } function clearLoginAttempts(ip) { loginAttempts.delete(ip); } router.post('/login', async (req, res) => { const ip = req.ip || req.socket?.remoteAddress || 'unknown'; const rate = checkLoginRate(ip); if (!rate.allowed) { res.setHeader('Retry-After', rate.retryAfter); return res.status(429).json({ error: 'Too many login attempts. Try again later.' }); } const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } try { const creds = getCredentials(); const usernameMatch = username === creds.username; const passwordMatch = await verifyPassword(password, creds.passwordHash); if (!usernameMatch || !passwordMatch) { recordFailedLogin(ip); // Constant-time delay to prevent timing attacks await new Promise(r => setTimeout(r, 500)); return res.status(401).json({ error: 'Invalid credentials' }); } clearLoginAttempts(ip); const token = generateToken(username); res.cookie(COOKIE_NAME, token, COOKIE_OPTS); res.json({ success: true, username }); } catch (error) { console.error('[Auth] Login error:', error.message); res.status(500).json({ error: 'Internal error' }); } }); router.post('/logout', (req, res) => { res.clearCookie(COOKIE_NAME); res.json({ success: true }); }); router.get('/status', (req, res) => { const token = req.cookies?.[COOKIE_NAME]; if (!token) return res.json({ authenticated: false }); const payload = verifyToken(token); if (!payload) return res.json({ authenticated: false }); res.json({ authenticated: true, username: payload.sub }); }); module.exports = router;