Files
website/server/src/model/users/users.model.js
Claude 933206a1b8 Implement web session/token revocation (#30)
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
2026-07-04 21:06:50 -05:00

106 lines
2.6 KiB
JavaScript

const bcrypt = require('bcryptjs')
const usersDb = require('./users.db')
const SALT_ROUNDS = 10
// Strip secrets (password hash, TOTP secret) before sending a user anywhere.
function sanitize(user) {
if (!user) return null
const { password_hash, totp_secret, ...safe } = user
return safe
}
async function createUser({ username, password, role = 'admin' }) {
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
const id = await usersDb.insertUser({ username, passwordHash, role })
return sanitize(await usersDb.findById(id))
}
// Returns the raw row (incl. hash) — used by login only.
async function getRawByUsername(username) {
return usersDb.findByUsername(username)
}
async function getById(id) {
return sanitize(await usersDb.findById(id))
}
// Raw row incl. totp_secret — server-side only (TOTP setup/verify). Never sent
// to a client; sanitize() strips the secret from anything user-facing.
async function getRawById(id) {
return usersDb.findById(id)
}
async function setTotpSecret(id, secret) {
return usersDb.setTotpSecret(id, secret)
}
async function enableTotp(id) {
return usersDb.enableTotp(id)
}
async function disableTotp(id) {
return usersDb.disableTotp(id)
}
async function validatePassword(user, password) {
if (!user || !user.password_hash) return false
return bcrypt.compare(password, user.password_hash)
}
async function list() {
return usersDb.listUsers()
}
async function update(id, { username, password, role }) {
const fields = {}
if (username !== undefined) fields.username = username
if (role !== undefined) fields.role = role
if (password) fields.password_hash = await bcrypt.hash(password, SALT_ROUNDS)
await usersDb.updateUser(id, fields)
// A password change must revoke existing sessions ("change password to log
// everyone out"), so bump the cutoff whenever the hash was rotated.
if (password) await usersDb.bumpTokensValidAfter(id)
return getById(id)
}
// Invalidate every session token this user currently holds ("log out everywhere")
// by advancing their tokens_valid_after cutoff to now.
async function invalidateSessions(id) {
return usersDb.bumpTokensValidAfter(id)
}
async function remove(id) {
return usersDb.deleteUser(id)
}
async function count() {
return usersDb.countUsers()
}
async function countAdmins() {
return usersDb.countAdmins()
}
async function recordLogin(id) {
return usersDb.touchLastLogin(id)
}
module.exports = {
createUser,
getRawByUsername,
getById,
getRawById,
validatePassword,
list,
update,
invalidateSessions,
remove,
count,
countAdmins,
recordLogin,
setTotpSecret,
enableTotp,
disableTotp,
}