Sessions: 180-day lifetime with sliding renewal

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 12:40:15 -04:00
1 parent 1566ac2897
commit 7dea2734ea
1 file changed
+18 -4
+18 -4
View File
@@ -35,8 +35,14 @@ function parseCookies(req) {
}).filter(([k]) => k)); }).filter(([k]) => k));
} }
// Sessions last SESSION_DAYS and slide: any signed-in request more than a day after the
// cookie was issued gets a fresh one, so people who keep using the app never sign in again.
const SESSION_DAYS = 180;
const SESSION_MS = SESSION_DAYS * 24 * 60 * 60 * 1000;
const SESSION_RENEW_AFTER_MS = 24 * 60 * 60 * 1000;
function signSession(username, epoch = 0) { function signSession(username, epoch = 0) {
const expiry = Date.now() + 30 * 24 * 60 * 60 * 1000; const expiry = Date.now() + SESSION_MS;
const payload = Buffer.from(`${username}|${expiry}|${epoch}`).toString('base64url'); const payload = Buffer.from(`${username}|${expiry}|${epoch}`).toString('base64url');
const sig = crypto.createHmac('sha256', SESSION_SECRET).update(payload).digest('base64url'); const sig = crypto.createHmac('sha256', SESSION_SECRET).update(payload).digest('base64url');
return `${payload}.${sig}`; return `${payload}.${sig}`;
@@ -56,12 +62,12 @@ function verifySession(token) {
const decoded = Buffer.from(payload, 'base64url').toString(); const decoded = Buffer.from(payload, 'base64url').toString();
const [username, expiry, epoch] = decoded.split('|'); const [username, expiry, epoch] = decoded.split('|');
if (!username || !expiry || Date.now() > parseInt(expiry)) return null; if (!username || !expiry || Date.now() > parseInt(expiry)) return null;
return { username, epoch: parseInt(epoch) || 0 }; return { username, epoch: parseInt(epoch) || 0, expiry: parseInt(expiry) };
} }
function sessionCookie(req, token) { function sessionCookie(req, token) {
const secure = req.secure || req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : ''; const secure = req.secure || req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '';
return `dsession=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 3600}; SameSite=Lax${secure}`; return `dsession=${token}; HttpOnly; Path=/; Max-Age=${SESSION_DAYS * 24 * 3600}; SameSite=Lax${secure}`;
} }
function hashPassword(password, salt) { function hashPassword(password, salt) {
@@ -142,12 +148,20 @@ function currentUser(req) {
if (!s) return null; if (!s) return null;
const u = findUser(s.username); const u = findUser(s.username);
if (!u || u.disabled || (u.epoch || 0) !== s.epoch) return null; if (!u || u.disabled || (u.epoch || 0) !== s.epoch) return null;
req.sessionExpiry = s.expiry;
return u; return u;
} }
function requireDashboardAuth(req, res, next) { function requireDashboardAuth(req, res, next) {
const user = currentUser(req); const user = currentUser(req);
if (user) { req.dashUser = user; return next(); } if (user) {
req.dashUser = user;
// Sliding renewal (routes that set their own cookie later simply overwrite this header)
if (req.sessionExpiry - Date.now() < SESSION_MS - SESSION_RENEW_AFTER_MS) {
res.setHeader('Set-Cookie', sessionCookie(req, signSession(user.username, user.epoch || 0)));
}
return next();
}
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorized' }); if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorized' });
res.redirect('/dashboard/login'); res.redirect('/dashboard/login');
} }