feat(auth): add per-instructor accounts and local password login

Replaces the single shared ADMIN_PASSWORD (Phase 2B) with real accounts: instructors table
(email/display_name/role/status), scrypt password hashing with a fresh random salt PER
INSTRUCTOR (Phase 2B used one fixed salt because there was exactly one shared password —
that assumption is gone), auth_identities table so a future OIDC login can map to the same
instructor_id without a schema change.

New POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session,
POST /api/auth/change-password (old password required, revokes other sessions). No account
enumeration: identical response whether the email doesn't exist or the password is wrong.

bootstrap-owner.js is the only supported way to create the first account — no manual
database editing to stand up a fresh deployment. Refuses to run if an active owner already
exists unless the target email is an existing instructor (zero-owners recovery path).
This commit is contained in:
2026-08-18 13:35:54 -05:00
parent 52c17c1907
commit 85fa920f96
8 changed files with 323 additions and 30 deletions
+77
View File
@@ -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 [email protected] "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 <email> <display_name> [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); });
@@ -0,0 +1,20 @@
-- instructors — replaces the single shared ADMIN_PASSWORD (Phase 2B) with real per-person
-- accounts. password_hash is "<salt-hex>:<hash-hex>" 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);
@@ -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:<name>'
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);
+2 -1
View File
@@ -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",
-29
View File
@@ -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 };
+27
View File
@@ -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 "<salt-hex>:<hash-hex>".
// 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 };
+102
View File
@@ -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;
+77
View File
@@ -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 };