Web sessions were stateless JWTs with no server-side store: the revocation hooks in session.service were stubs that only logged. As a result web logout was client-side only (a copied cookie stayed valid until natural JWT expiry) and a password change never invalidated existing sessions. The mobile bearer flow already had revocable, DB-stored tokens; this brings the web/cookie flow to parity. Two-layer revocation, both enforced in requireAuth (which already loads the fresh user row each request): - Per-session denylist: new `revoked_sessions` table keyed on the JWT `jti` (already minted per session). A single logout adds this session's jti; rows self-expire at the token's own exp and are pruned on boot. New model `revokedSessions` mirrors the `mobileSessions` db/model split. - Per-user cutoff: new `users.tokens_valid_after` column. A password change (and the new `invalidateSessions` helper) bumps it to NOW(); any token whose iat is at or before the cutoff is rejected. The comparison is inclusive so a token minted in the same wall-clock second as the change is still revoked. Wiring: - session.service: revokeSession / invalidateSession / invalidateAllUserSessions now delegate to the stores; sessions carry `expiresAt` (JWT exp) so logout can set a self-pruning denylist row. - /logout gains best-effort attachSession so the controller can revoke this session's jti and log auth.logout; stays a no-op for anonymous callers. - users.model.update bumps the cutoff whenever the password hash is rotated. - schema.sql: revoked_sessions table + tokens_valid_after column, added to the CREATE and to the idempotent migration block (ensureSchema on boot). Verified end-to-end against the local dev DB: a captured cookie is rejected after logout, and an existing session is rejected after a password change while re-login with the new password succeeds. Full server test suite green (96). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
93 lines
2.6 KiB
JavaScript
93 lines
2.6 KiB
JavaScript
const { query } = require('../../utils/db')
|
|
|
|
const PUBLIC_COLS = 'id, username, role, totp_enabled, created_at, last_login_at'
|
|
|
|
async function insertUser({ username, passwordHash, role = 'admin' }) {
|
|
const res = await query(
|
|
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
|
|
[username, passwordHash, role],
|
|
)
|
|
return res.insertId
|
|
}
|
|
|
|
async function findByUsername(username) {
|
|
const rows = await query('SELECT * FROM users WHERE username = ? LIMIT 1', [username])
|
|
return rows[0] || null
|
|
}
|
|
|
|
async function findById(id) {
|
|
const rows = await query('SELECT * FROM users WHERE id = ? LIMIT 1', [id])
|
|
return rows[0] || null
|
|
}
|
|
|
|
async function listUsers() {
|
|
return query(`SELECT ${PUBLIC_COLS} FROM users ORDER BY id ASC`)
|
|
}
|
|
|
|
async function updateUser(id, fields) {
|
|
const cols = []
|
|
const params = []
|
|
for (const [key, val] of Object.entries(fields)) {
|
|
cols.push(`${key} = ?`)
|
|
params.push(val)
|
|
}
|
|
if (cols.length === 0) return
|
|
params.push(id)
|
|
await query(`UPDATE users SET ${cols.join(', ')} WHERE id = ?`, params)
|
|
}
|
|
|
|
async function deleteUser(id) {
|
|
return query('DELETE FROM users WHERE id = ?', [id])
|
|
}
|
|
|
|
async function countUsers() {
|
|
const rows = await query('SELECT COUNT(*) AS c FROM users')
|
|
return Number(rows[0].c)
|
|
}
|
|
|
|
async function countAdmins() {
|
|
const rows = await query("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'")
|
|
return Number(rows[0].c)
|
|
}
|
|
|
|
async function touchLastLogin(id) {
|
|
return query('UPDATE users SET last_login_at = NOW() WHERE id = ?', [id])
|
|
}
|
|
|
|
// Move the "tokens valid after" cutoff to now, invalidating every session token
|
|
// issued before this instant (password change / log out everywhere). requireAuth
|
|
// compares each session's issued-at against this column.
|
|
async function bumpTokensValidAfter(id) {
|
|
return query('UPDATE users SET tokens_valid_after = NOW() WHERE id = ?', [id])
|
|
}
|
|
|
|
// Store a (not-yet-enabled) TOTP secret for a user. Enabling is a separate step
|
|
// so a secret is never trusted until the user has confirmed one code.
|
|
async function setTotpSecret(id, secret) {
|
|
return query('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?', [secret, id])
|
|
}
|
|
|
|
async function enableTotp(id) {
|
|
return query('UPDATE users SET totp_enabled = 1 WHERE id = ?', [id])
|
|
}
|
|
|
|
async function disableTotp(id) {
|
|
return query('UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?', [id])
|
|
}
|
|
|
|
module.exports = {
|
|
insertUser,
|
|
findByUsername,
|
|
findById,
|
|
listUsers,
|
|
updateUser,
|
|
deleteUser,
|
|
countUsers,
|
|
countAdmins,
|
|
touchLastLogin,
|
|
bumpTokensValidAfter,
|
|
setTotpSecret,
|
|
enableTotp,
|
|
disableTotp,
|
|
}
|