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
+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 };