From 6a5fea9ef9a758b29220e3c8d82ab0db51311591 Mon Sep 17 00:00:00 2001 From: gnacho Date: Sat, 19 Sep 2026 20:24:00 +0200 Subject: [PATCH] fix(auth): survive User-Agent changes and add sliding session expiration Changing the browser or WebView version no longer deletes the session; the stored fingerprint is updated instead. Sessions now renew their expiry and re-issue the cookie when less than half the TTL remains. Closes #251 --- server/src/app.js | 2 +- server/src/auth.js | 25 +++++++++++++++++--- server/tests/auth.test.js | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/server/src/app.js b/server/src/app.js index f7144c4..4cfa90d 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -97,7 +97,7 @@ export function createApp(ctx) { }) // --- Auth: todo /api/* requiere sesión salvo login/register/demo/logout --- - app.use('/api/*', auth.requireAuth({ prod, demo, secret })) + app.use('/api/*', auth.requireAuth({ prod, demo, secret, cookieSecure: config.cookieSecure })) // --- Demo de solo lectura: la BD demo es "inamovible". Se rechazan todas // las mutaciones (POST/PUT/PATCH/DELETE) en sesiones demo. Las rutas diff --git a/server/src/auth.js b/server/src/auth.js index 99b29bc..fa953f2 100644 --- a/server/src/auth.js +++ b/server/src/auth.js @@ -11,6 +11,9 @@ import { ERROR_CODES } from './error-codes.js' const log = logger.child({ component: 'auth' }) export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 días +// Renovación deslizante: con menos de la mitad del TTL por delante se +// extiende expires_at y se re-emite la cookie (issue #251). +export const SESSION_RENEW_THRESHOLD_MS = SESSION_TTL_MS / 2 export const COOKIE_NAME = 'deltos_session' const LOCK_MS = 5 * 60 * 1000 // 5 min de bloqueo tras 5 intentos fallidos @@ -92,12 +95,18 @@ export function resolveSession({ prod, demo, secret }, cookieHeader, ua) { if (!db) continue const s = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) if (!s || s.expires_at <= now) continue + // El User-Agent cambia con actualizaciones legítimas del navegador o del + // WebView de la PWA; ya no se invalida la sesión, solo se actualiza el + // fingerprint registrado (issue #251). El robo de cookie sigue mitigado + // por httpOnly + SameSite=Lax + HMAC de la cookie. if (s.ua && ua && s.ua !== ua) { - db.prepare('DELETE FROM sessions WHERE id = ?').run(id) - return null + db.prepare('UPDATE sessions SET ua = ? WHERE id = ?').run(ua, id) + log.info('session_ua_updated', { sessionId: id, reason: 'ua_changed' }) } const user = db.prepare(`SELECT ${USER_PUBLIC_COLS} FROM users WHERE id = ?`).get(s.user_id) - if (user) return { db, demo: isDemo, user, sessionId: id, csrfToken: s.csrf_token } + if (!user) continue + const renew = s.expires_at - now < SESSION_RENEW_THRESHOLD_MS + return { db, demo: isDemo, user, sessionId: id, csrfToken: s.csrf_token, renew } } return null } @@ -124,6 +133,16 @@ export function requireAuth(ctx) { const session = resolveSession(ctx, c.req.header('cookie'), c.req.header('user-agent')) if (!session) httpError(401, ERROR_CODES.AUTH_REQUIRED) + // Renovación deslizante (issue #251): si la sesión queda por debajo del + // umbral, extender su vida y re-emitir la cookie con maxAge fresco. + if (session.renew) { + const expiresAt = Date.now() + SESSION_TTL_MS + session.db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(expiresAt, session.sessionId) + c.header('set-cookie', cookieFor(ctx.secret, session.sessionId, { + secure: ctx.cookieSecure === true, + maxAgeMs: SESSION_TTL_MS, + })) + } c.set('db', session.db) c.set('user', session.user) c.set('demo', session.demo) diff --git a/server/tests/auth.test.js b/server/tests/auth.test.js index d599e80..c21bfed 100644 --- a/server/tests/auth.test.js +++ b/server/tests/auth.test.js @@ -253,4 +253,54 @@ describe('auth', () => { const currentSession = await app.request('/api/auth/me', { headers: { cookie: pepe2.cookie } }) expect(currentSession.status).toBe(200) }) + + it('cambio de User-Agent NO invalida la sesión: se acepta y se actualiza el ua (#251)', async () => { + const { app, prod } = await makeInstance() + const login = await app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'user-agent': 'AgenteA/1.0' }, + body: JSON.stringify({ username: 'admin', password: 'admin1234567' }), + }) + expect(login.status).toBe(200) + const cookie = login.headers.get('set-cookie').split(';')[0] + const sessionId = cookie.split('=')[1].split('.')[0] + + const me = await app.request('/api/auth/me', { + headers: { cookie, 'user-agent': 'AgenteB/2.0 (mismo navegador actualizado)' }, + }) + expect(me.status).toBe(200) + const row = prod.prepare('SELECT ua FROM sessions WHERE id = ?').get(sessionId) + expect(row.ua).toBe('AgenteB/2.0 (mismo navegador actualizado)') + }) + + it('renovación deslizante: sesión por debajo del umbral extiende expires_at y re-emite cookie (#251)', async () => { + const { app, prod } = await makeInstance() + const auth = await loginAdmin(app) + const sessionId = auth.cookie.split('=')[1].split('.')[0] + const now = Date.now() + const almostExpired = now + 10 * 24 * 3600 * 1000 // 10 días < umbral (15 días) + prod.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(almostExpired, sessionId) + + const me = await app.request('/api/auth/me', { headers: { cookie: auth.cookie } }) + expect(me.status).toBe(200) + const setCookie = me.headers.get('set-cookie') + expect(setCookie).toMatch(/^deltos_session=.+\..+/) + expect(setCookie).toMatch(/Max-Age=2592000/) + const row = prod.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(sessionId) + expect(row.expires_at).toBeGreaterThan(now + 25 * 24 * 3600 * 1000) + }) + + it('renovación deslizante: sesión por encima del umbral NO se toca (#251)', async () => { + const { app, prod } = await makeInstance() + const auth = await loginAdmin(app) + const sessionId = auth.cookie.split('=')[1].split('.')[0] + const fresh = Date.now() + 20 * 24 * 3600 * 1000 // 20 días > umbral (15 días) + prod.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(fresh, sessionId) + + const me = await app.request('/api/auth/me', { headers: { cookie: auth.cookie } }) + expect(me.status).toBe(200) + expect(me.headers.get('set-cookie')).toBeNull() + const row = prod.prepare('SELECT expires_at FROM sessions WHERE id = ?').get(sessionId) + expect(row.expires_at).toBe(fresh) + }) })