diff --git a/platform/api/bootstrap-owner.js b/platform/api/bootstrap-owner.js new file mode 100644 index 0000000..2db97b4 --- /dev/null +++ b/platform/api/bootstrap-owner.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// One-time owner bootstrap — the only supported way to create the first instructor account, +// so standing up a fresh Platform API never requires editing Postgres by hand. +// +// node bootstrap-owner.js you@example.com "Your Name" [password] +// +// Prefer BOOTSTRAP_OWNER_PASSWORD over the CLI argument in anything other than a throwaway +// local test — CLI args can leak into shell history and `ps`. +// +// Refuses to run if an active owner already exists, UNLESS the target email belongs to an +// existing instructor — in that case it promotes them to owner (the intended recovery path if +// a platform is ever locked out with zero owners: point this at a known instructor account). + +const { getPool, runMigrations } = require('./src/db'); +const { hashPassword } = require('./src/auth/password'); + +async function main() { + const [, , email, displayName, cliPassword] = process.argv; + const password = cliPassword || process.env.BOOTSTRAP_OWNER_PASSWORD; + if (!email || !displayName || !password) { + console.error('Usage: node bootstrap-owner.js [password]'); + console.error(' (or set BOOTSTRAP_OWNER_PASSWORD instead of passing the password)'); + process.exit(1); + } + if (password.length < 12) { + console.error('Password must be at least 12 characters.'); + process.exit(1); + } + + await runMigrations(); + const pool = getPool(); + const emailLower = email.toLowerCase().trim(); + + const { rows: existing } = await pool.query(`SELECT instructor_id, role, status FROM instructors WHERE email = $1`, [emailLower]); + const { rows: ownerCount } = await pool.query(`SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active'`); + + if (ownerCount[0].n > 0 && !existing[0]) { + console.error(`Refusing: ${ownerCount[0].n} active owner(s) already exist and ${emailLower} isn't one of your instructors.`); + console.error('Use the admin API (as an existing owner) to add instructors instead.'); + process.exit(1); + } + + const passwordHash = hashPassword(password); + let instructorId; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + if (existing[0]) { + await client.query( + `UPDATE instructors SET password_hash = $1, display_name = $2, role = 'owner', status = 'active', updated_at = now() WHERE instructor_id = $3`, + [passwordHash, displayName.trim(), existing[0].instructor_id] + ); + instructorId = existing[0].instructor_id; + } else { + const { rows } = await client.query( + `INSERT INTO instructors (email, display_name, password_hash, role, status) VALUES ($1, $2, $3, 'owner', 'active') RETURNING instructor_id`, + [emailLower, displayName.trim(), passwordHash] + ); + instructorId = rows[0].instructor_id; + await client.query( + `INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`, + [instructorId, String(instructorId), emailLower] + ); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + + console.log(`Owner ready: ${emailLower} (${instructorId})`); + process.exit(0); +} + +main().catch((e) => { console.error('FATAL:', e.message); process.exit(1); }); diff --git a/platform/api/migrations/007_instructors.sql b/platform/api/migrations/007_instructors.sql new file mode 100644 index 0000000..4cde82f --- /dev/null +++ b/platform/api/migrations/007_instructors.sql @@ -0,0 +1,20 @@ +-- instructors — replaces the single shared ADMIN_PASSWORD (Phase 2B) with real per-person +-- accounts. password_hash is ":" from scrypt (see src/auth/password.js), +-- NULL only if an instructor is ever external-auth-only (future OIDC, see auth_identities). +-- +-- Roles are a plain CHECK, not a lookup table — three roles, not expected to churn often, and +-- a CHECK is enough to keep bad data out without a join on every permission check. + +CREATE TABLE IF NOT EXISTS instructors ( + instructor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, -- always stored lowercased by the application + display_name TEXT NOT NULL, + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'instructor' CHECK (role IN ('owner', 'admin', 'instructor')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_instructors_role_status ON instructors(role, status); diff --git a/platform/api/migrations/008_auth_identities.sql b/platform/api/migrations/008_auth_identities.sql new file mode 100644 index 0000000..c52cd22 --- /dev/null +++ b/platform/api/migrations/008_auth_identities.sql @@ -0,0 +1,18 @@ +-- auth_identities — lets a local-password identity and a future OIDC identity map to the same +-- instructor record without a schema change when OIDC lands. Every instructor gets a 'local' +-- row today (provider_subject = their own instructor_id as text); a Google/OIDC login later +-- just adds a second row for the same instructor_id. See +-- docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for the adapter interface this backs. + +CREATE TABLE IF NOT EXISTS auth_identities ( + auth_identity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + instructor_id UUID NOT NULL REFERENCES instructors(instructor_id) ON DELETE CASCADE, + provider TEXT NOT NULL, -- 'local' | 'google' | 'oidc:' + provider_subject TEXT NOT NULL, + provider_email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ, + UNIQUE (provider, provider_subject) +); + +CREATE INDEX IF NOT EXISTS idx_auth_identities_instructor ON auth_identities(instructor_id); diff --git a/platform/api/package.json b/platform/api/package.json index e325949..f0f694a 100644 --- a/platform/api/package.json +++ b/platform/api/package.json @@ -6,7 +6,8 @@ "main": "src/server.js", "scripts": { "start": "node src/server.js", - "migrate-sqlite": "node migrate-sqlite.js" + "migrate-sqlite": "node migrate-sqlite.js", + "bootstrap-owner": "node bootstrap-owner.js" }, "dependencies": { "express": "^4.19.2", diff --git a/platform/api/src/admin-auth.js b/platform/api/src/admin-auth.js deleted file mode 100644 index 3af5690..0000000 --- a/platform/api/src/admin-auth.js +++ /dev/null @@ -1,29 +0,0 @@ -// Admin password verification. Phase 2B scope, explicitly not production SSO/OIDC (that's -// Phase 3+, documented in docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md). -// -// ADMIN_PASSWORD is read from the environment (the same secret-injection channel every other -// component here uses — PROVISIONER_SHARED_SECRET, etc. — not a new pattern). It is never -// stored in a database or logged. Comparison is done via scrypt + a timing-safe compare rather -// than a plain string comparison, so a login attempt never directly compares raw password -// bytes (defense in depth, cheap to do correctly with only Node's stdlib — no new dependency). - -const crypto = require('crypto'); - -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; -const SALT = 'rootnode-admin-fixed-salt-v1'; // fixed salt is fine here: single credential, not - // a multi-user password table needing per-row salts - -if (!ADMIN_PASSWORD) { - console.error('FATAL: ADMIN_PASSWORD is not set. Refusing to start with no way to authenticate an instructor.'); - process.exit(1); -} - -const expectedHash = crypto.scryptSync(ADMIN_PASSWORD, SALT, 64); - -function verifyAdminPassword(submitted) { - if (!submitted || typeof submitted !== 'string') return false; - const submittedHash = crypto.scryptSync(submitted, SALT, 64); - return crypto.timingSafeEqual(expectedHash, submittedHash); -} - -module.exports = { verifyAdminPassword }; diff --git a/platform/api/src/auth/password.js b/platform/api/src/auth/password.js new file mode 100644 index 0000000..a4e2446 --- /dev/null +++ b/platform/api/src/auth/password.js @@ -0,0 +1,27 @@ +// Password hashing for instructor accounts — scrypt (Node stdlib, no new dependency) with a +// fresh random salt per instructor, stored alongside the hash as ":". +// Phase 2B's admin-auth.js used one FIXED salt because there was exactly one shared password; +// now that there are real per-person accounts, each one gets its own salt. + +const crypto = require('crypto'); + +const KEY_LEN = 64; + +function hashPassword(plain) { + const salt = crypto.randomBytes(16); + const hash = crypto.scryptSync(plain, salt, KEY_LEN); + return `${salt.toString('hex')}:${hash.toString('hex')}`; +} + +function verifyPassword(plain, stored) { + if (!plain || !stored) return false; + const [saltHex, hashHex] = stored.split(':'); + if (!saltHex || !hashHex) return false; + const salt = Buffer.from(saltHex, 'hex'); + const expected = Buffer.from(hashHex, 'hex'); + const actual = crypto.scryptSync(plain, salt, expected.length || KEY_LEN); + if (actual.length !== expected.length) return false; + return crypto.timingSafeEqual(actual, expected); +} + +module.exports = { hashPassword, verifyPassword }; diff --git a/platform/api/src/instructor-auth-routes.js b/platform/api/src/instructor-auth-routes.js new file mode 100644 index 0000000..b9feed0 --- /dev/null +++ b/platform/api/src/instructor-auth-routes.js @@ -0,0 +1,102 @@ +// 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 (tooManyAttempts(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; diff --git a/platform/api/src/instructors.js b/platform/api/src/instructors.js new file mode 100644 index 0000000..336b579 --- /dev/null +++ b/platform/api/src/instructors.js @@ -0,0 +1,77 @@ +// Instructor account CRUD + the small set of invariants that protect the platform from +// locking itself out (never zero active owners). Shared by the auth routes (login/session) and +// the admin instructor-management routes. + +const { hashPassword } = require('./auth/password'); + +function publicInstructor(row) { + if (!row) return null; + const { password_hash, ...rest } = row; + return rest; +} + +async function getActiveInstructor(pool, instructorId) { + const { rows } = await pool.query( + `SELECT * FROM instructors WHERE instructor_id = $1 AND status = 'active'`, [instructorId] + ); + return rows[0] || null; +} + +// Returns the row regardless of status — the login flow needs to tell "wrong password" apart +// from "correct password, but account is deactivated" (see instructor-auth-routes.js), which +// requires seeing inactive accounts too. +async function getInstructorByEmail(pool, email) { + const { rows } = await pool.query(`SELECT * FROM instructors WHERE email = $1`, [String(email).toLowerCase().trim()]); + return rows[0] || null; +} + +async function listInstructors(pool) { + const { rows } = await pool.query(`SELECT * FROM instructors ORDER BY created_at`); + return rows.map(publicInstructor); +} + +async function countActiveOwners(pool, excludingId) { + const { rows } = await pool.query( + `SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active' AND instructor_id != $1`, + [excludingId || '00000000-0000-0000-0000-000000000000'] + ); + return rows[0].n; +} + +async function createInstructor(pool, { email, displayName, role, password }) { + const emailLower = String(email).toLowerCase().trim(); + const passwordHash = hashPassword(password); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query( + `INSERT INTO instructors (email, display_name, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING *`, + [emailLower, displayName.trim(), passwordHash, role || 'instructor'] + ); + const instructor = rows[0]; + await client.query( + `INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`, + [instructor.instructor_id, String(instructor.instructor_id), emailLower] + ); + await client.query('COMMIT'); + return instructor; + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + +// Guards the one invariant that matters: never let the platform end up with zero active +// owners. Called before any update that would deactivate an owner or demote their role. +async function assertNotLastOwner(pool, instructorId) { + const remaining = await countActiveOwners(pool, instructorId); + if (remaining === 0) { + const err = new Error('Cannot remove the last active owner.'); + err.code = 'LAST_OWNER'; + throw err; + } +} + +module.exports = { publicInstructor, getActiveInstructor, getInstructorByEmail, listInstructors, countActiveOwners, createInstructor, assertNotLastOwner };