feat(student-auth): real student credentials — email/login-ID + password, token-based activation and reset

- 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.
This commit is contained in:
2026-08-25 01:18:59 -05:00
parent 5bc0743b08
commit 2f09b1a498
5 changed files with 480 additions and 0 deletions
+70
View File
@@ -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 };
+178
View File
@@ -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;
+90
View File
@@ -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,
};