// 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, recordStudentActivation } = require('./metrics'); // Phase 5A §34, Phase 5C const notifications = require('./notifications'); 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) { recordStudentActivation('invalid_or_expired_token'); 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.' }); } recordStudentActivation('success'); 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); // Phase 5C §5D — a security-notice email, no token, purely informational. await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } }); await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip }); res.json({ ok: true }); }); // §9/§6/Phase 5C §8 — self-service "forgot password." Anti-enumeration is unchanged: the // SAME generic response returns regardless of whether the identifier exists, is inactive, or // is a genuine hit — the only difference a caller can ever observe is whether an email // eventually arrives, never anything in this response. Production NEVER returns the token // (isProduction, same COOKIE_SECURE-derived signal Phase 5A already established — see // server.js); the dev-only echo exists purely so a local/CI run can complete the loop without // a real inbox, exactly as before. What Phase 5C actually closes: the token is now also // handed to notifications.enqueue() so a real email goes out — see // docs/PHASE5C_ENROLLMENT_COMMUNICATIONS.md §8 for why the token is minted here // (synchronously, as before) rather than by the worker. 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' || !student.password_hash) return res.json(generic); const { token } = await issueCredentialToken(pool, student.student_id, 'reset'); await notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_reset', plaintextToken: token, metadata: { display_name: student.display_name } }); 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 notifications.enqueue(pool, { studentId: student.student_id, notificationType: 'password_changed', metadata: { display_name: student.display_name } }); 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;