A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
93 lines
3.1 KiB
JavaScript
Executable File
93 lines
3.1 KiB
JavaScript
Executable File
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;
|