Add opt-in "Trust this device" so a browser/app skips the TOTP step (never the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout fallback, and admin trusted-device/MFA-reset management — backend, web UI, OpenAPI spec, and tests. - Schema: trusted_devices (sha256 token hash, looked up by unique index) and recovery_codes (bcrypt, single-use). Both additive/idempotent. - Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust httpOnly cookie (survives logout, revoked on untrust/password change/reset/ TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim. - Web + mobile login accept a trusted-device token / recovery code; login/totp gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an over-cap trust returns 409/trustLimitReached and the client prompts to revoke. - Self-service /auth/me/trusted-devices* + recovery-codes*; admin /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged. - Client: "Trust this device" + recovery-code login options, one-time recovery code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled revoke-to-continue cap modal, and admin per-user security controls. - OpenAPI regenerated; 33 new server tests (all suites green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
332 lines
15 KiB
JavaScript
332 lines
15 KiB
JavaScript
// ── Session service ────────────────────────────────────────────────────────
|
|
//
|
|
// The single seam every caller goes through to issue and validate a session.
|
|
// Today a "session" is a signed JWT (cookie for web, or a Bearer token), but
|
|
// callers only ever see the abstract Session object below — never the raw token
|
|
// shape. That indirection is what lets Part 2 (mobile bearer tokens) and Part 3
|
|
// (SSO) add new `authMethod`s without touching controllers or middleware.
|
|
//
|
|
// A Session object:
|
|
// {
|
|
// sessionId, // stable id for this session (JWT jti)
|
|
// userId, // the user's DB id
|
|
// username,
|
|
// role,
|
|
// authMethod, // 'local' | 'totp' | 'mobile' | 'sso'
|
|
// createdAt, // ms epoch the token was issued (JWT iat)
|
|
// expiresAt, // ms epoch the token expires (JWT exp), or null
|
|
// lastSeenAt, // ms epoch this session was last validated
|
|
// }
|
|
//
|
|
// Revocation for web/cookie sessions is backed by two stores: a per-session jti
|
|
// denylist (revoked_sessions — single logout) and a per-user cutoff
|
|
// (users.tokens_valid_after — password change / log out everywhere). requireAuth
|
|
// consults both. The functions here are the seam the controllers call.
|
|
|
|
const crypto = require('crypto')
|
|
|
|
const token = require('./token')
|
|
const revokedSessions = require('../model/revokedSessions/revokedSessions.model')
|
|
const trustedDevices = require('../model/trustedDevices/trustedDevices.model')
|
|
const users = require('../model/users/users.model')
|
|
const log = require('../utils/logger')('session')
|
|
|
|
// Valid authentication methods. 'local'/'totp' are the web flows; 'mobile' is the
|
|
// bearer flow (Part 2); 'google'/'discord'/'oidc' are SSO providers and 'sso' is
|
|
// the generic fallback label (Part 3). Sessions are tagged by how they were
|
|
// authenticated without changing this module per provider.
|
|
const AUTH_METHODS = ['local', 'totp', 'mobile', 'google', 'discord', 'oidc', 'sso']
|
|
|
|
// The claim that positively marks a token as a real, full session. Every JWT in
|
|
// the app is signed with the same secret and is distinguished only by claims, so
|
|
// a session must be identified by what it *is* (typ === 'session'), never by the
|
|
// mere absence of some other marker. Only the session-minting paths below stamp
|
|
// it; flow/challenge tokens (the TOTP challenge, the SSO transaction cookie) do
|
|
// not, so — even though they verify against the same secret — they can never be
|
|
// mistaken for a session. See issue #32 (sso_tx token-type confusion).
|
|
const SESSION_TYP = 'session'
|
|
|
|
// Build a Session object from a decoded JWT payload. Returns null for anything
|
|
// that is not a full session. Validation is positively typed: a token qualifies
|
|
// only if it was explicitly minted as a session. As belt-and-suspenders we also
|
|
// reject any token carrying a non-session marker (stage = TOTP challenge, kind =
|
|
// SSO transaction), so a future minting path that forgets to omit those still
|
|
// can't produce an accepted session.
|
|
function sessionFromDecoded(decoded, now = Date.now()) {
|
|
if (!decoded || decoded.typ !== SESSION_TYP) return null
|
|
if (decoded.stage || decoded.kind) return null
|
|
return {
|
|
sessionId: decoded.jti || null,
|
|
userId: decoded.id,
|
|
username: decoded.username,
|
|
role: decoded.role,
|
|
authMethod: decoded.authMethod || 'local',
|
|
createdAt: decoded.iat ? decoded.iat * 1000 : null,
|
|
expiresAt: decoded.exp ? decoded.exp * 1000 : null,
|
|
lastSeenAt: now,
|
|
}
|
|
}
|
|
|
|
// Issue a real session for a fully-authenticated user. Signs a JWT carrying the
|
|
// identity claims plus authMethod + a fresh session id (jti), and returns both
|
|
// the raw token (the caller sets the cookie or returns it as a bearer token)
|
|
// and the decoded Session object. Does NOT touch cookies or the DB — issuing the
|
|
// cookie and recording the login stay in the controller so its bot-scoring /
|
|
// backoff / activity-log orchestration is unchanged.
|
|
function createSession(user, authMethod = 'local') {
|
|
const method = AUTH_METHODS.includes(authMethod) ? authMethod : 'local'
|
|
const sessionId = crypto.randomUUID()
|
|
const raw = token.signToken(user, { authMethod: method, jti: sessionId, typ: SESSION_TYP })
|
|
const session = sessionFromDecoded(token.verifyToken(raw))
|
|
log.info('session created', { userId: user.id, username: user.username, authMethod: method, sessionId })
|
|
return { token: raw, session }
|
|
}
|
|
|
|
// Issue the short-lived "password verified, awaiting TOTP" challenge. This is
|
|
// deliberately NOT a session — validateSession rejects it — so a half-completed
|
|
// login can never be presented as a full one.
|
|
function createPartialSession(user) {
|
|
log.info('partial (TOTP) session issued', { userId: user.id, username: user.username })
|
|
return token.signTotpChallenge(user)
|
|
}
|
|
|
|
// Complete the TOTP step: verify the challenge token and return the decoded
|
|
// identity ({ id, stage }) so the caller can load the user and createSession().
|
|
// Returns null for an expired/invalid/non-challenge token.
|
|
function upgradeSessionAfterTotp(challengeToken) {
|
|
const decoded = token.verifyTotpChallenge(challengeToken)
|
|
if (!decoded) {
|
|
log.warn('TOTP challenge rejected (expired or invalid)')
|
|
return null
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
// Validate the session on an incoming request WITHOUT hitting the DB — pure
|
|
// token verification + identity decode. Returns a Session object or null.
|
|
// Stage-tagged tokens (the TOTP challenge) are explicitly not sessions.
|
|
// DB re-validation of the user is a middleware concern (requireAuth), kept
|
|
// separate so a demoted/deleted user still loses access on the next request.
|
|
function validateSession(req, now = Date.now()) {
|
|
const raw = token.extractToken(req)
|
|
if (!raw) return null
|
|
return sessionFromDecoded(token.verifyToken(raw), now)
|
|
}
|
|
|
|
// Decode a raw token string into a Session object (or null). Used where the
|
|
// token is already in hand rather than on a request.
|
|
function decodeIdentity(rawToken, now = Date.now()) {
|
|
if (!rawToken) return null
|
|
return sessionFromDecoded(token.verifyToken(rawToken), now)
|
|
}
|
|
|
|
// ── Mobile (bearer) sessions ───────────────────────────────────────────────
|
|
// Native clients get a short-lived JWT access token (validated on every request
|
|
// exactly like a cookie session) plus a long-lived opaque refresh token. The
|
|
// refresh token is random and never a JWT: it is stored server-side by hash and
|
|
// is the only revocable half, which is what makes mobile logout meaningful.
|
|
//
|
|
// These functions are intentionally pure — they mint and hash but do NOT touch
|
|
// the database. The controller persists the returned refreshHash via the
|
|
// mobileSessions model, keeping this module DB-free and unit-testable.
|
|
|
|
const MOBILE_ACCESS_TTL = process.env.MOBILE_ACCESS_TTL || '15m'
|
|
const MOBILE_REFRESH_TTL_DAYS = Number(process.env.MOBILE_REFRESH_TTL_DAYS) || 30
|
|
|
|
// Hash a raw refresh token to the value stored in the DB. Exported so the
|
|
// controller and model agree on the exact representation.
|
|
function hashRefreshToken(raw) {
|
|
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
|
}
|
|
|
|
// Mint a fresh access + refresh pair for a user. `now` is injectable for tests.
|
|
function mintMobileTokens(user, meta = {}, now = Date.now()) {
|
|
const sessionId = crypto.randomUUID()
|
|
const accessToken = token.signToken(
|
|
user,
|
|
{ authMethod: 'mobile', jti: sessionId, typ: SESSION_TYP },
|
|
{ expiresIn: MOBILE_ACCESS_TTL },
|
|
)
|
|
// 256 bits of entropy, url-safe. Opaque — carries no claims.
|
|
const refreshToken = crypto.randomBytes(32).toString('base64url')
|
|
const refreshExpiresAt = new Date(now + MOBILE_REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000)
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
refreshHash: hashRefreshToken(refreshToken),
|
|
refreshExpiresAt,
|
|
expiresIn: MOBILE_ACCESS_TTL,
|
|
deviceHash: meta.deviceHash || null,
|
|
userAgent: meta.userAgent || null,
|
|
session: sessionFromDecoded(token.verifyToken(accessToken), now),
|
|
}
|
|
}
|
|
|
|
// Issue a mobile session at login.
|
|
function createMobileSession(user, meta = {}, now = Date.now()) {
|
|
const out = mintMobileTokens(user, meta, now)
|
|
log.info('mobile session created', { userId: user.id, username: user.username, sessionId: out.session.sessionId })
|
|
return out
|
|
}
|
|
|
|
// Rotate a mobile session on refresh — same shape as createMobileSession. The
|
|
// caller is responsible for having validated + revoked the presented refresh
|
|
// token before calling this (rotation), and for persisting the new refreshHash.
|
|
function refreshMobileSession(user, meta = {}, now = Date.now()) {
|
|
const out = mintMobileTokens(user, meta, now)
|
|
log.info('mobile session refreshed', { userId: user.id, sessionId: out.session.sessionId })
|
|
return out
|
|
}
|
|
|
|
// Validate a raw bearer access token → Session object or null. Rejects
|
|
// stage-tagged tokens (a TOTP challenge is not a bearer session).
|
|
function validateBearerToken(rawToken, now = Date.now()) {
|
|
if (!rawToken) return null
|
|
return sessionFromDecoded(token.verifyToken(rawToken), now)
|
|
}
|
|
|
|
// Optional per-session metadata derived from the request. Attached to the
|
|
// session object by middleware for logging/auditing; NOT baked into the token
|
|
// (keeps tokens small and avoids trusting client-supplied device data as a claim).
|
|
function sessionMeta(req) {
|
|
const ip = req.ip || null
|
|
const userAgent = (req.headers && req.headers['user-agent']) || null
|
|
const deviceHash = crypto
|
|
.createHash('sha256')
|
|
.update(`${userAgent || ''}|${ip || ''}`)
|
|
.digest('hex')
|
|
.slice(0, 16)
|
|
return { ip, userAgent, deviceHash }
|
|
}
|
|
|
|
// ── Trusted devices (MFA "Trust this device") ──────────────────────────────
|
|
// A trusted device lets a login SKIP the TOTP step (never the password). The
|
|
// opaque trust token lives client-side (rg_trust cookie on web, X-Trust-Token /
|
|
// EncryptedSharedPreferences on native); only its sha256 hash is stored, so — like
|
|
// the mobile refresh token — the server side is revocable and never holds the raw
|
|
// secret. These functions mint/hash/resolve; the controller sets the cookie and
|
|
// the trustedDevices model persists the row. sha256 (not bcrypt): the token is a
|
|
// 256-bit random value looked up BY its hash via a UNIQUE index.
|
|
|
|
const TRUSTED_DEVICE_TTL_DAYS = Number(process.env.TRUSTED_DEVICE_TTL_DAYS) || 30
|
|
|
|
// Hash a raw trust token to the value stored in the DB. Separate name from
|
|
// hashRefreshToken so intent is explicit at call sites, though the algorithm is
|
|
// the same deterministic sha256.
|
|
function hashTrustToken(raw) {
|
|
return crypto.createHash('sha256').update(String(raw)).digest('hex')
|
|
}
|
|
|
|
// Mint a fresh opaque trust token + its hash + expiry. `meta` (from sessionMeta)
|
|
// supplies the best-effort device fingerprint stored for display. `now` injectable
|
|
// for tests. Does NOT touch cookies or the DB.
|
|
function mintTrustToken(meta = {}, now = Date.now()) {
|
|
const trustToken = crypto.randomBytes(32).toString('base64url') // 256 bits, opaque
|
|
const expiresAt = new Date(now + TRUSTED_DEVICE_TTL_DAYS * 24 * 60 * 60 * 1000)
|
|
return {
|
|
trustToken,
|
|
trustHash: hashTrustToken(trustToken),
|
|
deviceHash: meta.deviceHash || null,
|
|
userAgent: meta.userAgent || null,
|
|
expiresAt,
|
|
}
|
|
}
|
|
|
|
// Resolve the trust token on an incoming request to its still-valid DB row (or
|
|
// null). The caller MUST confirm row.user_id matches the user who just passed the
|
|
// password step before honoring it — a trust token is scoped to the account that
|
|
// created it. Never throws on a DB hiccup here; the caller falls back to TOTP.
|
|
async function resolveTrustedDevice(req) {
|
|
const raw = token.extractTrustToken(req)
|
|
if (!raw) return null
|
|
return trustedDevices.findValidByHash(hashTrustToken(raw))
|
|
}
|
|
|
|
// Stamp a trusted device as used (called when its trust was honored to skip TOTP).
|
|
async function honorTrustedDevice(id) {
|
|
if (!id) return false
|
|
await trustedDevices.touchLastUsed(id)
|
|
return true
|
|
}
|
|
|
|
// True if the user already holds the maximum number of trusted devices. Callers
|
|
// refuse a new trust (signaling the client to revoke one first) rather than
|
|
// pruning silently. See docs/website/TRUSTED_DEVICES_MFA.md §5.
|
|
async function trustDeviceCapReached(userId) {
|
|
return trustedDevices.isAtCap(userId)
|
|
}
|
|
|
|
// ── Revocation / invalidation ──────────────────────────────────────────────
|
|
// Web/cookie sessions are JWTs, so revocation is enforced by requireAuth reading
|
|
// two server-side stores these functions write:
|
|
// • revoked_sessions — a per-session jti denylist (single logout)
|
|
// • users.tokens_valid_after — a per-user cutoff (log out everywhere)
|
|
// A jti + its expiry (from the decoded token) are needed to denylist one session;
|
|
// invalidating all of a user's sessions only needs their id.
|
|
|
|
// Revoke a single session by its jti. Needs the token's expiry so the denylist
|
|
// row can self-prune once the JWT would fail verification anyway. Idempotent.
|
|
async function revokeSession(sessionId, { userId = null, expiresAt } = {}) {
|
|
if (!sessionId) {
|
|
log.warn('revokeSession called without a sessionId (jti) — nothing to revoke')
|
|
return false
|
|
}
|
|
// Fall back to the max JWT lifetime if the caller didn't pass the token's exp,
|
|
// so the denylist row still outlives any token carrying this jti.
|
|
const exp = expiresAt || Date.now() + token.cookieMaxAge()
|
|
await revokedSessions.revoke({ jti: sessionId, userId, expiresAt: exp })
|
|
log.info('session revoked', { sessionId, userId })
|
|
return true
|
|
}
|
|
|
|
// Alias kept for callers that speak of "invalidating" one session.
|
|
async function invalidateSession(sessionId, opts) {
|
|
return revokeSession(sessionId, opts)
|
|
}
|
|
|
|
// Has this session (jti) been individually revoked? Used by requireAuth on every
|
|
// authenticated request. Broad "valid after" cutoffs are checked separately by
|
|
// the middleware against the fresh user row it already loads.
|
|
async function isSessionRevoked(sessionId) {
|
|
if (!sessionId) return false
|
|
return revokedSessions.isRevoked(sessionId)
|
|
}
|
|
|
|
// Invalidate every session a user holds (password change / log out everywhere)
|
|
// by advancing their tokens_valid_after cutoff. Covers cookie sessions issued
|
|
// before now regardless of jti.
|
|
async function invalidateAllUserSessions(userId) {
|
|
if (!userId) {
|
|
log.warn('invalidateAllUserSessions called without a userId')
|
|
return false
|
|
}
|
|
await users.invalidateSessions(userId)
|
|
log.info('all user sessions invalidated', { userId })
|
|
return true
|
|
}
|
|
|
|
module.exports = {
|
|
AUTH_METHODS,
|
|
createSession,
|
|
createPartialSession,
|
|
upgradeSessionAfterTotp,
|
|
validateSession,
|
|
decodeIdentity,
|
|
sessionMeta,
|
|
revokeSession,
|
|
invalidateSession,
|
|
isSessionRevoked,
|
|
invalidateAllUserSessions,
|
|
// Mobile bearer sessions.
|
|
createMobileSession,
|
|
refreshMobileSession,
|
|
validateBearerToken,
|
|
hashRefreshToken,
|
|
// Trusted devices (MFA "Trust this device").
|
|
hashTrustToken,
|
|
mintTrustToken,
|
|
resolveTrustedDevice,
|
|
honorTrustedDevice,
|
|
trustDeviceCapReached,
|
|
}
|