test(security): lock in authService — password verify, hashed sessions, rotation (Phase 1)
Pins the auth-core invariants: bcrypt password round-trip, wrong-password → bad_password (feeds lockout), unknown user → null (no enumeration); sessions stored HASHED (sha256), never the raw token; the session lifecycle (create/lookup/expire/rotate/invalidate/prune); and rotateSessionId enforcing ownership + killing the old session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b95f771d18
commit
b3ce334987
|
|
@ -0,0 +1,105 @@
|
|||
'use strict';
|
||||
|
||||
// Phase 1 — authService is the untested auth core: password verification, session
|
||||
// storage, rotation, and invalidation. These pin the security invariants that a
|
||||
// future refactor must not silently break: passwords round-trip through bcrypt,
|
||||
// sessions are stored HASHED (never the raw token), the session lifecycle
|
||||
// (create/lookup/expire/rotate/invalidate/prune) behaves, and rotation enforces
|
||||
// ownership.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-authsvc-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
const auth = require('../services/authService');
|
||||
|
||||
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
||||
let db, userId, otherUserId;
|
||||
|
||||
test.before(async () => {
|
||||
db = getDb();
|
||||
const hash = await auth.hashPassword('correct-horse-battery');
|
||||
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('alice', ?, 'user', 1)").run(hash).lastInsertRowid;
|
||||
otherUserId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('bob', 'x', 'user', 1)").run().lastInsertRowid;
|
||||
});
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
|
||||
});
|
||||
|
||||
test('hashPassword + login: correct password authenticates, wrong password fails, unknown user is null', async () => {
|
||||
const ok = await auth.login('alice', 'correct-horse-battery');
|
||||
assert.ok(ok?.sessionId, 'correct password yields a session');
|
||||
assert.equal(ok.user.id, userId);
|
||||
|
||||
const bad = await auth.login('alice', 'wrong');
|
||||
assert.deepEqual({ error: bad?.error, userId: bad?.userId }, { error: 'bad_password', userId }, 'wrong password → bad_password + userId (for lockout logging)');
|
||||
|
||||
assert.equal(await auth.login('nobody', 'whatever'), null, 'unknown user → null (no enumeration)');
|
||||
});
|
||||
|
||||
test('sessions are stored HASHED, never as the raw token', async () => {
|
||||
const { sessionId } = await auth.createSession(userId);
|
||||
const hashedRow = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sha256(sessionId));
|
||||
const rawRow = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
|
||||
assert.ok(hashedRow, 'the DB stores sha256(sessionId)');
|
||||
assert.equal(rawRow, undefined, 'the raw session token is never persisted');
|
||||
});
|
||||
|
||||
test('getSessionUser: valid → user, bogus → null, expired → null', async () => {
|
||||
const { sessionId } = await auth.createSession(userId);
|
||||
assert.equal(auth.getSessionUser(sessionId)?.id, userId);
|
||||
assert.equal(auth.getSessionUser('bogus-token'), null);
|
||||
|
||||
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
|
||||
.run(sha256('expired-raw'), userId, '2000-01-01 00:00:00');
|
||||
assert.equal(auth.getSessionUser('expired-raw'), null, 'an expired session is not honoured');
|
||||
});
|
||||
|
||||
test('rotateSessionId: old dies, new lives, and ownership is enforced', async () => {
|
||||
const { sessionId: oldId } = await auth.createSession(userId);
|
||||
const newId = auth.rotateSessionId(oldId, userId);
|
||||
assert.ok(newId && newId !== oldId, 'a fresh session id is issued');
|
||||
assert.equal(auth.getSessionUser(oldId), null, 'the old session is invalidated');
|
||||
assert.equal(auth.getSessionUser(newId)?.id, userId, 'the new session is valid');
|
||||
|
||||
// Ownership: cannot rotate a session that isn't yours, and a dead session can't rotate.
|
||||
const { sessionId: victimId } = await auth.createSession(userId);
|
||||
assert.equal(auth.rotateSessionId(victimId, otherUserId), null, 'rotation requires the session to belong to the user');
|
||||
assert.equal(auth.rotateSessionId(oldId, userId), null, 'an already-invalidated session cannot be rotated');
|
||||
});
|
||||
|
||||
test('invalidateOtherSessions keeps the current session and kills the rest', async () => {
|
||||
// Fresh user so other tests\' sessions don\'t interfere with the count.
|
||||
const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('carol', 'x', 'user', 1)").run().lastInsertRowid;
|
||||
const a = await auth.createSession(uid);
|
||||
const b = await auth.createSession(uid);
|
||||
auth.invalidateOtherSessions(uid, a.sessionId);
|
||||
assert.equal(auth.getSessionUser(a.sessionId)?.id, uid, 'kept session survives');
|
||||
assert.equal(auth.getSessionUser(b.sessionId), null, 'other sessions are invalidated');
|
||||
});
|
||||
|
||||
test('pruneExpiredSessions removes only expired rows', async () => {
|
||||
const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('dave', 'x', 'user', 1)").run().lastInsertRowid;
|
||||
const { sessionId: live } = await auth.createSession(uid);
|
||||
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
|
||||
.run(sha256('dead-raw'), uid, '2000-01-01 00:00:00');
|
||||
|
||||
auth.pruneExpiredSessions();
|
||||
assert.equal(auth.getSessionUser(live)?.id, uid, 'live session survives the prune');
|
||||
assert.equal(db.prepare('SELECT id FROM sessions WHERE id = ?').get(sha256('dead-raw')), undefined, 'expired session pruned');
|
||||
});
|
||||
|
||||
test('recordFailedLogin writes a success=0 history row for the known account', () => {
|
||||
const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('erin', 'x', 'user', 1)").run().lastInsertRowid;
|
||||
auth.recordFailedLogin(uid, '203.0.113.7', 'Mozilla/5.0 test');
|
||||
const row = db.prepare('SELECT success FROM user_login_history WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(uid);
|
||||
assert.equal(row?.success, 0, 'failed attempt recorded with success=0');
|
||||
auth.recordFailedLogin(null, '203.0.113.7', 'ua'); // no-op on missing userId, must not throw
|
||||
});
|
||||
Loading…
Reference in New Issue