Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- zone: `nameservers` holds the zone's nameserver ids on every store, and POST /zone defaults it to the group's usable nameservers
- fix: one live zone per name, ignoring case and a trailing dot
- fix(sql): store existing zone names lowercase without the trailing dot
- 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

Expand Down
18 changes: 12 additions & 6 deletions lib/session/store/file.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
13 changes: 10 additions & 3 deletions lib/session/store/mysql.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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
}
Expand All @@ -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`
Expand Down Expand Up @@ -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],
Comment thread
msimerson marked this conversation as resolved.
Resolved
)
return r.affectedRows
}

disconnect() {
return this.mysql?.disconnect()
}
Expand Down
46 changes: 44 additions & 2 deletions lib/session/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 () => {
Expand All @@ -48,11 +62,39 @@ 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('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)
const stale = { nt_user_id: sessionUser.id, last_access: now - maxAgeSec - 60 }
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 }))
Expand Down
17 changes: 17 additions & 0 deletions lib/session/v3.js
Original file line number Diff line number Diff line change
@@ -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 = 8 * 60 * 60
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)
}
17 changes: 17 additions & 0 deletions lib/session/v3.test.js
Original file line number Diff line number Diff line change
@@ -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'))
})
})
21 changes: 14 additions & 7 deletions routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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'
Expand Down Expand Up @@ -70,16 +71,22 @@ async function setup() {
sub: false,
nbf: true,
exp: true,
maxAgeSec: 8 * 60 * 60,
timeSkewSec: 15,
maxAgeSec,
timeSkewSec,
},
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 }
},
})

Expand Down
18 changes: 9 additions & 9 deletions routes/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

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'

Expand All @@ -24,8 +25,6 @@
handler: async (request, h) => {
const { user, group, session } = h.request.auth.credentials

Session.put({ id: session.id, last_access: true })

return h
.response({
user: user,
Expand Down Expand Up @@ -58,10 +57,14 @@
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(
Expand All @@ -79,7 +82,7 @@
algorithm: 'HS512',
},
{
ttlSec: 8 * 60 * 60,
ttlSec: maxAgeSec,
},
)

Expand Down Expand Up @@ -109,12 +112,9 @@
tags: ['api'],
},
handler: async (request, h) => {
const { user, group, session } = h.request.auth.credentials

Check warning on line 115 in routes/session.js

View workflow job for this annotation

GitHub Actions / lint / lint

'group' is assigned a value but never used

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({
Expand Down
Loading
Loading