feat: add TOTP multifactor authentication
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const usage = 'Usage: npm run admin:bootstrap -- <verified-email>';
|
||||
const suppliedEmails = process.argv.slice(2);
|
||||
if (suppliedEmails.length !== 1) throw new Error(usage);
|
||||
|
||||
const email = suppliedEmails[0]
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.toLocaleLowerCase('en-US');
|
||||
if (
|
||||
email.length < 3 ||
|
||||
email.length > 320 ||
|
||||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) ||
|
||||
/[\r\n]/u.test(email)
|
||||
) {
|
||||
throw new Error('Provide one valid verified email address.');
|
||||
}
|
||||
|
||||
const databasePath = resolve(
|
||||
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||
);
|
||||
const database = await connect(databasePath);
|
||||
let transactionStarted = false;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
transactionStarted = true;
|
||||
|
||||
const matchingAccounts = await (
|
||||
await database.prepare(`
|
||||
SELECT users.id
|
||||
FROM users
|
||||
JOIN user_emails ON user_emails.user_id=users.id
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE user_emails.email=? COLLATE NOCASE
|
||||
AND user_emails.verified_at IS NOT NULL
|
||||
AND deleted_accounts.user_id IS NULL
|
||||
AND user_status.blocked_at IS NULL
|
||||
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
|
||||
`)
|
||||
).all(email, now);
|
||||
if (matchingAccounts.length !== 1)
|
||||
throw new Error(
|
||||
'The email must identify exactly one active verified account.',
|
||||
);
|
||||
|
||||
const targetId = String(matchingAccounts[0].id);
|
||||
const activeAdministrators = await (
|
||||
await database.prepare(`
|
||||
SELECT users.id
|
||||
FROM users
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE deleted_accounts.user_id IS NULL
|
||||
AND user_status.blocked_at IS NULL
|
||||
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
|
||||
)
|
||||
`)
|
||||
).all(now);
|
||||
const targetAlreadyAdministrator = activeAdministrators.some(
|
||||
(administrator) => String(administrator.id) === targetId,
|
||||
);
|
||||
if (activeAdministrators.length > 0 && !targetAlreadyAdministrator)
|
||||
throw new Error('An active full administrator already exists.');
|
||||
|
||||
if (!targetAlreadyAdministrator) {
|
||||
const administratorRole = await (
|
||||
await database.prepare(`
|
||||
SELECT roles.id
|
||||
FROM roles
|
||||
JOIN role_permissions ON role_permissions.role_id=roles.id
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE roles.id='role-admin' AND permissions.name='system.admin'
|
||||
LIMIT 1
|
||||
`)
|
||||
).get();
|
||||
if (!administratorRole)
|
||||
throw new Error('The canonical administrator role is unavailable.');
|
||||
|
||||
const activeFactor = await (
|
||||
await database.prepare(
|
||||
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
|
||||
)
|
||||
).get(targetId);
|
||||
if (!activeFactor)
|
||||
throw new Error('The target account must have an active TOTP factor.');
|
||||
const unusedRecoveryCode = await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM mfa_recovery_codes WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL LIMIT 1',
|
||||
)
|
||||
).get(targetId);
|
||||
if (!unusedRecoveryCode)
|
||||
throw new Error('The target account must have an unused recovery code.');
|
||||
|
||||
const before = {
|
||||
roleIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(targetId)
|
||||
).map((row) => String(row.role_id)),
|
||||
};
|
||||
|
||||
await (
|
||||
await database.prepare(
|
||||
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
|
||||
)
|
||||
).run(targetId);
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE users SET role='user' WHERE id=? AND role='admin'",
|
||||
)
|
||||
).run(targetId);
|
||||
|
||||
const targetSessions = await (
|
||||
await database.prepare('SELECT id FROM sessions WHERE user_id=?')
|
||||
).all(targetId);
|
||||
const sessionIds = targetSessions.map((row) => String(row.id));
|
||||
if (sessionIds.length) {
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE login_events SET session_ended_at=?,session_end_reason=? WHERE session_id IN (${placeholders})`,
|
||||
)
|
||||
).run(now, 'administrator_bootstrap', ...sessionIds);
|
||||
await (
|
||||
await database.prepare(
|
||||
`DELETE FROM sessions WHERE id IN (${placeholders})`,
|
||||
)
|
||||
).run(...sessionIds);
|
||||
}
|
||||
|
||||
await (
|
||||
await database.prepare(`
|
||||
INSERT INTO audit_log(
|
||||
id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at
|
||||
) VALUES(?,?,?,?,?,?,?,?)
|
||||
`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
targetId,
|
||||
'role_management',
|
||||
targetId,
|
||||
'administrator.bootstrapped',
|
||||
JSON.stringify(before),
|
||||
JSON.stringify({
|
||||
roleId: 'role-admin',
|
||||
sessionsRevoked: sessionIds.length,
|
||||
mfaRequired: true,
|
||||
}),
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
const resultingAdministrators = await (
|
||||
await database.prepare(`
|
||||
SELECT COUNT(DISTINCT users.id) AS count
|
||||
FROM users
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE deleted_accounts.user_id IS NULL
|
||||
AND user_status.blocked_at IS NULL
|
||||
AND (user_status.banned_until IS NULL OR user_status.banned_until<=?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_roles.user_id=users.id AND user_roles.role_id='role-admin'
|
||||
)
|
||||
`)
|
||||
).get(now);
|
||||
if (Number(resultingAdministrators?.count) < 1)
|
||||
throw new Error('The database must retain an active full administrator.');
|
||||
|
||||
await database.exec('COMMIT');
|
||||
transactionStarted = false;
|
||||
console.log(
|
||||
targetAlreadyAdministrator
|
||||
? 'The target account already has unrestricted administration.'
|
||||
: 'Initial unrestricted administration granted.',
|
||||
);
|
||||
} catch (error) {
|
||||
if (transactionStarted) {
|
||||
try {
|
||||
await database.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original failure without exposing database details.
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
error instanceof Error ? error.message : 'Administrator bootstrap failed.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const run = promisify(execFile);
|
||||
let directory: string;
|
||||
let database: Database;
|
||||
let databasePath: string;
|
||||
|
||||
async function invoke() {
|
||||
try {
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
['scripts/admin-bootstrap.mjs', 'admin@example.com'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TDR_DATABASE_PATH: databasePath,
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
},
|
||||
);
|
||||
return { status: 0, stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (error) {
|
||||
const failure = error as {
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
return {
|
||||
status: Number(failure.code ?? 1),
|
||||
stdout: failure.stdout ?? '',
|
||||
stderr: failure.stderr ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('administrator bootstrap preconditions', () => {
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'shd-admin-bootstrap-'));
|
||||
databasePath = join(directory, 'database.db');
|
||||
database = await connect(databasePath);
|
||||
await database.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, role TEXT, created_at TEXT);
|
||||
CREATE TABLE user_emails (user_id TEXT, email TEXT, verified_at TEXT);
|
||||
CREATE TABLE user_status (user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
|
||||
CREATE TABLE deleted_accounts (user_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE roles (id TEXT PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE permissions (id TEXT PRIMARY KEY, name TEXT);
|
||||
CREATE TABLE role_permissions (role_id TEXT, permission_id TEXT);
|
||||
CREATE TABLE user_roles (user_id TEXT, role_id TEXT, PRIMARY KEY(user_id,role_id));
|
||||
CREATE TABLE mfa_totp_factors (id TEXT PRIMARY KEY, user_id TEXT, state TEXT);
|
||||
CREATE TABLE mfa_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT, revoked_at TEXT);
|
||||
CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE login_events (id TEXT PRIMARY KEY, session_id TEXT, session_ended_at TEXT, session_end_reason TEXT);
|
||||
CREATE TABLE audit_log (id TEXT PRIMARY KEY, actor_id TEXT, entity_type TEXT, entity_id TEXT, action TEXT, before_json TEXT, after_json TEXT, created_at TEXT);
|
||||
INSERT INTO users VALUES ('user-1','agent','user','2026-08-07T00:00:00.000Z');
|
||||
INSERT INTO user_emails VALUES ('user-1','admin@example.com','2026-08-07T00:00:00.000Z');
|
||||
INSERT INTO roles VALUES ('role-admin','Admin');
|
||||
INSERT INTO permissions VALUES ('permission-system-admin','system.admin');
|
||||
INSERT INTO role_permissions VALUES ('role-admin','permission-system-admin');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await database.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects accounts without both active TOTP and unused recovery code', async () => {
|
||||
await database.close();
|
||||
const result = await invoke();
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('active TOTP factor');
|
||||
database = await connect(databasePath);
|
||||
const user = await (await database.prepare('SELECT role FROM users')).get();
|
||||
expect(user.role).toBe('user');
|
||||
});
|
||||
|
||||
it('grants only after MFA readiness and records sanitized bootstrap evidence', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
|
||||
INSERT INTO sessions VALUES ('session-1','user-1');
|
||||
`);
|
||||
await database.close();
|
||||
const result = await invoke();
|
||||
expect(result.status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const user = await (await database.prepare('SELECT role FROM users')).get();
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('user-1');
|
||||
const sessions = await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get();
|
||||
const audit = await (
|
||||
await database.prepare(
|
||||
'SELECT entity_type,action,after_json FROM audit_log',
|
||||
)
|
||||
).get();
|
||||
expect(user.role).toBe('user');
|
||||
expect(role.role_id).toBe('role-admin');
|
||||
expect(Number(sessions.count)).toBe(0);
|
||||
expect(audit).toMatchObject({
|
||||
entity_type: 'role_management',
|
||||
action: 'administrator.bootstrapped',
|
||||
});
|
||||
expect(String(audit.after_json)).not.toContain('factor-1');
|
||||
expect(String(audit.after_json)).not.toContain('code-1');
|
||||
});
|
||||
|
||||
it('is idempotent for an already canonical administrator', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES ('factor-1','user-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES ('code-1','user-1',NULL,NULL);
|
||||
`);
|
||||
await database.close();
|
||||
expect((await invoke()).status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const firstAuditCount = await (
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
|
||||
)
|
||||
).get();
|
||||
await database.close();
|
||||
expect((await invoke()).status).toBe(0);
|
||||
database = await connect(databasePath);
|
||||
const secondAuditCount = await (
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM audit_log WHERE action='administrator.bootstrapped'",
|
||||
)
|
||||
).get();
|
||||
expect(Number(firstAuditCount.count)).toBe(1);
|
||||
expect(Number(secondAuditCount.count)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps only the local development bootstrap command', async () => {
|
||||
const packageJson = JSON.parse(await readFile('package.json', 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.scripts['admin:bootstrap']).toBeTruthy();
|
||||
expect(packageJson.scripts['admin:bootstrap:staging']).toBeUndefined();
|
||||
expect(packageJson.scripts['admin:bootstrap:production']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+152
-2
@@ -769,6 +769,138 @@ for (const statement of statements) {
|
||||
await database.exec(statement);
|
||||
}
|
||||
|
||||
// Authentication transactions created by older releases did not retain the
|
||||
// primary provider. Keep those rows intact and leave their provider unknown;
|
||||
// new login transactions write the validated provider at creation time.
|
||||
const authenticationTransactionColumns = await (
|
||||
await database.prepare("PRAGMA table_info('authentication_transactions')")
|
||||
).all();
|
||||
if (
|
||||
authenticationTransactionColumns.length &&
|
||||
!authenticationTransactionColumns.some((column) => column.name === 'provider')
|
||||
) {
|
||||
console.log('Adding primary providers to authentication transactions.');
|
||||
await database.exec(
|
||||
"ALTER TABLE authentication_transactions ADD COLUMN provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey'))",
|
||||
);
|
||||
}
|
||||
|
||||
// Move legacy administrator rows onto the canonical relationship before the
|
||||
// legacy users.role projection is cleared. The operation is repeatable and
|
||||
// keeps the existing account IDs and authored data unchanged.
|
||||
const legacyAdministratorRows = await (
|
||||
await database.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id")
|
||||
).all();
|
||||
if (legacyAdministratorRows.length) {
|
||||
const administratorRole = await (
|
||||
await database.prepare("SELECT id FROM roles WHERE id='role-admin'")
|
||||
).get();
|
||||
if (!administratorRole)
|
||||
throw new Error('Cannot migrate legacy administrators without role-admin.');
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const user of legacyAdministratorRows) {
|
||||
await (
|
||||
await database.prepare(
|
||||
"INSERT OR IGNORE INTO user_roles(user_id,role_id) VALUES(?,'role-admin')",
|
||||
)
|
||||
).run(String(user.id));
|
||||
}
|
||||
await database.exec("UPDATE users SET role='user' WHERE role='admin'");
|
||||
const remainingLegacyAdministrators = await (
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM users WHERE role='admin'",
|
||||
)
|
||||
).get();
|
||||
if (Number(remainingLegacyAdministrators?.count) !== 0)
|
||||
throw new Error('Legacy administrator authority could not be cleared.');
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// MFA rotations keep the current active factor while a replacement is pending.
|
||||
// Older databases used one partial index for both states, which prevented that
|
||||
// safe overlap. Drop it and install the two independent idempotent constraints.
|
||||
const legacyTotpCurrentIndex = await (
|
||||
await database.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='mfa_totp_factors_one_current_idx'",
|
||||
)
|
||||
).get();
|
||||
if (legacyTotpCurrentIndex) {
|
||||
await database.exec('DROP INDEX mfa_totp_factors_one_current_idx');
|
||||
}
|
||||
await database.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx ON mfa_totp_factors(user_id) WHERE state='active'",
|
||||
);
|
||||
await database.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx ON mfa_totp_factors(user_id) WHERE state='pending'",
|
||||
);
|
||||
|
||||
// Existing sessions were issued before authentication assurance levels were
|
||||
// recorded. They cannot be upgraded safely, so invalidate only those legacy
|
||||
// rows after adding the nullable migration columns. New sessions are written
|
||||
// with a non-null AAL evidence timestamp by the auth session service.
|
||||
const sessionTable = await (
|
||||
await database.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions'",
|
||||
)
|
||||
).get();
|
||||
if (sessionTable) {
|
||||
let sessionColumns = await (
|
||||
await database.prepare("PRAGMA table_info('sessions')")
|
||||
).all();
|
||||
if (!sessionColumns.some((column) => column.name === 'aal')) {
|
||||
await database.exec(
|
||||
'ALTER TABLE sessions ADD COLUMN aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2))',
|
||||
);
|
||||
sessionColumns = [...sessionColumns, { name: 'aal' }];
|
||||
}
|
||||
if (!sessionColumns.some((column) => column.name === 'aal_verified_at')) {
|
||||
await database.exec('ALTER TABLE sessions ADD COLUMN aal_verified_at TEXT');
|
||||
sessionColumns = [...sessionColumns, { name: 'aal_verified_at' }];
|
||||
}
|
||||
if (!sessionColumns.some((column) => column.name === 'aal2_verified_at')) {
|
||||
await database.exec(
|
||||
'ALTER TABLE sessions ADD COLUMN aal2_verified_at TEXT',
|
||||
);
|
||||
}
|
||||
const legacySessions = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM sessions WHERE aal_verified_at IS NULL',
|
||||
)
|
||||
).get();
|
||||
if (Number(legacySessions?.count) > 0) {
|
||||
console.log(
|
||||
`Invalidating ${Number(legacySessions.count)} legacy session(s) without AAL evidence.`,
|
||||
);
|
||||
await database.exec('DELETE FROM sessions WHERE aal_verified_at IS NULL');
|
||||
}
|
||||
}
|
||||
|
||||
const loginEventsTable = await (
|
||||
await database.prepare(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='login_events'",
|
||||
)
|
||||
).get();
|
||||
if (loginEventsTable) {
|
||||
const loginEventColumns = await (
|
||||
await database.prepare("PRAGMA table_info('login_events')")
|
||||
).all();
|
||||
if (!loginEventColumns.some((column) => column.name === 'aal')) {
|
||||
await database.exec(
|
||||
'ALTER TABLE login_events ADD COLUMN aal INTEGER CHECK(aal IS NULL OR aal IN (1,2))',
|
||||
);
|
||||
}
|
||||
if (!loginEventColumns.some((column) => column.name === 'aal2_verified_at')) {
|
||||
await database.exec(
|
||||
'ALTER TABLE login_events ADD COLUMN aal2_verified_at TEXT',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
|
||||
BEFORE INSERT ON specialization_ability_skill_chip_links
|
||||
BEGIN
|
||||
@@ -2142,10 +2274,12 @@ if (
|
||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
|
||||
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
|
||||
aal INTEGER CHECK(aal IS NULL OR aal IN (1,2)),
|
||||
aal2_verified_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`);
|
||||
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
|
||||
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
|
||||
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,aal,aal2_verified_at,created_at)
|
||||
SELECT id,user_id,provider,succeeded,NULL,NULL,created_at FROM login_events_legacy`);
|
||||
await database.exec('DROP TABLE login_events_legacy');
|
||||
await database.exec(
|
||||
'CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)',
|
||||
@@ -2170,6 +2304,8 @@ for (const [name, definition] of [
|
||||
['session_ended_at', 'TEXT'],
|
||||
['session_end_reason', 'TEXT'],
|
||||
['sensitive_purged_at', 'TEXT'],
|
||||
['aal', 'INTEGER CHECK(aal IS NULL OR aal IN (1,2))'],
|
||||
['aal2_verified_at', 'TEXT'],
|
||||
]) {
|
||||
if (!loginEventColumns.some((column) => column.name === name)) {
|
||||
await database.exec(
|
||||
@@ -2178,6 +2314,20 @@ for (const [name, definition] of [
|
||||
}
|
||||
}
|
||||
|
||||
// Earlier releases logged the primary factor as a successful login even when
|
||||
// the AAL2 challenge was still pending. Retain those events as failure
|
||||
// telemetry, but prevent abandoned MFA challenges from remaining successful.
|
||||
await (
|
||||
await database.prepare(`
|
||||
UPDATE login_events
|
||||
SET succeeded=0,
|
||||
failure_reason=COALESCE(failure_reason,'mfa_required'),
|
||||
aal=COALESCE(aal,1)
|
||||
WHERE succeeded=1
|
||||
AND instr(',' || COALESCE(risk_flags,'') || ',', ',primary_authenticated_mfa_required,')>0
|
||||
`)
|
||||
).run();
|
||||
|
||||
const proposalColumns = await (
|
||||
await database.prepare("PRAGMA table_info('data_change_proposals')")
|
||||
).all();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
import { isFullAdministrator } from '../src/lib/server/auth/bootstrap';
|
||||
|
||||
const run = promisify(execFile);
|
||||
let directory: string | undefined;
|
||||
let database: Database | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close();
|
||||
if (directory) await rm(directory, { recursive: true, force: true });
|
||||
database = undefined;
|
||||
directory = undefined;
|
||||
});
|
||||
|
||||
describe('legacy administrator migration', () => {
|
||||
it('moves legacy administrator rows to role-admin and is repeatable', async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'shd-admin-migration-'));
|
||||
const databasePath = join(directory, 'database.db');
|
||||
database = await connect(databasePath);
|
||||
await database.exec(await readFile('database/schema.sql', 'utf8'));
|
||||
await (
|
||||
await database.prepare(
|
||||
"INSERT INTO users(id,username,role,created_at) VALUES('legacy-admin','legacy','admin',?)",
|
||||
)
|
||||
).run(new Date().toISOString());
|
||||
await database.close();
|
||||
database = undefined;
|
||||
|
||||
const environment = {
|
||||
...process.env,
|
||||
TDR_DATABASE_PATH: databasePath,
|
||||
SKIP_DEFAULT_USERS: 'true',
|
||||
};
|
||||
await run(process.execPath, ['scripts/db-migrate.mjs'], {
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
});
|
||||
database = await connect(databasePath);
|
||||
let user = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get('legacy-admin');
|
||||
let relationship = await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? AND role_id=?',
|
||||
)
|
||||
).get('legacy-admin', 'role-admin');
|
||||
expect(user.role).toBe('user');
|
||||
expect(relationship.role_id).toBe('role-admin');
|
||||
|
||||
await database.close();
|
||||
database = undefined;
|
||||
await run(process.execPath, ['scripts/db-migrate.mjs'], {
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
});
|
||||
database = await connect(databasePath);
|
||||
user = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get('legacy-admin');
|
||||
relationship = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM user_roles WHERE user_id=? AND role_id=?',
|
||||
)
|
||||
).get('legacy-admin', 'role-admin');
|
||||
expect(user.role).toBe('user');
|
||||
expect(Number(relationship.count)).toBe(1);
|
||||
expect(await isFullAdministrator(database, 'legacy-admin')).toBe(true);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM user_roles WHERE user_id=? AND role_id=?',
|
||||
)
|
||||
).run('legacy-admin', 'role-admin');
|
||||
expect(await isFullAdministrator(database, 'legacy-admin')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ const APP_REQUIRED = [
|
||||
'APP_ORIGIN',
|
||||
'ORIGIN',
|
||||
'PASSKEY_RP_ID',
|
||||
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||
'SCHEDULER_TOKEN',
|
||||
'TDR_DATABASE_PATH',
|
||||
'TDR_AVATAR_STORAGE_PATH',
|
||||
@@ -48,6 +49,7 @@ export function validateRuntimeEnvironment(mode, environment) {
|
||||
|
||||
requireValues(APP_REQUIRED);
|
||||
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
||||
validateMfaTotpEncryptionKeys(get('MFA_TOTP_ENCRYPTION_KEYS'), errors);
|
||||
|
||||
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
|
||||
const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors);
|
||||
@@ -230,3 +232,60 @@ function validateSchedulerToken(value, errors) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the app-only TOTP encryption keyring without including configured
|
||||
* values in an error. The first entry is the active write key.
|
||||
*
|
||||
* @param {string} value
|
||||
* @param {string[]} errors
|
||||
*/
|
||||
function validateMfaTotpEncryptionKeys(value, errors) {
|
||||
if (!value) return;
|
||||
const entries = value.split(',').map((entry) => entry.trim());
|
||||
if (
|
||||
entries.length < 1 ||
|
||||
entries.length > 3 ||
|
||||
entries.some((entry) => !entry)
|
||||
) {
|
||||
errors.push(
|
||||
'MFA_TOTP_ENCRYPTION_KEYS must contain 1 through 3 key entries.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
const keys = new Set();
|
||||
for (const entry of entries) {
|
||||
const separator = entry.indexOf(':');
|
||||
const keyId = separator > 0 ? entry.slice(0, separator) : '';
|
||||
const encodedKey = separator > 0 ? entry.slice(separator + 1) : '';
|
||||
if (
|
||||
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(encodedKey) ||
|
||||
encodedKey.length !== 43
|
||||
) {
|
||||
errors.push(
|
||||
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let decoded;
|
||||
try {
|
||||
decoded = Buffer.from(encodedKey, 'base64url');
|
||||
} catch {
|
||||
decoded = Buffer.alloc(0);
|
||||
}
|
||||
if (decoded.length !== 32 || decoded.toString('base64url') !== encodedKey) {
|
||||
errors.push(
|
||||
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (ids.has(keyId) || keys.has(encodedKey)) {
|
||||
errors.push('MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.');
|
||||
}
|
||||
ids.add(keyId);
|
||||
keys.add(encodedKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ const validAppEnvironment = {
|
||||
APP_ORIGIN: 'https://stage.shd.build',
|
||||
ORIGIN: 'https://stage.shd.build',
|
||||
PASSKEY_RP_ID: 'shd.build',
|
||||
MFA_TOTP_ENCRYPTION_KEYS:
|
||||
'primary-2026:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||
@@ -21,6 +23,7 @@ describe('validateRuntimeEnvironment', () => {
|
||||
'APP_ORIGIN is required.',
|
||||
'ORIGIN is required.',
|
||||
'PASSKEY_RP_ID is required.',
|
||||
'MFA_TOTP_ENCRYPTION_KEYS is required.',
|
||||
'SCHEDULER_TOKEN is required.',
|
||||
'TDR_DATABASE_PATH is required.',
|
||||
'TDR_AVATAR_STORAGE_PATH is required.',
|
||||
@@ -158,4 +161,24 @@ describe('validateRuntimeEnvironment', () => {
|
||||
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
||||
).toThrow('Unsupported runtime mode: worker');
|
||||
});
|
||||
|
||||
it('validates the MFA encryption keyring without echoing secrets', () => {
|
||||
expect(
|
||||
validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
MFA_TOTP_ENCRYPTION_KEYS:
|
||||
'one:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,one:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
}),
|
||||
).toEqual(['MFA_TOTP_ENCRYPTION_KEYS must not repeat key IDs or keys.']);
|
||||
|
||||
const errors = validateRuntimeEnvironment('app', {
|
||||
...validAppEnvironment,
|
||||
MFA_TOTP_ENCRYPTION_KEYS:
|
||||
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
});
|
||||
expect(errors).toEqual([
|
||||
'MFA_TOTP_ENCRYPTION_KEYS entries must use key-id and canonical 32-byte Base64URL values.',
|
||||
]);
|
||||
expect(errors.join('\n')).not.toContain('primary:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { validateRuntimeEnvironment } from './runtime-environment.js';
|
||||
|
||||
const APP_SECRET_NAMES = new Set([
|
||||
'APP_ORIGIN',
|
||||
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||
'DISCORD_CLIENT_ID',
|
||||
'DISCORD_CLIENT_SECRET',
|
||||
'DISCORD_REDIRECT_URI',
|
||||
|
||||
Reference in New Issue
Block a user