From d1c27d853828f65a8d39ccce614445e52143cd62 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:16:54 +0100 Subject: [PATCH 1/3] session: one random row per login Each login writes its own row holding 3$ and 64 random hex chars instead of the constant 3.0.0, which v2 accepted as a session from anyone. The 3$ shape marks v3's rows the way a stored password hash names its own format. A login sweeps that user's v3 sessions idle past the token's max age, and leaves v2's to its own timeout. The token's exp now matches that max age. The mysql store selects last_access, so its activity throttle applies. --- CHANGELOG.md | 1 + lib/session/store/file.js | 18 ++++++++++++------ lib/session/store/mysql.js | 13 ++++++++++--- lib/session/test/index.js | 32 ++++++++++++++++++++++++++++++-- lib/session/v3.js | 17 +++++++++++++++++ lib/session/v3.test.js | 17 +++++++++++++++++ routes/index.js | 5 +++-- routes/session.js | 20 ++++++++++++-------- 8 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 lib/session/v3.js create mode 100644 lib/session/v3.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 915db80..c633d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - fix(mysql): read v2 group permission rows, and save partial permission changes - fix: file store login checks the group in user@group - create: allocate ids internally and reject caller-supplied ids +- fix: one random, self describing session row per login, expired ones swept at login, and mysql throttles the activity touch ### [3.0.3] - 2026-07-27 diff --git a/lib/session/store/file.js b/lib/session/store/file.js index 2c2eb16..b83ac3f 100644 --- a/lib/session/store/file.js +++ b/lib/session/store/file.js @@ -1,4 +1,5 @@ import FileStore, { claimId, removeRows } from '../../store/file.js' +import { isV3Session } from '../v3.js' // Map legacy nt_* column names to the friendly API names used throughout. function normalizeArgs(args) { @@ -29,12 +30,7 @@ class SessionRepoFile { async create(args) { args = normalizeArgs(JSON.parse(JSON.stringify(args))) - return this.file.mutate('session', (sessions, data, keep) => { - const existing = sessions.find( - (session) => session.uid === args.uid && session.session === args.session, - ) - if (existing) return keep(existing.id) - + return this.file.mutate('session', (sessions, data) => { args.id = claimId(sessions, data) sessions.push(args) return args.id @@ -102,6 +98,16 @@ class SessionRepoFile { ) } + async expire({ uid, before }) { + return this.file.mutate('session', (sessions, data, keep) => { + const removed = removeRows( + sessions, + (s) => s.uid === uid && isV3Session(s.session) && s.last_access < before, + ) + return removed > 0 ? removed : keep(0) + }) + } + disconnect() { // noop } diff --git a/lib/session/store/mysql.js b/lib/session/store/mysql.js index bc45390..d86bc8b 100644 --- a/lib/session/store/mysql.js +++ b/lib/session/store/mysql.js @@ -1,5 +1,6 @@ import Mysql from '../../mysql.js' import { mapToDbColumn } from '../../util.js' +import { V3_PREFIX } from '../v3.js' const sessionDbMap = { id: 'nt_user_session_id', @@ -13,9 +14,6 @@ class SessionRepoMySQL { } async create(args) { - const r = await this.get(args) - if (r) return r.id - const id = await Mysql.execute(...Mysql.insert(`nt_user_session`, mapToDbColumn(args, sessionDbMap))) return id } @@ -24,6 +22,7 @@ class SessionRepoMySQL { let query = `SELECT s.nt_user_session_id AS id , s.nt_user_id AS uid , s.nt_user_session AS session + , s.last_access FROM nt_user_session s LEFT JOIN nt_user u ON s.nt_user_id = u.nt_user_id WHERE u.deleted=0` @@ -73,6 +72,14 @@ class SessionRepoMySQL { return r.affectedRows === 1 } + async expire({ uid, before }) { + const r = await Mysql.execute( + 'DELETE FROM nt_user_session WHERE nt_user_id = ? AND nt_user_session LIKE ? AND last_access < ?', + [uid, `${V3_PREFIX}%`, before], + ) + return r.affectedRows + } + disconnect() { return this.mysql?.disconnect() } diff --git a/lib/session/test/index.js b/lib/session/test/index.js index db09f60..0cb0a51 100644 --- a/lib/session/test/index.js +++ b/lib/session/test/index.js @@ -3,6 +3,7 @@ import { describe, it, after, before } from 'node:test' import User from '../../user/index.js' import Session from '../index.js' +import { maxAgeSec, newSessionValue } from '../v3.js' import userCase from '../../user/test/user.json' with { type: 'json' } const sessionUser = { @@ -24,22 +25,35 @@ after(async () => { describe('session', function () { let sessionId + const sessionValue = newSessionValue() describe('create', () => { it('creates a login session', async () => { sessionId = await Session.create({ nt_user_id: sessionUser.id, - session: '3.0.0', + session: sessionValue, last_access: parseInt(Date.now() / 1000, 10), }) assert.ok(sessionId) }) + + it('creates a distinct row for each login', async () => { + const secondId = await Session.create({ + nt_user_id: sessionUser.id, + session: newSessionValue(), + last_access: parseInt(Date.now() / 1000, 10), + }) + assert.notEqual(secondId, sessionId) + await Session.delete({ id: secondId }) + assert.ok(await Session.get({ id: sessionId })) + }) }) describe('get', () => { it('finds a session by id', async () => { const s = await Session.get({ id: sessionId }) assert.ok(s?.id) + assert.ok(s.last_access) }) it('finds a session by nt_user_session_id', async () => { @@ -48,11 +62,25 @@ describe('session', function () { }) it('finds a session by session', async () => { - const s = await Session.get({ nt_user_session: '3.0.0' }) + const s = await Session.get({ nt_user_session: sessionValue }) assert.ok(s?.id) }) }) + describe('expire', () => { + it('removes only v3 sessions idle since before the cutoff', async () => { + const now = parseInt(Date.now() / 1000, 10) + const stale = { nt_user_id: sessionUser.id, last_access: now - 20000 } + const staleId = await Session.create({ ...stale, session: newSessionValue() }) + const v2Id = await Session.create({ ...stale, session: '0123456789abcdef0123456789abcdef' }) + await Session.expire({ uid: sessionUser.id, before: now - maxAgeSec }) + assert.equal(await Session.get({ id: staleId }), undefined) + assert.ok(await Session.get({ id: v2Id })) + assert.ok(await Session.get({ id: sessionId })) + await Session.delete({ id: v2Id }) + }) + }) + describe('delete', () => { it('deletes a session by ID', async () => { assert.ok(await Session.delete({ id: sessionId })) diff --git a/lib/session/v3.js b/lib/session/v3.js new file mode 100644 index 0000000..72f8627 --- /dev/null +++ b/lib/session/v3.js @@ -0,0 +1,17 @@ +import crypto from 'node:crypto' + +// v2 writes 32 hex chars. v3 writes 3$ and 64, self describing like a +// password hash, so v3 can tell its rows from v2's in the shared table. +export const V3_PREFIX = '3$' + +// a token older than this, give or take the skew, is refused +export const maxAgeSec = 14400 +export const timeSkewSec = 15 + +export function newSessionValue() { + return `${V3_PREFIX}${crypto.randomBytes(32).toString('hex')}` +} + +export function isV3Session(value) { + return typeof value === 'string' && value.startsWith(V3_PREFIX) +} diff --git a/lib/session/v3.test.js b/lib/session/v3.test.js new file mode 100644 index 0000000..ad20898 --- /dev/null +++ b/lib/session/v3.test.js @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { isV3Session, newSessionValue } from './v3.js' + +describe('session value', () => { + it('is new and self describing on every login', () => { + const a = newSessionValue() + assert.match(a, /^3\$[0-9a-f]{64}$/) + assert.notEqual(a, newSessionValue()) + assert.ok(isV3Session(a)) + }) + + it('does not claim a v2 session', () => { + assert.ok(!isV3Session('0123456789abcdef0123456789abcdef')) + }) +}) diff --git a/routes/index.js b/routes/index.js index f158135..8b4af29 100644 --- a/routes/index.js +++ b/routes/index.js @@ -21,6 +21,7 @@ import pkgJson from '../package.json' with { type: 'json' } import GroupRoutes from './group.js' import { User, UserRoutes } from './user.js' import { Session, SessionRoutes } from './session.js' +import { maxAgeSec, timeSkewSec } from '../lib/session/v3.js' import { PermissionRoutes } from './permission.js' import { NameserverRoutes } from './nameserver.js' import { ZoneRoutes } from './zone.js' @@ -91,8 +92,8 @@ async function setup() { sub: false, nbf: true, exp: true, - maxAgeSec: 14400, // 4 hours - timeSkewSec: 15, + maxAgeSec, + timeSkewSec, }, httpAuthScheme: 'Bearer', headerName: 'authorization', diff --git a/routes/session.js b/routes/session.js index e49ee4e..98dd24c 100644 --- a/routes/session.js +++ b/routes/session.js @@ -5,6 +5,7 @@ import Jwt from '@hapi/jwt' import User from '../lib/user/index.js' import Session from '../lib/session/index.js' +import { maxAgeSec, newSessionValue, timeSkewSec } from '../lib/session/v3.js' import { meta } from '../lib/util.js' @@ -24,7 +25,9 @@ function SessionRoutes(server) { handler: async (request, h) => { const { user, group, session } = h.request.auth.credentials - Session.put({ id: session.id, last_access: true }) + Session.put({ id: session.id, last_access: true }).catch((err) => { + console.error(`session ${session.id} activity: ${err.message}`) + }) return h .response({ @@ -58,10 +61,14 @@ function SessionRoutes(server) { return h.response({ err: 'Invalid authentication' }).code(401) } + const now = parseInt(Date.now() / 1000, 10) + await Session.expire({ uid: account.user.id, before: now - maxAgeSec - timeSkewSec }).catch((err) => { + console.error(`session expiry for user ${account.user.id}: ${err.message}`) + }) const sessId = await Session.create({ uid: account.user.id, - session: '3.0.0', - last_access: parseInt(Date.now() / 1000, 10), + session: newSessionValue(), + last_access: now, }) const token = Jwt.token.generate( @@ -79,7 +86,7 @@ function SessionRoutes(server) { algorithm: 'HS512', }, { - ttlSec: 28800, // 8 hours + ttlSec: maxAgeSec, }, ) @@ -111,10 +118,7 @@ function SessionRoutes(server) { handler: async (request, h) => { const { user, group, session } = h.request.auth.credentials - const bool = await Session.delete({ - id: session.id, - session: '3.0.0', - }) + const bool = await Session.delete({ id: session.id, uid: user.id }) return h .response({ From 5ca1102781b98dbb80ab56e94f7a27da4f46dc95 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:39:29 +0100 Subject: [PATCH 2/3] test: cover the session throttle write and failed touches --- lib/session/test/index.js | 14 ++++++++++++++ routes/session.test.js | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/session/test/index.js b/lib/session/test/index.js index 0cb0a51..8c75b8c 100644 --- a/lib/session/test/index.js +++ b/lib/session/test/index.js @@ -67,6 +67,20 @@ describe('session', function () { }) }) + describe('put', () => { + it('refreshes last_access once it is a minute old', async () => { + const now = parseInt(Date.now() / 1000, 10) + const id = await Session.create({ + nt_user_id: sessionUser.id, + session: newSessionValue(), + last_access: now - 120, + }) + assert.ok(await Session.put({ id, last_access: true })) + assert.ok((await Session.get({ id })).last_access >= now) + await Session.delete({ id }) + }) + }) + describe('expire', () => { it('removes only v3 sessions idle since before the cutoff', async () => { const now = parseInt(Date.now() / 1000, 10) diff --git a/routes/session.test.js b/routes/session.test.js index 8c1ffc2..9f7bcfe 100644 --- a/routes/session.test.js +++ b/routes/session.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { describe, it, before, after } from 'node:test' +import { describe, it, before, after, mock } from 'node:test' import { init } from './index.js' import userCase from './test/user.json' with { type: 'json' } @@ -9,6 +9,7 @@ import permCase from './test/permission.json' with { type: 'json' } import User from '../lib/user/index.js' import Group from '../lib/group/index.js' import Permission from '../lib/permission/index.js' +import Session from '../lib/session/index.js' let server @@ -41,6 +42,41 @@ describe('session routes', () => { } }) + const login = { + username: `${userCase.username}@${groupCase.name}`, + password: userCase.password, + } + + it('POST /session refuses a wrong password', async () => { + const res = await server.inject({ + method: 'POST', + url: '/session', + payload: { ...login, password: 'wrong' }, + }) + assert.equal(res.statusCode, 401) + }) + + it('logs in and refreshes activity when session writes fail', async () => { + mock.method(console, 'error', () => {}) + mock.method(Session, 'expire', async () => { + throw new Error('expire failed') + }) + mock.method(Session, 'put', async () => { + throw new Error('put failed') + }) + try { + const res = await server.inject({ method: 'POST', url: '/session', payload: login }) + assert.equal(res.statusCode, 200) + const headers = { Authorization: `Bearer ${res.result.session.token}` } + assert.equal((await server.inject({ method: 'GET', url: '/session', headers })).statusCode, 200) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(console.error.mock.callCount(), 2) + await server.inject({ method: 'DELETE', url: '/session', headers }) + } finally { + mock.restoreAll() + } + }) + describe('with session, can retrieve private URIs', () => { let auth = { headers: {} } From 9da45e7f93dc1dda3e121f72efa48c78c608c61e Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 15 Oct 2026 18:40:02 -0700 Subject: [PATCH 3/3] session: logout revokes the token The JWT validate accepted any well-signed token, so DELETE /session left the token working until it expired. A token is now valid only while its session row exists for that user. v2's timeout_sessions deletes rows idle past its session_timeout (45 min by default) on a shared db, so every authenticated request now refreshes last_access, not only GET /session. --- CHANGELOG.md | 1 + routes/index.js | 16 ++++++++++----- routes/session.js | 4 ---- routes/session.test.js | 44 ++++++++++++++++++++++++++++++++---------- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c633d7b..925800e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - fix: file store login checks the group in user@group - create: allocate ids internally and reject caller-supplied ids - fix: one random, self describing session row per login, expired ones swept at login, and mysql throttles the activity touch +- fix: a token is valid only while its session row exists, so logout revokes it, and any authenticated request counts as activity ### [3.0.3] - 2026-07-27 diff --git a/routes/index.js b/routes/index.js index 3c00d78..f7b189b 100644 --- a/routes/index.js +++ b/routes/index.js @@ -76,11 +76,17 @@ async function setup() { }, httpAuthScheme: 'Bearer', headerName: 'authorization', - validate: (artifacts, request, h) => { - return { - isValid: true, - credentials: artifacts.decoded.payload.nt, - } + validate: async (artifacts) => { + const credentials = artifacts.decoded.payload.nt + const session = await Session.get({ id: credentials.session.id, uid: credentials.user.id }) + if (!session) return { isValid: false } + + // v2's timeout_sessions deletes any row idle past its session_timeout, + // so every authenticated request has to count as activity + await Session.put({ id: session.id, last_access: true }).catch((err) => { + console.error(`session ${session.id} activity: ${err.message}`) + }) + return { isValid: true, credentials } }, }) diff --git a/routes/session.js b/routes/session.js index b1d7843..be1a980 100644 --- a/routes/session.js +++ b/routes/session.js @@ -25,10 +25,6 @@ function SessionRoutes(server) { handler: async (request, h) => { const { user, group, session } = h.request.auth.credentials - Session.put({ id: session.id, last_access: true }).catch((err) => { - console.error(`session ${session.id} activity: ${err.message}`) - }) - return h .response({ user: user, diff --git a/routes/session.test.js b/routes/session.test.js index aa92a29..222a172 100644 --- a/routes/session.test.js +++ b/routes/session.test.js @@ -117,16 +117,6 @@ describe('session routes', () => { auth.headers = { Authorization: `Bearer ${res.result.session.token}` } }) - after(async () => { - const res = await server.inject({ - method: 'DELETE', - url: '/session', - headers: auth.headers, - }) - // console.log(res.result) - assert.equal(res.statusCode, 200) - }) - const routes = [{ GET: '/' }, { GET: '/session' }, { DELETE: '/session' }] for (const r of routes) { @@ -143,4 +133,38 @@ describe('session routes', () => { }) } }) + + it('DELETE /session revokes the token', async () => { + const res = await server.inject({ method: 'POST', url: '/session', payload: login }) + const headers = { Authorization: `Bearer ${res.result.session.token}` } + + assert.equal((await server.inject({ method: 'DELETE', url: '/session', headers })).statusCode, 200) + assert.equal((await server.inject({ method: 'GET', url: '/session', headers })).statusCode, 401) + }) + + it('refuses a token whose session row is gone', async () => { + const res = await server.inject({ method: 'POST', url: '/session', payload: login }) + const headers = { Authorization: `Bearer ${res.result.session.token}` } + + await Session.delete({ id: res.result.session.id }) + assert.equal((await server.inject({ method: 'GET', url: '/user/4096', headers })).statusCode, 401) + }) + + it('counts any authenticated request as session activity', async () => { + const res = await server.inject({ method: 'POST', url: '/session', payload: login }) + const { id, token } = res.result.session + const headers = { Authorization: `Bearer ${token}` } + const loggedIn = (await Session.get({ id })).last_access + + const now = Date.now() + mock.method(Date, 'now', () => now + 120_000) + try { + assert.equal((await server.inject({ method: 'GET', url: '/', headers })).statusCode, 200) + } finally { + mock.restoreAll() + } + assert.ok((await Session.get({ id })).last_access >= loggedIn + 120) + + await server.inject({ method: 'DELETE', url: '/session', headers }) + }) })