librelad 875a60f90f LibrePortal v0.1.0 — initial release
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>
2026-05-21 20:37:54 +01:00

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;