Replaces the in-memory sliding-window Map (reset on every restart, not shareable across instances) with a login_attempts table (migration 016). No Redis added - login is not a hot path, and this is a single indexed range-scan query. Restart behavior confirmed for real: platform/test/e2e-phase3a.js's rate-limiting test passes after a genuine Platform API restart in the same run.
103 lines
6.1 KiB
JavaScript
103 lines
6.1 KiB
JavaScript
// POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session,
|
|
// POST /api/auth/change-password — replaces Phase 2B's single shared ADMIN_PASSWORD
|
|
// (/api/admin/login) with real per-instructor accounts. See
|
|
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
|
|
|
|
const { verifyPassword, hashPassword } = require('./auth/password');
|
|
const { getInstructorByEmail, publicInstructor } = require('./instructors');
|
|
const { recordAudit } = require('./audit');
|
|
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
|
|
const { buildCookie, clearCookie } = require('./cookies');
|
|
|
|
const INSTRUCTOR_COOKIE = 'rn_instructor';
|
|
const CSRF_COOKIE = 'rn_csrf_i';
|
|
const LOGIN_MAX_ATTEMPTS = 8;
|
|
const LOGIN_WINDOW_MS = 5 * 60 * 1000; // 5 min
|
|
|
|
module.exports = function registerInstructorAuthRoutes(app, { pool, sessions, instructorSession }) {
|
|
app.post('/api/auth/instructor/login', async (req, res) => {
|
|
const { email, password } = req.body || {};
|
|
const rateKey = `${req.ip}:${String(email || '').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 (!email || !password) return res.status(400).json({ error: 'email and password are required' });
|
|
|
|
const instructor = await getInstructorByEmail(pool, email);
|
|
// Same generic response whether the email doesn't exist or the password is wrong — no
|
|
// account enumeration. Only once the password is ALSO correct do we distinguish
|
|
// "deactivated," which doesn't help an attacker who doesn't already know the password.
|
|
const passwordOk = instructor && verifyPassword(password, instructor.password_hash);
|
|
if (!passwordOk) {
|
|
await recordAudit(pool, {
|
|
actorType: 'instructor', actorId: instructor?.instructor_id, action: 'login',
|
|
result: 'failure', sourceIp: req.ip, metadata: { email: String(email).toLowerCase() },
|
|
});
|
|
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password.' });
|
|
}
|
|
if (instructor.status !== 'active') {
|
|
await recordAudit(pool, {
|
|
actorType: 'instructor', actorId: instructor.instructor_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.createInstructorSession(pool, instructor.instructor_id);
|
|
const csrf = makeCsrfToken();
|
|
await pool.query(`UPDATE instructors SET last_login_at = now() WHERE instructor_id = $1`, [instructor.instructor_id]);
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'login', result: 'success', sourceIp: req.ip });
|
|
|
|
res.append('Set-Cookie', buildCookie(INSTRUCTOR_COOKIE, token, { maxAgeSec: 12 * 3600 }));
|
|
res.append('Set-Cookie', buildCookie(CSRF_COOKIE, csrf, { maxAgeSec: 12 * 3600, httpOnly: false }));
|
|
res.json({ instructor: publicInstructor({ ...instructor, last_login_at: new Date() }) });
|
|
});
|
|
|
|
app.post('/api/auth/logout', async (req, res) => {
|
|
const token = parseCookies(req)[INSTRUCTOR_COOKIE];
|
|
// CSRF is still checked here even though logout is low-severity to forge (the worst case is
|
|
// an annoying forced sign-out, not data exposure) — consistent enforcement is simpler to
|
|
// reason about than a documented exception per route.
|
|
if (!checkCsrf(req, CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
|
|
// Resolve directly rather than requiring the strict instructorSession middleware — logout
|
|
// must still succeed (and clear cookies) even against an already-expired/invalid session.
|
|
const instructorId = await sessions.resolveInstructorSession(pool, token);
|
|
if (instructorId) {
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: instructorId, action: 'logout', result: 'success', sourceIp: req.ip });
|
|
}
|
|
await sessions.destroyInstructorSession(pool, token);
|
|
res.append('Set-Cookie', clearCookie(INSTRUCTOR_COOKIE));
|
|
res.append('Set-Cookie', clearCookie(CSRF_COOKIE));
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// GET /api/auth/session — "whoami" for the instructor portal.
|
|
app.get('/api/auth/session', instructorSession, (req, res) => {
|
|
res.json({ instructor: publicInstructor(req.instructor) });
|
|
});
|
|
|
|
app.post('/api/auth/change-password', instructorSession, async (req, res) => {
|
|
// CSRF already checked by instructorSession (server.js) for every non-GET method.
|
|
const { old_password, new_password } = req.body || {};
|
|
if (!old_password || !new_password || new_password.length < 12) {
|
|
return res.status(400).json({ error: 'new_password must be at least 12 characters' });
|
|
}
|
|
const instructor = req.instructor; // full row (studentSession-style middleware already fetched it)
|
|
if (!verifyPassword(old_password, instructor.password_hash)) {
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, 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 instructors SET password_hash = $1, updated_at = now() WHERE instructor_id = $2`, [newHash, instructor.instructor_id]);
|
|
// Kill any other lingering sessions for this account — a password change should invalidate
|
|
// access anywhere else that was logged in, but not the session that just made the change.
|
|
const currentToken = parseCookies(req)[INSTRUCTOR_COOKIE];
|
|
await sessions.revokeAllInstructorSessions(pool, instructor.instructor_id, currentToken);
|
|
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'password_change', result: 'success', sourceIp: req.ip });
|
|
res.json({ ok: true });
|
|
});
|
|
};
|
|
|
|
module.exports.INSTRUCTOR_COOKIE = INSTRUCTOR_COOKIE;
|
|
module.exports.CSRF_COOKIE = CSRF_COOKIE;
|