diff --git a/server/src/auth.js b/server/src/auth.js index 55b538c..7a655ba 100644 --- a/server/src/auth.js +++ b/server/src/auth.js @@ -1,7 +1,7 @@ import crypto from 'node:crypto' import bcrypt from 'bcryptjs' import { getCookie, setCookie, deleteCookie } from 'hono/cookie' -import { createSession, getSession, deleteSession, createUser, getUserByUsername, getUserById, updateUser } from './db.js' +import { createSession, getSession, deleteSession, renewSessionIfDue, createUser, getUserByUsername, getUserById, updateUser } from './db.js' import { config } from './config.js' const COOKIE_NAME = 'helios_session' @@ -143,6 +143,18 @@ export function requireAuth(db) { return async (c, next) => { const session = sessionIdFromCookie(db, c) if (!session) return c.json({ error: 'no autorizado' }, 401) + // Expiración deslizante (#130): si la sesión queda por debajo de la mitad + // del TTL, extender su vida y re-emitir la cookie con maxAge fresco. + if (renewSessionIfDue(db, session.id, config.sessionTtlMs)) { + const isHttps = c.req.header('x-forwarded-proto') === 'https' || c.req.url.startsWith('https://') + setCookie(c, COOKIE_NAME, `${session.id}.${sign(db, session.id)}`, { + httpOnly: true, + sameSite: 'Lax', + secure: isHttps, + maxAge: Math.floor(config.sessionTtlMs / 1000), + path: '/', + }) + } c.set('userId', session.userId) return next() } diff --git a/server/src/db.js b/server/src/db.js index 3ed020f..19314ef 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -295,6 +295,19 @@ export function deleteSession(db, id) { db.prepare('DELETE FROM sessions WHERE id = ?').run(id) } +// Expiración deslizante: si a la sesión le queda menos de la mitad del TTL, +// extiende expires_at un TTL completo desde ahora. Un usuario activo no pierde +// la sesión en el límite fijo. Devuelve true si renovó (el caller re-emite la +// cookie con maxAge fresco). +export function renewSessionIfDue(db, id, ttlMs) { + const row = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(id) + if (!row) return false + const now = Date.now() + if (row.expires_at - now >= ttlMs / 2) return false + db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(now + ttlMs, id) + return true +} + export function cleanSessions(db) { db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()) const oneHourAgo = Date.now() - 3600 * 1000 diff --git a/server/test/auth.test.js b/server/test/auth.test.js index 738e77f..7777fbd 100644 --- a/server/test/auth.test.js +++ b/server/test/auth.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import Database from 'better-sqlite3' import { initSchema, getUserByUsername } from '../src/db.js' -import { loginRateLimited, registerLoginFail, loginOk, registerUser, handleLogin, ensureBootstrapAdmin, changeOwnPassword } from '../src/auth.js' +import { loginRateLimited, registerLoginFail, loginOk, registerUser, handleLogin, ensureBootstrapAdmin, changeOwnPassword, requireAuth } from '../src/auth.js' function mockContext(ip, cookie) { const headers = new Headers() @@ -13,6 +13,7 @@ function mockContext(ip, cookie) { raw: { headers }, }, header: () => {}, + set: () => {}, res: { headers: { append: () => {} } }, env: {}, } @@ -112,3 +113,55 @@ describe('rate-limit de login (SQLite)', () => { expect(loginRateLimited(db, b)).toBe(false) }) }) + +describe('expiración deslizante de sesiones (#130)', () => { + const TTL_MS = 30 * 24 * 3600 * 1000 + + async function loginCapturingCookie(username, password) { + const c = mockContext('1.1.1.1') + const captured = [] + c.header = (k, v) => captured.push([k, v]) + const res = await handleLogin(db, c, { username, password }) + const setCookie = captured.find(([k]) => k === 'Set-Cookie')?.[1] || '' + const cookie = setCookie.split(';')[0] // helios_session=. + return { res, cookie } + } + + it('sesión por debajo de la mitad del TTL: renueva expires_at y re-emite cookie', async () => { + await registerUser(db, 'slide', 'secreto123') + const { cookie } = await loginCapturingCookie('slide', 'secreto123') + expect(cookie).toMatch(/^helios_session=.+\..+/) + const sessionId = cookie.split('=')[1].split('.')[0] + const soon = Date.now() + 10 * 24 * 3600 * 1000 // 10 días < 15 (mitad del TTL) + db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(soon, sessionId) + + const c2 = mockContext('1.1.1.1', cookie) + const captured = [] + c2.header = (k, v) => captured.push([k, v]) + let nextCalled = false + await requireAuth(db)(c2, async () => { nextCalled = true }) + expect(nextCalled).toBe(true) + const renewed = captured.find(([k]) => k === 'Set-Cookie')?.[1] || '' + expect(renewed).toMatch(/^helios_session=.+\..+/) + expect(renewed).toMatch(/Max-Age=2592000/) + const after = db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(sessionId).expires_at + expect(after).toBeGreaterThan(Date.now() + 25 * 24 * 3600 * 1000) + }) + + it('sesión por encima de la mitad del TTL: no renueva ni re-emite cookie', async () => { + await registerUser(db, 'fresh', 'secreto123') + const { cookie } = await loginCapturingCookie('fresh', 'secreto123') + const sessionId = cookie.split('=')[1].split('.')[0] + const fresh = Date.now() + 20 * 24 * 3600 * 1000 // 20 días > 15 + db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(fresh, sessionId) + + const c2 = mockContext('1.1.1.1', cookie) + const captured = [] + c2.header = (k, v) => captured.push([k, v]) + let nextCalled = false + await requireAuth(db)(c2, async () => { nextCalled = true }) + expect(nextCalled).toBe(true) + expect(captured.find(([k]) => k === 'Set-Cookie')).toBeUndefined() + expect(db.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(sessionId).expires_at).toBe(fresh) + }) +})