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();
|
||||
}
|
||||
Reference in New Issue
Block a user