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:
@@ -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;
|
||||
Reference in New Issue
Block a user