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,
|
// Persistent session store (Phase 3A) — replaces Phase 2B's in-memory studentSessions/
|
||||||
// trivially forgeable) with opaque, server-issued tokens the browser can't fabricate or guess.
|
// adminSessions Maps. Sessions live in Postgres (the `sessions` table), so a Platform API
|
||||||
// In-memory, single-process: fine for a pilot admin/classroom tool (see
|
// restart no longer logs everyone out, and individual sessions can be revoked/audited.
|
||||||
// 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).
|
|
||||||
//
|
//
|
||||||
// Two separate, non-overlapping session kinds (student / admin) so a student session can never
|
// The browser only ever holds an opaque, unguessable bearer token (32 random bytes,
|
||||||
// be mistaken for an admin one regardless of any bug elsewhere — they use different cookie
|
// base64url). The server stores only a SHA-256 hash of it (token_hash) — a database dump
|
||||||
// names AND different token namespaces.
|
// 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 crypto = require('crypto');
|
||||||
|
|
||||||
const STUDENT_TTL_MS = 8 * 60 * 60 * 1000; // 8h — a work/class session
|
const STUDENT_TTL_MS = Number(process.env.STUDENT_SESSION_TTL_HOURS || 8) * 60 * 60 * 1000;
|
||||||
const ADMIN_TTL_MS = 4 * 60 * 60 * 1000; // 4h — shorter, this is a privileged session
|
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 studentSessions = new Map(); // token -> { student_id, expiresAt }
|
const INSTRUCTOR_IDLE_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
|
||||||
const adminSessions = new Map(); // token -> { expiresAt }
|
|
||||||
|
|
||||||
function makeToken() {
|
function makeToken() {
|
||||||
return crypto.randomBytes(32).toString('base64url');
|
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();
|
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();
|
const now = Date.now();
|
||||||
for (const [token, s] of studentSessions) if (s.expiresAt < now) studentSessions.delete(token);
|
const expiresAt = new Date(now + ttlMs);
|
||||||
for (const [token, s] of adminSessions) if (s.expiresAt < now) adminSessions.delete(token);
|
const idleExpiresAt = new Date(Math.min(now + idleTtlMs, expiresAt.getTime()));
|
||||||
}, 10 * 60 * 1000).unref();
|
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 = {
|
module.exports = {
|
||||||
createStudentSession, resolveStudentSession, destroyStudentSession,
|
createStudentSession, resolveStudentSession, destroyStudentSession,
|
||||||
createAdminSession, resolveAdminSession, destroyAdminSession,
|
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
|
||||||
|
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user