feat(identity): add persistent Postgres-backed session store
Replaces the in-memory studentSessions/adminSessions Maps (Phase 2B — gone on every restart) with a sessions table. Browser holds an opaque CSPRNG bearer token; only its SHA-256 hash is ever stored server-side, same principle as password hashing. Each session has an absolute expiry (fixed at creation) and a sliding idle expiry (pushed forward on every authenticated request, capped at the absolute one) — one atomic UPDATE...RETURNING per resolve, no read-then-write race. Student and instructor sessions stay fully separate token namespaces, matching Phase 2B's cookie-name separation. cookies.js centralizes Set-Cookie construction so the Secure attribute (COOKIE_SECURE=1, for real HTTPS deployments) is applied consistently instead of hand-built per call site.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
-- sessions — replaces Phase 2B's in-memory studentSessions/adminSessions Maps. A Platform API
|
||||
-- restart no longer logs everyone out, and sessions are individually revocable/auditable.
|
||||
--
|
||||
-- Only a SHA-256 hash of the opaque bearer token is stored (token_hash) — never the raw token
|
||||
-- itself, so a database read (backup, dump, compromised replica) can't be turned into working
|
||||
-- session cookies.
|
||||
--
|
||||
-- expires_at is the absolute ceiling (set once, at creation). idle_expires_at slides forward on
|
||||
-- every authenticated request (see src/sessions.js resolve*Session), capped at expires_at —
|
||||
-- whichever of the two is hit first ends the session. session_type distinguishes student vs.
|
||||
-- instructor sessions; the CHECK below keeps a session row from ever being ambiguous about
|
||||
-- which kind it is, so a bug elsewhere can't accidentally treat one as the other.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_type TEXT NOT NULL CHECK (session_type IN ('student', 'instructor')),
|
||||
student_id TEXT REFERENCES students(student_id),
|
||||
instructor_id UUID REFERENCES instructors(instructor_id),
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
idle_expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
CHECK (
|
||||
(session_type = 'student' AND student_id IS NOT NULL AND instructor_id IS NULL) OR
|
||||
(session_type = 'instructor' AND instructor_id IS NOT NULL AND student_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_instructor ON sessions(instructor_id) WHERE instructor_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_student ON sessions(student_id) WHERE student_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at);
|
||||
@@ -0,0 +1,21 @@
|
||||
// Cookie construction, centralized so the Secure attribute is set consistently everywhere
|
||||
// instead of hand-built per call site. Secure is opt-in via COOKIE_SECURE=1 — off by default
|
||||
// because local dev and the current single-VM pilot deployment run over plain HTTP; anything
|
||||
// served over real HTTPS should set COOKIE_SECURE=1 (see docs/PHASE3A... "production HTTPS
|
||||
// expectations").
|
||||
|
||||
const SECURE = process.env.COOKIE_SECURE === '1';
|
||||
|
||||
function buildCookie(name, value, { maxAgeSec, httpOnly = true } = {}) {
|
||||
const parts = [`${name}=${value}`, 'Path=/', 'SameSite=Lax'];
|
||||
if (httpOnly) parts.push('HttpOnly');
|
||||
if (SECURE) parts.push('Secure');
|
||||
parts.push(`Max-Age=${maxAgeSec != null ? maxAgeSec : 0}`);
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function clearCookie(name) {
|
||||
return buildCookie(name, '', { maxAgeSec: 0 });
|
||||
}
|
||||
|
||||
module.exports = { buildCookie, clearCookie };
|
||||
+104
-52
@@ -1,67 +1,119 @@
|
||||
// Server-controlled session store — replaces Phase 2A's pilot ?sid= mechanism (client-supplied,
|
||||
// trivially forgeable) with opaque, server-issued tokens the browser can't fabricate or guess.
|
||||
// In-memory, single-process: fine for a pilot admin/classroom tool (see
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations" — a Platform API restart logs
|
||||
// everyone out, which is an acceptable tradeoff at this scale, not something silently ignored).
|
||||
// Persistent session store (Phase 3A) — replaces Phase 2B's in-memory studentSessions/
|
||||
// adminSessions Maps. Sessions live in Postgres (the `sessions` table), so a Platform API
|
||||
// restart no longer logs everyone out, and individual sessions can be revoked/audited.
|
||||
//
|
||||
// Two separate, non-overlapping session kinds (student / admin) so a student session can never
|
||||
// be mistaken for an admin one regardless of any bug elsewhere — they use different cookie
|
||||
// names AND different token namespaces.
|
||||
// The browser only ever holds an opaque, unguessable bearer token (32 random bytes,
|
||||
// base64url). The server stores only a SHA-256 hash of it (token_hash) — a database dump
|
||||
// can't be replayed as working session cookies, same principle as password hashing.
|
||||
//
|
||||
// Two independent lifecycles, matching Phase 2B's separation: student sessions and instructor
|
||||
// sessions never share a token namespace or a cookie name, so a bug can't confuse one for the
|
||||
// other. Each has an absolute expiry (expires_at, fixed at creation) and a sliding idle expiry
|
||||
// (idle_expires_at, pushed forward on every authenticated use, never past expires_at) —
|
||||
// whichever is hit first ends the session.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const STUDENT_TTL_MS = 8 * 60 * 60 * 1000; // 8h — a work/class session
|
||||
const ADMIN_TTL_MS = 4 * 60 * 60 * 1000; // 4h — shorter, this is a privileged session
|
||||
|
||||
const studentSessions = new Map(); // token -> { student_id, expiresAt }
|
||||
const adminSessions = new Map(); // token -> { expiresAt }
|
||||
const STUDENT_TTL_MS = Number(process.env.STUDENT_SESSION_TTL_HOURS || 8) * 60 * 60 * 1000;
|
||||
const STUDENT_IDLE_TTL_MS = Number(process.env.STUDENT_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
|
||||
const INSTRUCTOR_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_TTL_HOURS || 12) * 60 * 60 * 1000;
|
||||
const INSTRUCTOR_IDLE_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
|
||||
|
||||
function makeToken() {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function createStudentSession(studentId) {
|
||||
function hashToken(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
async function createSession(pool, { type, studentId, instructorId, ttlMs, idleTtlMs, metadata }) {
|
||||
const token = makeToken();
|
||||
studentSessions.set(token, { student_id: studentId, expiresAt: Date.now() + STUDENT_TTL_MS });
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveStudentSession(token) {
|
||||
if (!token) return null;
|
||||
const s = studentSessions.get(token);
|
||||
if (!s || s.expiresAt < Date.now()) { studentSessions.delete(token); return null; }
|
||||
return s.student_id;
|
||||
}
|
||||
|
||||
function destroyStudentSession(token) {
|
||||
studentSessions.delete(token);
|
||||
}
|
||||
|
||||
function createAdminSession() {
|
||||
const token = makeToken();
|
||||
adminSessions.set(token, { expiresAt: Date.now() + ADMIN_TTL_MS });
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveAdminSession(token) {
|
||||
if (!token) return false;
|
||||
const s = adminSessions.get(token);
|
||||
if (!s || s.expiresAt < Date.now()) { adminSessions.delete(token); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function destroyAdminSession(token) {
|
||||
adminSessions.delete(token);
|
||||
}
|
||||
|
||||
// Periodic sweep so long-idle sessions don't accumulate forever in memory.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, s] of studentSessions) if (s.expiresAt < now) studentSessions.delete(token);
|
||||
for (const [token, s] of adminSessions) if (s.expiresAt < now) adminSessions.delete(token);
|
||||
}, 10 * 60 * 1000).unref();
|
||||
const expiresAt = new Date(now + ttlMs);
|
||||
const idleExpiresAt = new Date(Math.min(now + idleTtlMs, expiresAt.getTime()));
|
||||
await pool.query(
|
||||
`INSERT INTO sessions (session_type, student_id, instructor_id, token_hash, expires_at, idle_expires_at, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[type, studentId || null, instructorId || null, hashToken(token), expiresAt, idleExpiresAt, metadata ? JSON.stringify(metadata) : null]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
// Validates + slides the idle window forward in one round trip. Returns null on any failure
|
||||
// (missing, expired, idle-timed-out, or revoked) without distinguishing why — the caller
|
||||
// doesn't need to know, and not distinguishing avoids leaking session state to a client.
|
||||
async function resolveSession(pool, token, type, idleTtlMs) {
|
||||
if (!token) return null;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE sessions
|
||||
SET last_seen_at = now(), idle_expires_at = LEAST(now() + $3::interval, expires_at)
|
||||
WHERE token_hash = $1 AND session_type = $2 AND revoked_at IS NULL
|
||||
AND expires_at > now() AND idle_expires_at > now()
|
||||
RETURNING student_id, instructor_id`,
|
||||
[hashToken(token), type, `${idleTtlMs} milliseconds`]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function revokeSession(pool, token) {
|
||||
if (!token) return;
|
||||
await pool.query(`UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, [hashToken(token)]);
|
||||
}
|
||||
|
||||
// ── Student sessions ─────────────────────────────────────────────────────────────────────
|
||||
function createStudentSession(pool, studentId) {
|
||||
return createSession(pool, { type: 'student', studentId, ttlMs: STUDENT_TTL_MS, idleTtlMs: STUDENT_IDLE_TTL_MS });
|
||||
}
|
||||
async function resolveStudentSession(pool, token) {
|
||||
const row = await resolveSession(pool, token, 'student', STUDENT_IDLE_TTL_MS);
|
||||
return row ? row.student_id : null;
|
||||
}
|
||||
const destroyStudentSession = revokeSession;
|
||||
|
||||
// ── Instructor sessions ──────────────────────────────────────────────────────────────────
|
||||
function createInstructorSession(pool, instructorId) {
|
||||
return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS });
|
||||
}
|
||||
async function resolveInstructorSession(pool, token) {
|
||||
const row = await resolveSession(pool, token, 'instructor', INSTRUCTOR_IDLE_TTL_MS);
|
||||
return row ? row.instructor_id : null;
|
||||
}
|
||||
const destroyInstructorSession = revokeSession;
|
||||
|
||||
// Revokes every live session for an instructor — used for the owner-facing "revoke all
|
||||
// sessions" control, and automatically after a password change (except the session that just
|
||||
// changed the password, so the user isn't immediately logged out of their own action).
|
||||
async function revokeAllInstructorSessions(pool, instructorId, exceptToken) {
|
||||
const exceptHash = exceptToken ? hashToken(exceptToken) : null;
|
||||
await pool.query(
|
||||
`UPDATE sessions SET revoked_at = now()
|
||||
WHERE instructor_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`,
|
||||
[instructorId, exceptHash]
|
||||
);
|
||||
}
|
||||
|
||||
async function listInstructorSessions(pool, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT session_id, created_at, last_seen_at, expires_at, revoked_at
|
||||
FROM sessions WHERE instructor_id = $1 ORDER BY created_at DESC LIMIT 20`,
|
||||
[instructorId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Periodic cleanup of long-dead rows so the table doesn't grow forever — cosmetic/storage only,
|
||||
// not a security control (expired/revoked rows already fail resolveSession's WHERE clause).
|
||||
function startSessionSweeper(pool) {
|
||||
setInterval(() => {
|
||||
pool.query(`DELETE FROM sessions WHERE expires_at < now() - interval '7 days'`).catch((e) => {
|
||||
console.error('[session sweep] failed:', e.message);
|
||||
});
|
||||
}, 60 * 60 * 1000).unref();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createStudentSession, resolveStudentSession, destroyStudentSession,
|
||||
createAdminSession, resolveAdminSession, destroyAdminSession,
|
||||
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
|
||||
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user