From 2f09b1a498702f4cfb1d79de88ff49e4f3e3795a Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 01:18:59 -0500 Subject: [PATCH] =?UTF-8?q?feat(student-auth):=20real=20student=20credenti?= =?UTF-8?q?als=20=E2=80=94=20email/login-ID=20+=20password,=20token-based?= =?UTF-8?q?=20activation=20and=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrations/017: email, login_identifier, password_hash, last_login_at, credential_token_* on students. Fully additive, both nullable/independent, no destructive change. - students-auth.js: shared credential/token lifecycle (issue/consume/find), reusing instructors' exact scrypt password.js verbatim. - student-auth-routes.js: /api/student/login, /activate, /change-password, /request-password-reset (anti-enumeration, dev-only token echo), /reset-password, /profile. - metrics.js: hand-rolled /metrics Prometheus exporter, login success/failure counters, no student/instructor ID ever used as a label value. - migrations/018: classroom_sessions/participants/events/announcements/help_requests, with a partial unique index enforcing at most one live classroom per cohort at the DB level. --- .../migrations/017_student_credentials.sql | 43 +++++ .../api/migrations/018_classroom_sessions.sql | 99 ++++++++++ platform/api/src/metrics.js | 70 +++++++ platform/api/src/student-auth-routes.js | 178 ++++++++++++++++++ platform/api/src/students-auth.js | 90 +++++++++ 5 files changed, 480 insertions(+) create mode 100644 platform/api/migrations/017_student_credentials.sql create mode 100644 platform/api/migrations/018_classroom_sessions.sql create mode 100644 platform/api/src/metrics.js create mode 100644 platform/api/src/student-auth-routes.js create mode 100644 platform/api/src/students-auth.js diff --git a/platform/api/migrations/017_student_credentials.sql b/platform/api/migrations/017_student_credentials.sql new file mode 100644 index 0000000..334c828 --- /dev/null +++ b/platform/api/migrations/017_student_credentials.sql @@ -0,0 +1,43 @@ +-- Real student accounts — Phase 5A §2/§3/§7, docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md. +-- +-- Additive only (check-before-alter, docs/ENGINEERING_SOP.md §6) — every existing pilot student +-- row stays a valid academic record with all columns NULL/default. A pilot student +-- (password_hash IS NULL) can still use the original bare student_id login +-- (POST /api/login, unchanged) until an instructor converts them; a converted/newly-created +-- student (password_hash IS NOT NULL) must use the new credentialed login +-- (POST /api/student/login) instead. See src/students-auth.js. +-- +-- email vs login_identifier (Phase 5A §3): RootNode serves minors who may not have an email +-- address. Both are nullable, independently unique — an older/self-paced student gets `email`, +-- a younger instructor-managed student gets `login_identifier` (any safe non-email string the +-- instructor assigns, e.g. a classroom username). Exactly one of the two is required once an +-- account is credentialed (enforced in application code, not a CHECK, so the exact rule can +-- evolve without a migration). Neither column names or implies a parent/guardian relationship — +-- that's explicitly future work (docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md), not built here; +-- the schema deliberately doesn't need to change to add it later (a guardian would reference a +-- student_id, not the other way around). + +ALTER TABLE students ADD COLUMN IF NOT EXISTS email TEXT; +ALTER TABLE students ADD COLUMN IF NOT EXISTS login_identifier TEXT; +ALTER TABLE students ADD COLUMN IF NOT EXISTS password_hash TEXT; +ALTER TABLE students ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; + +-- The activation/reset token is ONE mechanism serving two purposes (first-login activation and +-- forgot-password) — both are "a time-limited secure token that lets you set a new password," +-- functionally identical, so one column set instead of two parallel ones. Only the hash is +-- ever stored (crypto.createHash('sha256'), same pattern as sessions.js's token_hash) — the +-- plaintext token exists only in the instructor's API response / student's activation link, +-- never persisted, never logged. +ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_hash TEXT; +ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_purpose TEXT; +ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_expires_at TIMESTAMPTZ; + +DO $$ BEGIN + ALTER TABLE students ADD CONSTRAINT students_credential_token_purpose_check + CHECK (credential_token_purpose IS NULL OR credential_token_purpose IN ('activation', 'reset')); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_students_email ON students(email) WHERE email IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_students_login_identifier ON students(login_identifier) WHERE login_identifier IS NOT NULL; diff --git a/platform/api/migrations/018_classroom_sessions.sql b/platform/api/migrations/018_classroom_sessions.sql new file mode 100644 index 0000000..f98a232 --- /dev/null +++ b/platform/api/migrations/018_classroom_sessions.sql @@ -0,0 +1,99 @@ +-- Live classroom sessions — Phase 5A §11/§12. A classroom session is a distinct concept from a +-- cohort (a standing roster), an assessment session (one student's exam attempt), a Lab +-- environment (disposable infrastructure), or a login session (one browser's auth) — it +-- represents ONE live teaching event, e.g. "Week 3 — Kubernetes Workloads, Aug 29 6-8pm." +-- +-- Deliberately five tables, not one JSONB blob: classroom_sessions (the event itself), +-- classroom_participants (who's in it + presence), classroom_events (append-only audit trail), +-- classroom_announcements and help_requests get their own tables rather than being folded into +-- classroom_events, since both need efficient direct queries ("show me the open help queue", +-- "show me this classroom's announcements in order") that a generic event log would make +-- awkward. classroom_events stays the audit trail (docs/PHASE5A... §25) — it does NOT duplicate +-- announcement/help-request content, just records that the action happened. + +CREATE TABLE IF NOT EXISTS classroom_sessions ( + classroom_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id), + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'scheduled' + CHECK (status IN ('scheduled', 'live', 'ended', 'cancelled')), + scheduled_start TIMESTAMPTZ, + scheduled_end TIMESTAMPTZ, + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + created_by UUID REFERENCES instructors(instructor_id), + -- "Current activity" (§20) — a scenario id, not a stage id, matching how students actually + -- work (one scenario at a time). NULL means the instructor hasn't set one yet. This is + -- guidance only — never used to redirect a browser or auto-mark work complete (enforced in + -- application code: nothing reads this to gate access or grade anything). + current_scenario_id TEXT, + -- Optional external video-conferencing link (§23) — a plain URL, RootNode never validates + -- which provider it points at and never proxies/embeds it. + meeting_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_classroom_sessions_cohort ON classroom_sessions(cohort_id); +CREATE INDEX IF NOT EXISTS idx_classroom_sessions_status ON classroom_sessions(status); + +-- At most one LIVE classroom per cohort at a time — an instructor starting a second live +-- session for the same cohort while one is already live is almost certainly a mistake, not a +-- real use case (see classroom.js's startClassroom for the app-level check this backs). +CREATE UNIQUE INDEX IF NOT EXISTS idx_classroom_sessions_one_live_per_cohort + ON classroom_sessions(cohort_id) WHERE status = 'live'; + +CREATE TABLE IF NOT EXISTS classroom_participants ( + classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id), + student_id TEXT NOT NULL REFERENCES students(student_id), + joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Presence (§15) — updated by a throttled heartbeat while the student has this classroom + -- open, never by Lab terminal activity. online/recently-active/offline is DERIVED from this + -- at read time (see admin-classroom-routes.js), not stored as a separate status column, so + -- there's nothing to keep in sync or go stale on its own. + last_seen_at TIMESTAMPTZ, + left_at TIMESTAMPTZ, + PRIMARY KEY (classroom_session_id, student_id) +); + +CREATE INDEX IF NOT EXISTS idx_classroom_participants_student ON classroom_participants(student_id); + +CREATE TABLE IF NOT EXISTS classroom_events ( + event_id BIGSERIAL PRIMARY KEY, + classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id), + actor_type TEXT NOT NULL CHECK (actor_type IN ('instructor', 'student', 'system')), + actor_id TEXT, + event_type TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_classroom_events_session ON classroom_events(classroom_session_id, created_at); + +CREATE TABLE IF NOT EXISTS classroom_announcements ( + announcement_id BIGSERIAL PRIMARY KEY, + classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id), + instructor_id UUID REFERENCES instructors(instructor_id), + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_classroom_announcements_session ON classroom_announcements(classroom_session_id, created_at); + +CREATE TABLE IF NOT EXISTS help_requests ( + help_request_id BIGSERIAL PRIMARY KEY, + classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id), + student_id TEXT NOT NULL REFERENCES students(student_id), + category TEXT, -- free text ('lab_problem' | 'dont_understand' | 'validation_failing' | 'other' from the frontend, not a DB-enforced enum — see §18's example list) + message TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + acknowledged_at TIMESTAMPTZ, + acknowledged_by UUID REFERENCES instructors(instructor_id), + resolved_at TIMESTAMPTZ, + instructor_note TEXT +); + +CREATE INDEX IF NOT EXISTS idx_help_requests_session_status ON help_requests(classroom_session_id, status); +CREATE INDEX IF NOT EXISTS idx_help_requests_student ON help_requests(student_id); diff --git a/platform/api/src/metrics.js b/platform/api/src/metrics.js new file mode 100644 index 0000000..8cc05ce --- /dev/null +++ b/platform/api/src/metrics.js @@ -0,0 +1,70 @@ +// Prometheus text-exposition metrics — Phase 5A §34. Hand-rolled, not prom-client: the metric +// set here is small (a handful of counters/gauges), and the exposition format is trivial text +// (`# HELP`, `# TYPE`, `name value`) — not worth a new dependency for this (ponytail: +// stdlib/hand-rolled first). Deliberately NO student/instructor id (or any other +// high-cardinality value) ever appears as a label — every metric here is an aggregate count, +// matching the explicit "avoid high-cardinality student IDs... do not expose PII" requirement. +// +// Counters (login success/failure) are real in-process Prometheus counters — incremented at +// the exact point the event happens (student-auth-routes.js), not derived from a windowed SQL +// COUNT(*), which would behave like a gauge with a rolling window rather than a real counter. +// They reset on process restart, which is the normal, expected way Prometheus counters behave +// (rate()/increase() handle a counter reset transparently) — no persistence needed. + +let studentLoginSuccessTotal = 0; +let studentLoginFailureTotal = 0; + +function recordStudentLoginSuccess() { studentLoginSuccessTotal++; } +function recordStudentLoginFailure() { studentLoginFailureTotal++; } + +// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a +// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to +// keep in sync with reality). +async function collectGauges(pool, provisioner) { + const [{ rows: liveClassrooms }, { rows: helpOpen }, envRes] = await Promise.all([ + pool.query(`SELECT classroom_session_id FROM classroom_sessions WHERE status = 'live'`), + pool.query(`SELECT count(*)::int AS n FROM help_requests WHERE status = 'open'`), + // Reuses the exact same environments-by-status query the admin dashboard already runs + // (admin-routes.js's /api/admin/dashboard) — one source of truth for Lab state counts. + pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`).catch(() => ({ rows: [] })), + ]); + + let onlineParticipants = 0; + if (liveClassrooms.length) { + const classroom = require('./classroom'); + for (const c of liveClassrooms) { + const participants = await classroom.listParticipants(pool, c.classroom_session_id); + onlineParticipants += participants.filter((p) => p.presence === 'online').length; + } + } + + const envCounts = Object.fromEntries(envRes.rows.map((r) => [r.status, r.n])); + return { + classroom_sessions_live: liveClassrooms.length, + classroom_participants_online: onlineParticipants, + help_requests_open: helpOpen[0].n, + labs_running: envCounts.running || 0, + labs_provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0), + labs_error: envCounts.error || 0, + }; +} + +function line(name, help, type, value) { + return `# HELP ${name} ${help}\n# TYPE ${name} ${type}\n${name} ${value}\n`; +} + +async function renderMetrics(pool, provisioner) { + const gauges = await collectGauges(pool, provisioner); + return [ + line('rootnode_classroom_sessions_live', 'Number of classroom sessions currently live.', 'gauge', gauges.classroom_sessions_live), + line('rootnode_classroom_participants_online', 'Number of classroom participants currently online across all live sessions.', 'gauge', gauges.classroom_participants_online), + line('rootnode_help_requests_open', 'Number of open (not yet acknowledged/resolved) help requests.', 'gauge', gauges.help_requests_open), + line('rootnode_labs_running', 'Number of student Lab environments currently running.', 'gauge', gauges.labs_running), + line('rootnode_labs_provisioning', 'Number of student Lab environments currently being created/reset.', 'gauge', gauges.labs_provisioning), + line('rootnode_labs_error', 'Number of student Lab environments in an error state.', 'gauge', gauges.labs_error), + line('rootnode_student_login_success_total', 'Total successful student logins since process start.', 'counter', studentLoginSuccessTotal), + line('rootnode_student_login_failure_total', 'Total failed student login attempts since process start.', 'counter', studentLoginFailureTotal), + ].join('\n'); +} + +module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, renderMetrics }; diff --git a/platform/api/src/student-auth-routes.js b/platform/api/src/student-auth-routes.js new file mode 100644 index 0000000..10e1eae --- /dev/null +++ b/platform/api/src/student-auth-routes.js @@ -0,0 +1,178 @@ +// POST /api/student/login, /activate, /change-password, /request-password-reset, +// /reset-password, GET /api/student/profile — Phase 5A §2/§5/§6/§9/§10. Logout reuses the +// existing POST /api/logout (session-mechanism-agnostic already, no change needed there). +// +// A pilot student (password_hash IS NULL) keeps using the original POST /api/login +// (bare student_id, unchanged) until an instructor converts them — see +// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7. This file is exclusively the NEW +// credentialed path. + +const { + verifyPassword, hashPassword, publicStudent, getStudentByIdentifier, getActiveStudent, + issueCredentialToken, findStudentByCredentialToken, consumeCredentialToken, +} = require('./students-auth'); +const { recordAudit } = require('./audit'); +const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security'); +const { buildCookie, clearCookie } = require('./cookies'); +const { recordStudentLoginSuccess, recordStudentLoginFailure } = require('./metrics'); // Phase 5A §34 + +const STUDENT_COOKIE = 'rn_session'; +const STUDENT_CSRF_COOKIE = 'rn_csrf_s'; +const LOGIN_MAX_ATTEMPTS = 8; +const LOGIN_WINDOW_MS = 5 * 60 * 1000; +const SESSION_MAX_AGE_SEC = 8 * 3600; + +module.exports = function registerStudentAuthRoutes(app, { pool, sessions, studentSession, curriculum, loadProgress, isProduction }) { + app.post('/api/student/login', async (req, res) => { + const { identifier, password } = req.body || {}; + const rateKey = `student:${req.ip}:${String(identifier || '').toLowerCase()}`; + if (await tooManyAttempts(pool, rateKey, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) { + return res.status(429).json({ error: 'too_many_attempts', message: 'Too many login attempts. Try again in a few minutes.' }); + } + if (!identifier || !password) return res.status(400).json({ error: 'identifier and password are required' }); + + const student = await getStudentByIdentifier(pool, identifier); + // Same generic response whether the identifier doesn't exist, has no credentials yet + // (pilot/unconverted), or the password is wrong — no account enumeration either way. + const passwordOk = student && student.password_hash && verifyPassword(password, student.password_hash); + if (!passwordOk) { + recordStudentLoginFailure(); + await recordAudit(pool, { + actorType: 'student', actorId: student?.student_id, action: 'login', result: 'failure', sourceIp: req.ip, + }); + return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid login or password.' }); + } + if (student.status !== 'active') { + recordStudentLoginFailure(); + await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'login', result: 'failure', sourceIp: req.ip, metadata: { reason: 'inactive' } }); + return res.status(403).json({ error: 'account_inactive', message: 'This account has been deactivated.' }); + } + + const token = await sessions.createStudentSession(pool, student.student_id); + const csrf = makeCsrfToken(); + await pool.query(`UPDATE students SET last_login_at = now() WHERE student_id = $1`, [student.student_id]); + recordStudentLoginSuccess(); + await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'login', result: 'success', sourceIp: req.ip }); + + res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: SESSION_MAX_AGE_SEC })); + res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: SESSION_MAX_AGE_SEC, httpOnly: false })); + res.json({ student: publicStudent({ ...student, last_login_at: new Date() }) }); + }); + + // Consumes an activation token issued by GET/POST /api/admin/students (creation) or + // /api/admin/students/:id/convert — sets the student's first password and logs them straight + // in, same as any other login (§5's "temporary credential becomes invalid" is the token being + // single-use — consumeCredentialToken clears it atomically with setting the password). + app.post('/api/student/activate', async (req, res) => { + const { token, password } = req.body || {}; + if (!token || !password || password.length < 10) { + return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' }); + } + const student = await consumeCredentialToken(pool, token, 'activation', password); + if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' }); + + const sessionToken = await sessions.createStudentSession(pool, student.student_id); + const csrf = makeCsrfToken(); + await pool.query(`UPDATE students SET last_login_at = now() WHERE student_id = $1`, [student.student_id]); + await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'account_activated', result: 'success', sourceIp: req.ip }); + + res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, sessionToken, { maxAgeSec: SESSION_MAX_AGE_SEC })); + res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: SESSION_MAX_AGE_SEC, httpOnly: false })); + res.json({ student: publicStudent(student) }); + }); + + // Lets a not-yet-logged-in student preview whose activation/reset link this is before they + // submit a new password (e.g. "Activating account for Alice") — never reveals anything if + // the token is invalid/expired/wrong-purpose, same shape either way. + app.get('/api/student/credential-token/:purpose', async (req, res) => { + const { purpose } = req.params; + if (!['activation', 'reset'].includes(purpose)) return res.status(400).json({ error: 'invalid_purpose' }); + const student = await findStudentByCredentialToken(pool, req.query.token, purpose); + if (!student) return res.status(404).json({ error: 'invalid_or_expired_token' }); + res.json({ display_name: student.display_name }); + }); + + app.post('/api/student/change-password', studentSession, async (req, res) => { + const { old_password, new_password } = req.body || {}; + if (!old_password || !new_password || new_password.length < 10) { + return res.status(400).json({ error: 'invalid_request', message: 'new_password must be at least 10 characters' }); + } + const student = await getActiveStudent(pool, req.studentId); + if (!student || !student.password_hash || !verifyPassword(old_password, student.password_hash)) { + await recordAudit(pool, { actorType: 'student', actorId: req.studentId, action: 'password_change', result: 'failure', sourceIp: req.ip }); + return res.status(401).json({ error: 'invalid_credentials', message: 'Current password is incorrect.' }); + } + const newHash = hashPassword(new_password); + await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]); + await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken); + await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip }); + res.json({ ok: true }); + }); + + // §9/§6 — self-service "forgot password." No email delivery infrastructure exists yet + // (explicitly not required this phase), so this deliberately does NOT invent fake email + // sending and does NOT hand the reset token back to whoever asked (that would let anyone + // "reset" any account just by guessing an identifier — a real vulnerability, not a shortcut). + // Always returns the same generic response regardless of whether the identifier exists + // (anti-enumeration). In non-production, the real token is included so local/test flows can + // complete the loop without pretending email exists; in production it is never returned by + // this endpoint — completing a real self-service reset needs email delivery, a documented, + // not-yet-built follow-up (see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5). The + // realistic path today is instructor-triggered reset (POST /api/admin/students/:id/reset-password). + app.post('/api/student/request-password-reset', async (req, res) => { + const { identifier } = req.body || {}; + const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' }; + if (!identifier) return res.json(generic); + const student = await getStudentByIdentifier(pool, identifier); + if (!student || student.status !== 'active') return res.json(generic); + const { token } = await issueCredentialToken(pool, student.student_id, 'reset'); + await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip }); + if (!isProduction) return res.json({ ...generic, dev_reset_token: token }); + res.json(generic); + }); + + app.post('/api/student/reset-password', async (req, res) => { + const { token, password } = req.body || {}; + if (!token || !password || password.length < 10) { + return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' }); + } + const student = await consumeCredentialToken(pool, token, 'reset', password); + if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' }); + await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access" + await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip }); + res.json({ ok: true }); + }); + + // §10 — minimal profile page data: identity, cohort(s), progress summary, achievements, + // active session count. Reuses the same loadProgress/curriculum modules the existing + // /api/progress and /api/curriculum/path routes use — no parallel progress-reading logic. + app.get('/api/student/profile', studentSession, async (req, res) => { + const student = await getActiveStudent(pool, req.studentId); + if (!student) return res.status(401).json({ error: 'no_session' }); + const [{ rows: cohorts }, progress, { rows: achievements }, sessionsList] = await Promise.all([ + pool.query( + `SELECT c.cohort_id, c.name FROM cohort_enrollments e JOIN cohorts c ON c.cohort_id = e.cohort_id + WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled'`, + [student.student_id] + ), + loadProgress(pool, student.student_id), + pool.query( + `SELECT achievement_type, stage_id, achieved_at FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`, + [student.student_id] + ), + sessions.listStudentSessions(pool, student.student_id), + ]); + const scenarios = curriculum.loadScenarios(); + const completed = Object.values(progress).filter((p) => p.status === 'completed').length; + res.json({ + student: publicStudent(student), + cohorts, + progress_summary: { completed, total: scenarios.length }, + achievements, + active_sessions: sessionsList.filter((s) => !s.revoked_at && new Date(s.expires_at) > new Date()).length, + }); + }); +}; + +module.exports.STUDENT_COOKIE = STUDENT_COOKIE; +module.exports.STUDENT_CSRF_COOKIE = STUDENT_CSRF_COOKIE; diff --git a/platform/api/src/students-auth.js b/platform/api/src/students-auth.js new file mode 100644 index 0000000..b16ec07 --- /dev/null +++ b/platform/api/src/students-auth.js @@ -0,0 +1,90 @@ +// Student credential/account lifecycle — Phase 5A §2/§4/§5/§7. Parallel to instructors.js +// (same scrypt hashing from ./auth/password.js, same "row minus password_hash/token_hash" +// public-view pattern), not a duplicate auth system: student and instructor accounts share the +// exact same session store (sessions.js), CSRF pattern, and rate limiter — only the identity +// table and the specific login identifier (email/login_identifier vs. instructor email) differ. + +const crypto = require('crypto'); +const { hashPassword, verifyPassword } = require('./auth/password'); + +const CREDENTIAL_TOKEN_BYTES = 32; +const ACTIVATION_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; // a week — an instructor may hand this to a student who isn't logging in same-day +const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // an hour — shorter, since a reset implies "I have access right now" + +function hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +// Never returns password_hash or credential_token_hash — the one and only place a student row +// is shaped for an API response. Every route below returns THIS, never the raw row. +function publicStudent(row) { + if (!row) return null; + const { password_hash, credential_token_hash, ...rest } = row; + return { ...rest, has_credentials: !!password_hash }; +} + +async function getActiveStudent(pool, studentId) { + const { rows } = await pool.query(`SELECT * FROM students WHERE student_id = $1 AND status = 'active'`, [studentId]); + return rows[0] || null; +} + +// Case-insensitive on email (matches instructors.js's convention); login_identifier is matched +// exactly as stored — it's instructor-assigned, not self-chosen, so there's no "did they mean +// to capitalize it differently" ambiguity to normalize away. +async function getStudentByIdentifier(pool, identifier) { + const id = String(identifier || '').trim(); + if (!id) return null; + const { rows } = await pool.query( + `SELECT * FROM students WHERE lower(email) = lower($1) OR login_identifier = $1`, + [id] + ); + return rows[0] || null; +} + +// Generates a credential token (activation or reset), returns the PLAINTEXT once — callers +// must hand it to the instructor/student and never persist or log it themselves. Only the hash +// goes to the database. Overwrites any previous outstanding token for this student (a new +// activation/reset link invalidates an older unused one — only the latest is ever valid). +async function issueCredentialToken(pool, studentId, purpose) { + const token = crypto.randomBytes(CREDENTIAL_TOKEN_BYTES).toString('base64url'); + const ttlMs = purpose === 'reset' ? RESET_TOKEN_TTL_MS : ACTIVATION_TOKEN_TTL_MS; + const expiresAt = new Date(Date.now() + ttlMs); + await pool.query( + `UPDATE students SET credential_token_hash = $1, credential_token_purpose = $2, credential_token_expires_at = $3 + WHERE student_id = $4`, + [hashToken(token), purpose, expiresAt, studentId] + ); + return { token, expiresAt }; +} + +// Looks up the student a token belongs to, WITHOUT consuming it — callers that need to show +// "who is this token for" before the student submits a new password (e.g. to greet them by +// name) use this; consumeCredentialToken is the one that actually spends it. +async function findStudentByCredentialToken(pool, token, purpose) { + if (!token) return null; + const { rows } = await pool.query( + `SELECT * FROM students WHERE credential_token_hash = $1 AND credential_token_purpose = $2 + AND credential_token_expires_at > now()`, + [hashToken(token), purpose] + ); + return rows[0] || null; +} + +// Sets a new password and clears the token in one statement — a token can only ever be used +// once, whether it succeeds or the caller retries with a stale value afterward. +async function consumeCredentialToken(pool, token, purpose, newPassword) { + const passwordHash = hashPassword(newPassword); + const { rows } = await pool.query( + `UPDATE students SET password_hash = $1, credential_token_hash = NULL, credential_token_purpose = NULL, + credential_token_expires_at = NULL, updated_at = now() + WHERE credential_token_hash = $2 AND credential_token_purpose = $3 AND credential_token_expires_at > now() + RETURNING *`, + [passwordHash, hashToken(token), purpose] + ); + return rows[0] || null; +} + +module.exports = { + hashPassword, verifyPassword, publicStudent, getActiveStudent, getStudentByIdentifier, + issueCredentialToken, findStudentByCredentialToken, consumeCredentialToken, +};