diff --git a/platform/api/migrations/009_sessions.sql b/platform/api/migrations/009_sessions.sql new file mode 100644 index 0000000..2c9c2e7 --- /dev/null +++ b/platform/api/migrations/009_sessions.sql @@ -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); diff --git a/platform/api/src/cookies.js b/platform/api/src/cookies.js new file mode 100644 index 0000000..6cd5ad7 --- /dev/null +++ b/platform/api/src/cookies.js @@ -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 }; diff --git a/platform/api/src/sessions.js b/platform/api/src/sessions.js index f830225..0eeac60 100644 --- a/platform/api/src/sessions.js +++ b/platform/api/src/sessions.js @@ -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, };