Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67c3d4cdb |
@@ -41,6 +41,10 @@ ORIGIN=https://dev.shd.build
|
||||
# accessing development directly at http://localhost:5173.
|
||||
PASSKEY_RP_ID=shd.build
|
||||
|
||||
# One to three comma-separated key-id:canonical-base64url-32-byte-key entries.
|
||||
# The first key is used for new MFA TOTP factors; retain old keys for rotation.
|
||||
MFA_TOTP_ENCRYPTION_KEYS=
|
||||
|
||||
# SMTP delivery for email verification and magic-link authentication.
|
||||
# Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS.
|
||||
SMTP_HOST=
|
||||
|
||||
@@ -32,8 +32,23 @@ npm run db:seed-users
|
||||
|
||||
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
|
||||
generates and displays strong temporary passwords once, then stores only salted
|
||||
Argon2id hashes. The separate bootstrap policy promotes the first authenticated
|
||||
account when no administrator exists.
|
||||
Argon2id hashes. `DEFAULT_ADMIN_PASSWORD` is the password variable name for
|
||||
`Aewyn`; it does not grant administrator authority.
|
||||
|
||||
Sign-in never grants administrator authority automatically. For local
|
||||
development, give the target account a verified email, enroll TOTP at
|
||||
`/account/security`, and save its one-time recovery codes. Stop the development
|
||||
server, then run:
|
||||
|
||||
```sh
|
||||
npm run admin:bootstrap -- <verified-email>
|
||||
```
|
||||
|
||||
The offline command requires an active TOTP factor and an unused recovery code,
|
||||
revokes the target account's sessions, and refuses to run when an active full
|
||||
administrator already exists. Restart the development server after it succeeds.
|
||||
See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for staging and production
|
||||
operation.
|
||||
|
||||
## Develop
|
||||
|
||||
|
||||
+57
-1
@@ -555,7 +555,7 @@ CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT N
|
||||
CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1);
|
||||
CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCES stat_definitions(id), equipment_type_id TEXT NOT NULL REFERENCES equipment_types(id), PRIMARY KEY(stat_id, equipment_type_id));
|
||||
CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL DEFAULT 1 CHECK(aal IN (1,2)), aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
|
||||
CREATE TABLE IF NOT EXISTS deleted_roles (role_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, deleted_by TEXT NOT NULL REFERENCES users(id));
|
||||
CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT);
|
||||
@@ -591,6 +591,8 @@ CREATE TABLE IF NOT EXISTS login_events (
|
||||
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,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_emails (
|
||||
@@ -643,6 +645,60 @@ CREATE TABLE IF NOT EXISTS deleted_accounts (
|
||||
reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS mfa_totp_factors (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
state TEXT NOT NULL CHECK(state IN ('pending','active','revoked')),
|
||||
encrypted_secret TEXT NOT NULL,
|
||||
encryption_key_id TEXT NOT NULL,
|
||||
issuer TEXT NOT NULL DEFAULT 'SHD.build',
|
||||
account_label TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL DEFAULT 'SHA1' CHECK(algorithm='SHA1'),
|
||||
digits INTEGER NOT NULL DEFAULT 6 CHECK(digits=6),
|
||||
period_seconds INTEGER NOT NULL DEFAULT 30 CHECK(period_seconds=30),
|
||||
last_counter INTEGER,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK(failed_attempts BETWEEN 0 AND 5),
|
||||
locked_until TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
activated_at TEXT,
|
||||
revoked_at TEXT,
|
||||
CHECK((state='pending' AND expires_at IS NOT NULL AND activated_at IS NULL AND revoked_at IS NULL)
|
||||
OR (state='active' AND activated_at IS NOT NULL AND expires_at IS NULL AND revoked_at IS NULL)
|
||||
OR (state='revoked' AND revoked_at IS NOT NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_active_idx
|
||||
ON mfa_totp_factors(user_id) WHERE state='active';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mfa_totp_factors_one_pending_idx
|
||||
ON mfa_totp_factors(user_id) WHERE state='pending';
|
||||
CREATE INDEX IF NOT EXISTS mfa_totp_factors_expiry_idx
|
||||
ON mfa_totp_factors(state,expires_at) WHERE state='pending';
|
||||
CREATE TABLE IF NOT EXISTS mfa_recovery_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
revoked_at TEXT,
|
||||
UNIQUE(user_id,code_hash)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mfa_recovery_codes_available_idx
|
||||
ON mfa_recovery_codes(user_id,consumed_at,revoked_at);
|
||||
CREATE TABLE IF NOT EXISTS authentication_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT CHECK(provider IS NULL OR provider IN ('password','discord','magic_link','passkey')),
|
||||
kind TEXT NOT NULL CHECK(kind IN ('login','step_up')),
|
||||
return_path TEXT NOT NULL CHECK(length(return_path) BETWEEN 1 AND 2048 AND substr(return_path,1,1)='/'),
|
||||
required_aal INTEGER NOT NULL DEFAULT 2 CHECK(required_aal=2),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 5),
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS authentication_transactions_expiry_idx
|
||||
ON authentication_transactions(expires_at);
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-data-editor','Data Editor','Access to ISAC game-data management');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-user-operator','User Operator','View and moderate SHD.build users');
|
||||
INSERT OR IGNORE INTO roles(id,name,description) VALUES('role-access-manager','Access Manager','Manage user roles and direct permissions');
|
||||
|
||||
@@ -140,6 +140,10 @@ describe('container runtime startup', () => {
|
||||
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
|
||||
writeExport(appExport, [
|
||||
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
||||
{
|
||||
key: 'MFA_TOTP_ENCRYPTION_KEYS',
|
||||
value: 'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
},
|
||||
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||
]),
|
||||
@@ -218,6 +222,8 @@ describe('container runtime startup', () => {
|
||||
);
|
||||
expect(childEnvironment).toMatchObject({
|
||||
APP_ORIGIN: 'https://stage.shd.build',
|
||||
MFA_TOTP_ENCRYPTION_KEYS:
|
||||
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||
ORIGIN: 'https://stage.shd.build',
|
||||
|
||||
+81
-3
@@ -98,9 +98,81 @@ remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
|
||||
and delete local and hosted dotenv key material. Removing encrypted files from
|
||||
the current Git tree does not revoke values stored in Git history.
|
||||
|
||||
The staging container disables default-user seeding. On a brand-new staging
|
||||
database, the existing bootstrap policy grants Admin to the first real account
|
||||
that signs in instead of writing generated credentials into deployment logs.
|
||||
## Authentication bootstrap and MFA secrets
|
||||
|
||||
Set the required app-only `MFA_TOTP_ENCRYPTION_KEYS` secret under `/app` in the
|
||||
`dev`, `staging`, and `prod` Infisical environments before starting each app.
|
||||
The value contains one to three comma-separated, unique
|
||||
`key-id:canonical-base64url-32-byte-key` entries. Each key is a random 32-byte
|
||||
value encoded as canonical Base64URL. The first entry is the active write key;
|
||||
following entries are retained decryption keys for older TOTP factors. Do not
|
||||
put this secret under `/shared/scheduler`, in a Gitea variable, in the image, or
|
||||
in a deployment log. A missing, malformed, duplicate, or overlong keyring fails
|
||||
app runtime validation.
|
||||
|
||||
Rotate the keyring without losing access to existing factors:
|
||||
|
||||
1. Generate a new random 32-byte key in the approved secret-management process
|
||||
and assign it a new key ID. Do not copy the value into a document or shell
|
||||
history.
|
||||
2. Prepend the new `key-id` entry and retain the old entries in Infisical.
|
||||
3. Deploy or restart the app, then verify an existing TOTP sign-in and a new
|
||||
enrollment. A successful verification rewraps that factor under the first
|
||||
key.
|
||||
4. Retain each old key until every factor encrypted with it has been rewrapped
|
||||
and verified. Never remove old entries merely because new enrollments use the
|
||||
new key; losing a required old key prevents decryption of those factors.
|
||||
|
||||
TOTP enrollment issues ten recovery codes and displays them once. The database
|
||||
stores only their domain-separated hashes. The account owner must save the codes
|
||||
in an offline password manager or another protected offline record; do not put
|
||||
their values in Infisical, logs, tickets, backups, audit snapshots, or this
|
||||
documentation. Each code works once. Regenerating codes immediately revokes all
|
||||
unused previous codes and displays a replacement set once. Disabling TOTP also
|
||||
revokes its recovery codes.
|
||||
|
||||
Sign-in never grants administrator authority automatically. Before offline
|
||||
elevation, the target administrator must have a verified email, an active TOTP
|
||||
factor, and at least one unused recovery code. The target enrolls TOTP at
|
||||
`/account/security` while the app is running. Then stop every app and scheduler
|
||||
service that can access the database and run the environment-specific command.
|
||||
The command requires that no active full administrator already exists, assigns
|
||||
the canonical administrator role, revokes the target's sessions, and records
|
||||
`role_management / administrator.bootstrapped`.
|
||||
|
||||
For local development:
|
||||
|
||||
```sh
|
||||
npm run admin:bootstrap -- <verified-email>
|
||||
```
|
||||
|
||||
For staging, from `/opt/shd-build-stage`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||
app node scripts/admin-bootstrap.mjs <verified-email>
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
|
||||
```
|
||||
|
||||
For production, from `/opt/shd-build-production`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
|
||||
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||
app node scripts/admin-bootstrap.mjs <verified-email>
|
||||
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
|
||||
```
|
||||
|
||||
The script requires exactly one active, unblocked account with a verified email,
|
||||
an active TOTP factor, and an unused recovery code. It exits without changing
|
||||
roles when an active full administrator already exists. The Compose commands
|
||||
restart their services; restart the local development server after its command.
|
||||
The target must sign in again because its old sessions were revoked.
|
||||
|
||||
The staging container disables default-user seeding. A first sign-in never
|
||||
receives Admin; use the offline flow above after a new database or an initial
|
||||
data bootstrap.
|
||||
|
||||
## Gitea Actions runner
|
||||
|
||||
@@ -388,6 +460,12 @@ import. The importer verifies the bundle target, creates a production safety
|
||||
backup, and refuses to replace a production database containing users, builds,
|
||||
audit events, or proposals. Remove the remote host copy after verification.
|
||||
|
||||
If the imported database has no active full administrator, complete the offline
|
||||
administrator flow with the production Compose command in
|
||||
[Authentication bootstrap and MFA secrets](#authentication-bootstrap-and-mfa-secrets)
|
||||
before reopening the site. Do not rely on the first production sign-in for
|
||||
elevation.
|
||||
|
||||
### Promote one News article
|
||||
|
||||
Export a locally authored News article by ID or slug while the development
|
||||
|
||||
+42
-6
@@ -9,8 +9,8 @@ The team builds the project in vertical slices.
|
||||
data
|
||||
- Auth.js-compatible server authentication with Discord OAuth and credentials
|
||||
providers
|
||||
- Argon2id password hashing, server-side sessions, CSRF protection, and role
|
||||
checks
|
||||
- Argon2id password hashing, TOTP MFA with encrypted secrets, server-side
|
||||
sessions, CSRF protection, and role checks
|
||||
- Browser `localStorage` only for anonymous build drafts
|
||||
|
||||
## Product boundaries
|
||||
@@ -41,6 +41,43 @@ Local credentials use Argon2id PHC strings with a unique random salt per
|
||||
password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
|
||||
minimum profile. Successful login rehashes credentials with outdated parameters.
|
||||
|
||||
## Authentication and MFA
|
||||
|
||||
Sign-in never grants administrator authority. The first full administrator is
|
||||
created through an offline database operation after the target account has
|
||||
enrolled TOTP. The local command is:
|
||||
|
||||
```sh
|
||||
npm run admin:bootstrap -- <verified-email>
|
||||
```
|
||||
|
||||
Staging and production use the stopped-container Compose workflow documented in
|
||||
[`DEPLOYMENT.md`](DEPLOYMENT.md), not a host-side npm command. The bootstrap
|
||||
operation accepts exactly one active, unblocked account with a verified email,
|
||||
requires an active TOTP factor and an unused recovery code, requires that no
|
||||
active full administrator exists, assigns the canonical `role-admin`
|
||||
relationship, revokes the target's sessions, and records
|
||||
`role_management / administrator.bootstrapped`. Administrators must enroll TOTP
|
||||
before offline elevation.
|
||||
|
||||
`MFA_TOTP_ENCRYPTION_KEYS` is a required app-only secret. Its value contains one
|
||||
to three comma-separated, unique `key-id:canonical-base64url-32-byte-key`
|
||||
entries. The first entry is the active encryption key for new factors; later
|
||||
entries retain decryption access to older factors during rotation. There is no
|
||||
generated-key fallback. Rotate by prepending a newly generated 32-byte key with
|
||||
a new ID, deploying it with the old entries retained, and verifying existing
|
||||
TOTP authentication. A successful verification rewraps that factor under the
|
||||
first key. Do not remove an old entry until every factor encrypted with it has
|
||||
been rewrapped and verified. Keep key material in the secret manager and never
|
||||
place it in source, logs, audit snapshots, or documentation.
|
||||
|
||||
TOTP enrollment issues ten one-time recovery codes. The interface displays them
|
||||
only once; the database stores only domain-separated hashes. Save the codes in
|
||||
an offline password manager or another protected offline record. Each code is
|
||||
consumed once. Regenerating codes immediately revokes all unused previous codes
|
||||
and displays a new set once. Disabling TOTP revokes its recovery codes, and a
|
||||
full administrator cannot disable TOTP.
|
||||
|
||||
## Database runtime
|
||||
|
||||
The application uses Turso Database in-process through its native Node package.
|
||||
@@ -244,10 +281,9 @@ does not become part of the canonical action replay.
|
||||
|
||||
# Administrator bootstrap
|
||||
|
||||
The authentication service promotes the first authenticated user to `admin` if
|
||||
the database does not contain an administrator. Promotion uses one conditional
|
||||
database update so concurrent first sign-ins cannot both become administrators.
|
||||
Once an administrator exists, later sign-ins never alter roles.
|
||||
The first full administrator is created only through the offline flow documented
|
||||
in [Authentication and MFA](#authentication-and-mfa). Sign-in never grants
|
||||
administrator authority or changes an account's roles.
|
||||
|
||||
# Database backup and restore
|
||||
|
||||
|
||||
Generated
+25
@@ -21,6 +21,7 @@
|
||||
"argon2": "^0.45.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"openai": "^6.49.0",
|
||||
"otpauth": "9.5.1",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -706,6 +707,18 @@
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
@@ -3486,6 +3499,18 @@
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/otpauth": {
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
|
||||
"integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/hectorm/otpauth?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"db:migrate:production": "infisical run --env=prod --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
|
||||
"db:seed-users": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs",
|
||||
"db:seed-users:reset": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs --reset-passwords",
|
||||
"admin:bootstrap": "infisical run --env=dev --path=/app -- node scripts/admin-bootstrap.mjs",
|
||||
"db:backup": "infisical run --env=dev --path=/app -- node scripts/db-backup.mjs",
|
||||
"db:backup:staging": "infisical run --env=staging --path=/app -- node scripts/db-backup.mjs",
|
||||
"db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
|
||||
@@ -79,6 +80,7 @@
|
||||
"argon2": "^0.45.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"openai": "^6.49.0",
|
||||
"otpauth": "9.5.1",
|
||||
"sharp": "^0.35.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,6 +2,15 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
|
||||
import { stringify } from 'devalue';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import {
|
||||
hasActiveTotpFactor,
|
||||
isFullAdministrator,
|
||||
} from '$lib/server/auth/bootstrap';
|
||||
import {
|
||||
createAuthenticationTransaction,
|
||||
getAuthenticationTransaction,
|
||||
} from '$lib/server/auth/authentication-transactions';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import {
|
||||
getSiteAccessBlock,
|
||||
getSiteAccessSettings,
|
||||
@@ -49,6 +58,62 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const isIsacRoute =
|
||||
event.url.pathname === '/isac' || event.url.pathname.startsWith('/isac/');
|
||||
if (sessionUser && isIsacRoute) {
|
||||
const database = await getDatabase();
|
||||
if (await isFullAdministrator(database, sessionUser.id)) {
|
||||
const hasFactor = await hasActiveTotpFactor(database, sessionUser.id);
|
||||
const mutation = !['GET', 'HEAD'].includes(event.request.method);
|
||||
if (!hasFactor) {
|
||||
if (mutation)
|
||||
return json(
|
||||
{ error: 'Administrator MFA enrollment is required.' },
|
||||
{ status: 403, headers: { 'cache-control': 'no-store' } },
|
||||
);
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: '/account/security?required=admin',
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (sessionUser.aal !== 2) {
|
||||
if (mutation)
|
||||
return json(
|
||||
{ error: 'AAL2 authentication is required.' },
|
||||
{ status: 403, headers: { 'cache-control': 'no-store' } },
|
||||
);
|
||||
const returnPath = `${event.url.pathname}${event.url.search}`;
|
||||
const existing = await getAuthenticationTransaction(
|
||||
event.cookies,
|
||||
database,
|
||||
);
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== sessionUser.id ||
|
||||
existing.kind !== 'step_up'
|
||||
) {
|
||||
await createAuthenticationTransaction(event.cookies, database, {
|
||||
userId: sessionUser.id,
|
||||
kind: 'step_up',
|
||||
returnPath,
|
||||
});
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: '/auth/two-factor',
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.request.method === 'POST') {
|
||||
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
||||
const module = match?.[1] as DataModule | undefined;
|
||||
|
||||
@@ -172,6 +172,10 @@
|
||||
<a class="discord-link-button" class:interaction-pending={discordLinkPending} aria-busy={discordLinkPending} href="/auth/discord?mode=link" onclick={() => discordLinkPending = true}><img src="/discord-symbol.svg" alt="" aria-hidden="true"/><span>Link Discord</span></a>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="security-summary" aria-label="Multi-factor authentication">
|
||||
<div><p class="eyebrow">ACCOUNT HARDENING</p><h2>MULTI-FACTOR AUTHENTICATION</h2><small>{account.mfa?.enabled ? 'Authenticator active.' : 'Optional for standard accounts.'}</small></div>
|
||||
<a class="secondary security-link" href="/account/security">REVIEW SECURITY →</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="account-panel" role="tabpanel">
|
||||
@@ -183,6 +187,6 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.owner-account-control{width:100%;min-width:0;padding:12px}.current-avatar{display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.current-avatar>img{width:58px;height:58px;border:1px solid var(--shd-orange);border-radius:50%;object-fit:cover}.current-avatar>div{min-width:0;display:grid;gap:3px}.current-avatar small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.current-avatar strong{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.avatar-edit-trigger{min-height:40px;padding:0 10px}.avatar-editor{display:grid;gap:9px;margin-top:9px;padding-top:9px;border-top:1px solid var(--shd-border)}.account-section-heading{display:flex;align-items:start;justify-content:space-between;gap:8px}.account-section-heading h2{margin-bottom:0}.owner-account-control :global(.image-drop-input){grid-template-columns:58px minmax(0,1fr)}.owner-account-control :global(.image-upload-actions){grid-column:1/-1;grid-template-columns:1fr}.discord-link-button{width:100%;min-height:44px;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid #6571dc;background:#171a32;color:#fff}.discord-link-button img{width:22px}.provider-unlink-confirm{grid-column:1/-1;display:grid;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--shd-border)}.provider-unlink-confirm>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.provider-unlink-confirm button{width:100%;min-height:40px}
|
||||
@media(max-width:650px){.current-avatar{grid-template-columns:52px minmax(0,1fr)}.current-avatar>img{width:52px;height:52px}.avatar-edit-trigger{grid-column:1/-1;width:100%;min-height:44px}}
|
||||
.owner-account-control{width:100%;min-width:0;padding:12px}.current-avatar{display:grid;grid-template-columns:58px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.current-avatar>img{width:58px;height:58px;border:1px solid var(--shd-orange);border-radius:50%;object-fit:cover}.current-avatar>div{min-width:0;display:grid;gap:3px}.current-avatar small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.current-avatar strong{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}.avatar-edit-trigger{min-height:40px;padding:0 10px}.avatar-editor{display:grid;gap:9px;margin-top:9px;padding-top:9px;border-top:1px solid var(--shd-border)}.account-section-heading{display:flex;align-items:start;justify-content:space-between;gap:8px}.account-section-heading h2{margin-bottom:0}.owner-account-control :global(.image-drop-input){grid-template-columns:58px minmax(0,1fr)}.owner-account-control :global(.image-upload-actions){grid-column:1/-1;grid-template-columns:1fr}.discord-link-button{width:100%;min-height:44px;display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid #6571dc;background:#171a32;color:#fff}.discord-link-button img{width:22px}.provider-unlink-confirm{grid-column:1/-1;display:grid;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--shd-border)}.provider-unlink-confirm>div{display:grid;grid-template-columns:1fr 1fr;gap:6px}.provider-unlink-confirm button{width:100%;min-height:40px}.security-summary{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:18px;padding:14px 0;border-top:1px solid var(--shd-border)}.security-summary h2{margin:2px 0 4px}.security-summary small{color:var(--shd-text-muted)}.security-link{white-space:nowrap;text-decoration:none}
|
||||
@media(max-width:650px){.current-avatar{grid-template-columns:52px minmax(0,1fr)}.current-avatar>img{width:52px;height:52px}.avatar-edit-trigger{grid-column:1/-1;width:100%;min-height:44px}.security-summary{align-items:stretch;flex-direction:column}.security-link{width:100%;text-align:center}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
unlink: vi.fn(async () => undefined),
|
||||
avatarDirectory: vi.fn(() => '/tmp/account-deletion-avatars'),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({ recordIsacAudit: mocks.audit }));
|
||||
vi.mock('node:fs/promises', () => ({ unlink: mocks.unlink }));
|
||||
vi.mock('$lib/server/avatars', () => ({
|
||||
avatarStorageDirectory: mocks.avatarDirectory,
|
||||
}));
|
||||
vi.mock('$lib/agents', () => ({
|
||||
displayNameKey: (value: string) => value.toLowerCase(),
|
||||
}));
|
||||
|
||||
import {
|
||||
LastAdministratorDeletionError,
|
||||
softDeleteAccount,
|
||||
} from './account-deletion';
|
||||
|
||||
async function createDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users(
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL, login_username TEXT,
|
||||
password_hash TEXT, discord_id TEXT, role TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO users VALUES('user-1','agent','agent','hash','discord','user');
|
||||
CREATE TABLE user_profiles(
|
||||
user_id TEXT PRIMARY KEY, display_name TEXT, display_name_key TEXT,
|
||||
bio_markdown TEXT NOT NULL DEFAULT '', is_public INTEGER NOT NULL DEFAULT 1,
|
||||
bio_updated_at TEXT, updated_at TEXT
|
||||
);
|
||||
INSERT INTO user_profiles(user_id,display_name) VALUES('user-1','Agent');
|
||||
CREATE TABLE user_avatars(user_id TEXT PRIMARY KEY, source TEXT, value TEXT);
|
||||
CREATE TABLE avatar_reviews(
|
||||
id TEXT PRIMARY KEY, user_id TEXT, status TEXT, reviewed_at TEXT
|
||||
);
|
||||
INSERT INTO user_avatars(user_id,source,value)
|
||||
VALUES('user-1','upload','11111111-1111-4111-8111-111111111111.webp');
|
||||
CREATE TABLE user_status(user_id TEXT PRIMARY KEY, blocked_at TEXT, banned_until TEXT);
|
||||
CREATE TABLE deleted_accounts(
|
||||
user_id TEXT PRIMARY KEY, original_username TEXT, deleted_at TEXT,
|
||||
deleted_by TEXT, reason TEXT
|
||||
);
|
||||
CREATE TABLE sessions(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE magic_link_tokens(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE webauthn_challenges(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE passkeys(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE user_emails(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE oauth_states(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE discord_profiles(user_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE user_roles(user_id TEXT, role_id TEXT);
|
||||
CREATE TABLE user_permissions(user_id TEXT, permission_id TEXT);
|
||||
CREATE TABLE login_events(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
CREATE TABLE mfa_totp_factors(
|
||||
id TEXT PRIMARY KEY, user_id TEXT, state TEXT, encrypted_secret TEXT,
|
||||
encryption_key_id TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE mfa_recovery_codes(
|
||||
id TEXT PRIMARY KEY, user_id TEXT, code_hash TEXT,
|
||||
consumed_at TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE authentication_transactions(
|
||||
id TEXT PRIMARY KEY, user_id TEXT, consumed_at TEXT
|
||||
);
|
||||
INSERT INTO mfa_totp_factors VALUES('factor-1','user-1','pending','mfa1.old','old-key',NULL);
|
||||
INSERT INTO mfa_recovery_codes VALUES('recovery-1','user-1','hash',NULL,NULL);
|
||||
INSERT INTO authentication_transactions VALUES('tx-1','user-1',NULL);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
describe('account deletion MFA erasure', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
mocks.audit.mockReset();
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
mocks.unlink.mockReset();
|
||||
mocks.unlink.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('purges pending MFA transactions, recovery codes, and factor material', async () => {
|
||||
expect(
|
||||
await softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
|
||||
).toBe(true);
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(
|
||||
'/tmp/account-deletion-avatars/11111111-1111-4111-8111-111111111111.webp',
|
||||
);
|
||||
for (const table of [
|
||||
'mfa_totp_factors',
|
||||
'mfa_recovery_codes',
|
||||
'authentication_transactions',
|
||||
]) {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM ${table} WHERE user_id=?`,
|
||||
)
|
||||
).get('user-1');
|
||||
expect(Number(row?.count)).toBe(0);
|
||||
}
|
||||
const user = await (
|
||||
await database.prepare(
|
||||
'SELECT username,password_hash,discord_id FROM users WHERE id=?',
|
||||
)
|
||||
).get('user-1');
|
||||
expect(user).toMatchObject({
|
||||
username: 'deleted-agent-user-1',
|
||||
password_hash: null,
|
||||
discord_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back all deletion and MFA erasure when audit recording fails', async () => {
|
||||
mocks.audit.mockRejectedValueOnce(new Error('audit unavailable'));
|
||||
await expect(
|
||||
softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
|
||||
).rejects.toThrow('audit unavailable');
|
||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||
const user = await (
|
||||
await database.prepare(
|
||||
'SELECT username,password_hash FROM users WHERE id=?',
|
||||
)
|
||||
).get('user-1');
|
||||
expect(user).toMatchObject({ username: 'agent', password_hash: 'hash' });
|
||||
const factor = await (
|
||||
await database.prepare(
|
||||
'SELECT encrypted_secret,state FROM mfa_totp_factors WHERE id=?',
|
||||
)
|
||||
).get('factor-1');
|
||||
expect(factor).toMatchObject({
|
||||
encrypted_secret: 'mfa1.old',
|
||||
state: 'pending',
|
||||
});
|
||||
const deleted = await (
|
||||
await database.prepare('SELECT 1 FROM deleted_accounts WHERE user_id=?')
|
||||
).get('user-1');
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not clean the avatar when a transaction mutation fails', async () => {
|
||||
await database.exec('DROP TABLE user_permissions');
|
||||
await expect(
|
||||
softDeleteAccount(database, 'user-1', 'admin-1', 'request', true),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||
const user = await (
|
||||
await database.prepare('SELECT username FROM users WHERE id=?')
|
||||
).get('user-1');
|
||||
expect(user?.username).toBe('agent');
|
||||
});
|
||||
|
||||
it('preserves committed deletion when post-commit avatar cleanup fails', async () => {
|
||||
mocks.unlink.mockRejectedValueOnce(new Error('unlink failed'));
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
expect(
|
||||
await softDeleteAccount(database, 'user-1', 'admin-1', 'request'),
|
||||
).toBe(true);
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
'[account-deletion] committed deletion; uploaded avatar cleanup failed.',
|
||||
{ userId: 'user-1' },
|
||||
);
|
||||
const user = await (
|
||||
await database.prepare('SELECT username FROM users WHERE id=?')
|
||||
).get('user-1');
|
||||
expect(user?.username).toBe('deleted-agent-user-1');
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects deletion of the sole active canonical administrator', async () => {
|
||||
await database.exec(
|
||||
"INSERT INTO user_roles(user_id,role_id) VALUES('user-1','role-admin')",
|
||||
);
|
||||
|
||||
await expect(
|
||||
softDeleteAccount(database, 'user-1', 'user-1', 'request'),
|
||||
).rejects.toBeInstanceOf(LastAdministratorDeletionError);
|
||||
|
||||
const deleted = await (
|
||||
await database.prepare('SELECT 1 FROM deleted_accounts WHERE user_id=?')
|
||||
).get('user-1');
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,24 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { avatarStorageDirectory } from '$lib/server/avatars';
|
||||
import { displayNameKey } from '$lib/agents';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
countActiveFullAdministrators,
|
||||
isFullAdministrator,
|
||||
} from './bootstrap';
|
||||
|
||||
const AVATAR_FILE_PATTERN = /^[0-9a-f-]{36}\.webp$/i;
|
||||
type AvatarCleanup = { key: string } | { invalid: true } | null;
|
||||
|
||||
export class LastAdministratorDeletionError extends Error {
|
||||
constructor() {
|
||||
super('The final active full administrator cannot be deleted.');
|
||||
this.name = 'LastAdministratorDeletionError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function softDeleteAccount(
|
||||
database: Database,
|
||||
@@ -10,113 +27,189 @@ export async function softDeleteAccount(
|
||||
reason: string | null,
|
||||
purgeSecurityData = false,
|
||||
): Promise<boolean> {
|
||||
const user = await (
|
||||
await database.prepare(`
|
||||
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
|
||||
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.id=?
|
||||
`)
|
||||
).get(userId);
|
||||
if (!user || user.deleted_at) return false;
|
||||
const now = new Date().toISOString();
|
||||
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
||||
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
|
||||
const deletion = database.transactionAsync(async (transaction) => {
|
||||
const user = await (
|
||||
await transaction.prepare(`
|
||||
SELECT users.username,users.login_username,user_profiles.display_name,
|
||||
user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
|
||||
FROM users LEFT JOIN user_profiles ON user_profiles.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 users.id=?
|
||||
`)
|
||||
).get(userId);
|
||||
if (!user || user.deleted_at) return false;
|
||||
const now = new Date().toISOString();
|
||||
const targetIsActive =
|
||||
!user.blocked_at &&
|
||||
(!user.banned_until || String(user.banned_until) <= now);
|
||||
if (
|
||||
targetIsActive &&
|
||||
(await isFullAdministrator(transaction as unknown as Database, userId)) &&
|
||||
(await countActiveFullAdministrators(
|
||||
transaction as unknown as Database,
|
||||
new Date(now),
|
||||
)) <= 1
|
||||
)
|
||||
throw new LastAdministratorDeletionError();
|
||||
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
||||
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
|
||||
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
userId,
|
||||
purgeSecurityData
|
||||
? deletedUsername
|
||||
: String(user.login_username ?? user.username),
|
||||
now,
|
||||
deletedBy,
|
||||
reason,
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`,
|
||||
)
|
||||
).run(deletedUsername, userId);
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,?,0,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
display_name_key=excluded.display_name_key,
|
||||
bio_markdown='',
|
||||
is_public=0,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`,
|
||||
)
|
||||
).run(
|
||||
userId,
|
||||
deletedDisplayName,
|
||||
displayNameKey(deletedDisplayName),
|
||||
'',
|
||||
now,
|
||||
now,
|
||||
);
|
||||
await (
|
||||
await database.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM passkeys WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_emails WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM oauth_states WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(userId);
|
||||
await deleteUploadedAvatar(database, userId);
|
||||
if (purgeSecurityData) {
|
||||
await (
|
||||
await database.prepare('DELETE FROM login_events WHERE user_id=?')
|
||||
).run(userId);
|
||||
}
|
||||
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(),
|
||||
deletedBy,
|
||||
'user_access',
|
||||
userId,
|
||||
'account.soft_deleted',
|
||||
purgeSecurityData
|
||||
? JSON.stringify({ purgedAtOwnerRequest: true })
|
||||
: JSON.stringify({
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
}),
|
||||
JSON.stringify({
|
||||
username: deletedUsername,
|
||||
displayName: 'Deleted Agent',
|
||||
await transaction.prepare(
|
||||
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
userId,
|
||||
purgeSecurityData
|
||||
? deletedUsername
|
||||
: String(user.login_username ?? user.username),
|
||||
now,
|
||||
deletedBy,
|
||||
reason,
|
||||
securityDataPurged: purgeSecurityData,
|
||||
}),
|
||||
now,
|
||||
);
|
||||
);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`,
|
||||
)
|
||||
).run(deletedUsername, userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
|
||||
VALUES(?,?,?,?,0,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
display_name_key=excluded.display_name_key,
|
||||
bio_markdown='',
|
||||
is_public=0,
|
||||
bio_updated_at=excluded.bio_updated_at,
|
||||
updated_at=excluded.updated_at`,
|
||||
)
|
||||
).run(
|
||||
userId,
|
||||
deletedDisplayName,
|
||||
displayNameKey(deletedDisplayName),
|
||||
'',
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const avatar = await (
|
||||
await transaction.prepare(
|
||||
'SELECT source,value FROM user_avatars WHERE user_id=? LIMIT 1',
|
||||
)
|
||||
).get(userId);
|
||||
const avatarCleanup: AvatarCleanup =
|
||||
avatar?.source === 'upload'
|
||||
? typeof avatar.value === 'string' &&
|
||||
AVATAR_FILE_PATTERN.test(avatar.value)
|
||||
? { key: avatar.value }
|
||||
: { invalid: true }
|
||||
: null;
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM user_avatars WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE avatar_reviews SET status='removed',reviewed_at=COALESCE(reviewed_at,?) WHERE user_id=? AND status='pending'",
|
||||
)
|
||||
).run(now, userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'DELETE FROM webauthn_challenges WHERE user_id=?',
|
||||
)
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM passkeys WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM user_emails WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM oauth_states WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM discord_profiles WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(userId);
|
||||
|
||||
// Overwrite ciphertext and key metadata before deleting factor rows.
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',encrypted_secret=?,encryption_key_id='erased',revoked_at=? WHERE user_id=?",
|
||||
)
|
||||
).run(`erased:${randomBytes(32).toString('base64url')}`, now, userId);
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM mfa_totp_factors WHERE user_id=?')
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'DELETE FROM mfa_recovery_codes WHERE user_id=?',
|
||||
)
|
||||
).run(userId);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'DELETE FROM authentication_transactions WHERE user_id=?',
|
||||
)
|
||||
).run(userId);
|
||||
|
||||
if (purgeSecurityData) {
|
||||
await (
|
||||
await transaction.prepare('DELETE FROM login_events WHERE user_id=?')
|
||||
).run(userId);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: deletedBy,
|
||||
category: 'user_access',
|
||||
action: 'account.soft_deleted',
|
||||
entityId: userId,
|
||||
before: purgeSecurityData
|
||||
? { purgedAtOwnerRequest: true }
|
||||
: {
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
},
|
||||
after: {
|
||||
username: deletedUsername,
|
||||
displayName: 'Deleted Agent',
|
||||
reason,
|
||||
securityDataPurged: purgeSecurityData,
|
||||
mfaFactorsPurged: true,
|
||||
recoveryCodesPurged: true,
|
||||
authenticationTransactionsPurged: true,
|
||||
},
|
||||
database: transaction as unknown as Database,
|
||||
createdAt: now,
|
||||
});
|
||||
return { deleted: true, avatarCleanup };
|
||||
});
|
||||
const committed = await deletion.immediate();
|
||||
if (!committed) return false;
|
||||
if (committed.avatarCleanup) {
|
||||
try {
|
||||
if ('invalid' in committed.avatarCleanup)
|
||||
throw new Error('invalid avatar storage key');
|
||||
await unlink(
|
||||
path.join(avatarStorageDirectory(), committed.avatarCleanup.key),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error(
|
||||
'[account-deletion] committed deletion; uploaded avatar cleanup failed.',
|
||||
{ userId: userId.slice(0, 200) },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
export const AUTHENTICATION_TRANSACTION_COOKIE = 'shd_auth_tx';
|
||||
export const AUTHENTICATION_TRANSACTION_COOKIE_PATH = '/auth/two-factor';
|
||||
export const AUTHENTICATION_TRANSACTION_SECONDS = 5 * 60;
|
||||
export const AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT = 5;
|
||||
|
||||
export const AUTHENTICATION_TRANSACTION_PROVIDERS = [
|
||||
'password',
|
||||
'discord',
|
||||
'magic_link',
|
||||
'passkey',
|
||||
] as const;
|
||||
export type AuthenticationTransactionProvider =
|
||||
(typeof AUTHENTICATION_TRANSACTION_PROVIDERS)[number];
|
||||
export type AuthenticationTransactionKind = 'login' | 'step_up';
|
||||
export type AuthenticationTransactionDatabase = Pick<
|
||||
Database,
|
||||
'prepare' | 'transactionAsync'
|
||||
>;
|
||||
export type AuthenticationTransactionTransaction = Pick<Database, 'prepare'>;
|
||||
|
||||
export type AuthenticationTransaction = {
|
||||
id: string;
|
||||
userId: string;
|
||||
provider: AuthenticationTransactionProvider | null;
|
||||
kind: AuthenticationTransactionKind;
|
||||
returnPath: string;
|
||||
requiredAal: 2;
|
||||
attemptCount: number;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
type AuthenticationTransactionRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
provider: AuthenticationTransactionProvider | null;
|
||||
kind: AuthenticationTransactionKind;
|
||||
return_path: string;
|
||||
required_aal: 2;
|
||||
attempt_count: number;
|
||||
expires_at: string;
|
||||
consumed_at: string | null;
|
||||
};
|
||||
|
||||
export class AuthenticationTransactionError extends Error {
|
||||
constructor(message = 'Unable to create the authentication transaction.') {
|
||||
super(message);
|
||||
this.name = 'AuthenticationTransactionError';
|
||||
}
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function databaseOrDefault(
|
||||
database?: AuthenticationTransactionDatabase,
|
||||
): Promise<AuthenticationTransactionDatabase> {
|
||||
return database
|
||||
? Promise.resolve(database)
|
||||
: (getDatabase() as Promise<AuthenticationTransactionDatabase>);
|
||||
}
|
||||
|
||||
function nowDate(value: Date | string | number | undefined): Date {
|
||||
const date =
|
||||
value instanceof Date ? new Date(value) : new Date(value ?? Date.now());
|
||||
if (!Number.isFinite(date.getTime()))
|
||||
throw new AuthenticationTransactionError();
|
||||
return date;
|
||||
}
|
||||
|
||||
/** Accept only a same-origin path, never an absolute or protocol-relative URL. */
|
||||
export function validateAuthenticationReturnPath(value: string): string | null {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 2048 ||
|
||||
!value.startsWith('/') ||
|
||||
value.startsWith('//') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('#') ||
|
||||
/[\u0000-\u001f\u007f]/u.test(value)
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
const decoded = decodeURIComponent(value);
|
||||
if (
|
||||
decoded.startsWith('//') ||
|
||||
decoded.includes('\\') ||
|
||||
/[\u0000-\u001f\u007f]/u.test(decoded)
|
||||
)
|
||||
return null;
|
||||
const parsed = new URL(value, 'https://shd.invalid');
|
||||
if (
|
||||
parsed.origin !== 'https://shd.invalid' ||
|
||||
!parsed.pathname.startsWith('/')
|
||||
)
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
path: AUTHENTICATION_TRANSACTION_COOKIE_PATH,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: !dev,
|
||||
maxAge: AUTHENTICATION_TRANSACTION_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAuthenticationTransactionCookie(cookies: Cookies): void {
|
||||
cookies.delete(AUTHENTICATION_TRANSACTION_COOKIE, {
|
||||
path: AUTHENTICATION_TRANSACTION_COOKIE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
function fromRow(row: AuthenticationTransactionRow): AuthenticationTransaction {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.user_id),
|
||||
provider: row.provider ?? null,
|
||||
kind: row.kind,
|
||||
returnPath: String(row.return_path),
|
||||
requiredAal: 2,
|
||||
attemptCount: Number(row.attempt_count),
|
||||
expiresAt: String(row.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAuthenticationTransaction(
|
||||
cookies: Cookies,
|
||||
databaseInput: AuthenticationTransactionDatabase | undefined,
|
||||
options: {
|
||||
userId: string;
|
||||
kind: AuthenticationTransactionKind;
|
||||
provider?: AuthenticationTransactionProvider | null;
|
||||
returnPath: string;
|
||||
now?: Date | string | number;
|
||||
},
|
||||
): Promise<AuthenticationTransaction> {
|
||||
const returnPath = validateAuthenticationReturnPath(options.returnPath);
|
||||
if (
|
||||
!options.userId ||
|
||||
!returnPath ||
|
||||
(options.kind === 'login' && !options.provider)
|
||||
)
|
||||
throw new AuthenticationTransactionError();
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const now = nowDate(options.now);
|
||||
const createdAt = now.toISOString();
|
||||
const expiresAt = new Date(
|
||||
now.getTime() + AUTHENTICATION_TRANSACTION_SECONDS * 1000,
|
||||
).toISOString();
|
||||
const id = randomUUID();
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO authentication_transactions(
|
||||
id,token_hash,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,created_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
id,
|
||||
hashToken(token),
|
||||
options.userId,
|
||||
options.provider ?? null,
|
||||
options.kind,
|
||||
returnPath,
|
||||
2,
|
||||
0,
|
||||
expiresAt,
|
||||
createdAt,
|
||||
);
|
||||
cookies.set(AUTHENTICATION_TRANSACTION_COOKIE, token, cookieOptions());
|
||||
return {
|
||||
id,
|
||||
userId: options.userId,
|
||||
provider: options.provider ?? null,
|
||||
kind: options.kind,
|
||||
returnPath,
|
||||
requiredAal: 2,
|
||||
attemptCount: 0,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const beginAuthenticationTransaction = createAuthenticationTransaction;
|
||||
|
||||
export async function getAuthenticationTransaction(
|
||||
cookies: Cookies,
|
||||
databaseInput?: AuthenticationTransactionDatabase,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<AuthenticationTransaction | null> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return null;
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const row = (await (
|
||||
await database.prepare(
|
||||
`SELECT id,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,consumed_at
|
||||
FROM authentication_transactions
|
||||
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
||||
)
|
||||
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
||||
AuthenticationTransactionRow | undefined;
|
||||
if (!row) {
|
||||
clearAuthenticationTransactionCookie(cookies);
|
||||
return null;
|
||||
}
|
||||
return fromRow(row);
|
||||
}
|
||||
|
||||
/** Atomically account for one failed factor attempt. */
|
||||
export async function recordAuthenticationAttempt(
|
||||
cookies: Cookies,
|
||||
databaseInput?: AuthenticationTransactionDatabase,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<{ accepted: boolean; attemptCount: number; locked: boolean }> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return { accepted: false, attemptCount: 0, locked: false };
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const attempt = database.transactionAsync(async (transaction) => {
|
||||
const row = (await (
|
||||
await transaction.prepare(
|
||||
'SELECT id,attempt_count FROM authentication_transactions WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?',
|
||||
)
|
||||
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
||||
{ id: string; attempt_count: number } | undefined;
|
||||
if (!row) return null;
|
||||
const updated = await (
|
||||
await transaction.prepare(
|
||||
'UPDATE authentication_transactions SET attempt_count=attempt_count+1 WHERE id=? AND consumed_at IS NULL AND attempt_count<?',
|
||||
)
|
||||
).run(row.id, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT);
|
||||
return updated.changes === 1 ? Number(row.attempt_count) + 1 : null;
|
||||
});
|
||||
const attemptCount = await attempt.immediate();
|
||||
if (attemptCount === null) {
|
||||
clearAuthenticationTransactionCookie(cookies);
|
||||
return { accepted: false, attemptCount: 0, locked: true };
|
||||
}
|
||||
return {
|
||||
accepted: true,
|
||||
attemptCount,
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a transaction while the caller's immediate transaction holds the
|
||||
* write claim. The conditional consume must happen before that transaction
|
||||
* commits.
|
||||
*/
|
||||
export async function claimAuthenticationTransactionInTransaction(
|
||||
transaction: AuthenticationTransactionTransaction,
|
||||
cookies: Cookies,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<AuthenticationTransaction | null> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return null;
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const row = (await (
|
||||
await transaction.prepare(
|
||||
`SELECT id,user_id,provider,kind,return_path,required_aal,attempt_count,expires_at,consumed_at
|
||||
FROM authentication_transactions
|
||||
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
||||
)
|
||||
).get(hashToken(token), now, AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT)) as
|
||||
AuthenticationTransactionRow | undefined;
|
||||
return row ? fromRow(row) : null;
|
||||
}
|
||||
|
||||
/** Conditionally consume a transaction already loaded in this transaction. */
|
||||
export async function consumeAuthenticationTransactionInTransaction(
|
||||
transaction: AuthenticationTransactionTransaction,
|
||||
cookies: Cookies,
|
||||
transactionId: string,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<boolean> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return false;
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const consumed = await (
|
||||
await transaction.prepare(
|
||||
`UPDATE authentication_transactions SET consumed_at=?
|
||||
WHERE id=? AND token_hash=? AND consumed_at IS NULL AND expires_at>? AND attempt_count<?`,
|
||||
)
|
||||
).run(
|
||||
now,
|
||||
transactionId,
|
||||
hashToken(token),
|
||||
now,
|
||||
AUTHENTICATION_TRANSACTION_ATTEMPT_LIMIT,
|
||||
);
|
||||
return consumed.changes === 1;
|
||||
}
|
||||
|
||||
/** Consume a transaction in one conditional write; replay cannot succeed. */
|
||||
export async function consumeAuthenticationTransaction(
|
||||
cookies: Cookies,
|
||||
databaseInput?: AuthenticationTransactionDatabase,
|
||||
nowValue?: Date | string | number,
|
||||
): Promise<AuthenticationTransaction | null> {
|
||||
const token = cookies.get(AUTHENTICATION_TRANSACTION_COOKIE);
|
||||
if (!token) return null;
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const now = nowDate(nowValue).toISOString();
|
||||
const consume = database.transactionAsync(async (transaction) => {
|
||||
const row = await claimAuthenticationTransactionInTransaction(
|
||||
transaction,
|
||||
cookies,
|
||||
now,
|
||||
);
|
||||
if (!row) return null;
|
||||
const consumed = await consumeAuthenticationTransactionInTransaction(
|
||||
transaction,
|
||||
cookies,
|
||||
row.id,
|
||||
now,
|
||||
);
|
||||
return consumed ? row : null;
|
||||
});
|
||||
const result = await consume.immediate();
|
||||
clearAuthenticationTransactionCookie(cookies);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const consumeAuthTransaction = consumeAuthenticationTransaction;
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: mocks.audit,
|
||||
}));
|
||||
|
||||
import { applyAuthorityChange } from './authority-change';
|
||||
import type { AuthUser } from './session';
|
||||
|
||||
const operator = {
|
||||
id: 'operator-1',
|
||||
aal: 2,
|
||||
authLevel: 2,
|
||||
aal2VerifiedAt: new Date().toISOString(),
|
||||
} as AuthUser;
|
||||
|
||||
async function createDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users(id TEXT PRIMARY KEY, role TEXT NOT NULL);
|
||||
CREATE TABLE roles(id TEXT PRIMARY KEY);
|
||||
CREATE TABLE permissions(id TEXT PRIMARY KEY, name TEXT NOT NULL);
|
||||
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 user_permissions(user_id TEXT, permission_id 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 sessions(id TEXT PRIMARY KEY, user_id TEXT);
|
||||
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);
|
||||
INSERT INTO users VALUES('operator-1','user'),('target-1','user');
|
||||
INSERT INTO roles VALUES('role-admin'),('role-user');
|
||||
INSERT INTO permissions VALUES('permission-system-admin','system.admin'),('permission-users-view','users.view');
|
||||
INSERT INTO role_permissions VALUES('role-admin','permission-system-admin'),('role-user','permission-users-view');
|
||||
INSERT INTO user_roles VALUES('operator-1','role-admin');
|
||||
INSERT INTO mfa_totp_factors VALUES('operator-factor','operator-1','active');
|
||||
INSERT INTO sessions VALUES('target-session','target-1');
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
const access = (roleIds: string[], permissionIds: string[]) => ({
|
||||
roleIds,
|
||||
permissionIds,
|
||||
});
|
||||
|
||||
describe('shared authority changes', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
mocks.audit.mockReset();
|
||||
mocks.audit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('cannot revert an administrator grant by removing the final active authority', async () => {
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'operator-1',
|
||||
...access([], []),
|
||||
expectedCurrent: access(['role-admin'], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('operator-1');
|
||||
expect(role.role_id).toBe('role-admin');
|
||||
});
|
||||
|
||||
it('requires an MFA-ready target when reversion restores administrator authority', async () => {
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('target-1');
|
||||
expect(role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires recent AAL2 authentication for administrator reversion', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator: {
|
||||
...operator,
|
||||
aal2VerifiedAt: new Date(Date.now() - 11 * 60_000).toISOString(),
|
||||
} as never,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('revokes sessions and appends reversion audit evidence in the transaction', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
const result = await applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
audit: {
|
||||
actorId: 'operator-1',
|
||||
category: 'audit_control',
|
||||
action: 'audit.reverted',
|
||||
entityId: 'event-1',
|
||||
before: access([], []),
|
||||
after: {
|
||||
originalEventId: 'event-1',
|
||||
restored: access(['role-admin'], []),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.after).toEqual(access(['role-admin'], []));
|
||||
expect(
|
||||
await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get(),
|
||||
).toMatchObject({ count: 0 });
|
||||
expect(mocks.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'audit.reverted',
|
||||
entityId: 'event-1',
|
||||
database,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back authority and session changes when audit append fails', async () => {
|
||||
await database.exec(`
|
||||
INSERT INTO mfa_totp_factors VALUES('target-factor','target-1','active');
|
||||
INSERT INTO mfa_recovery_codes VALUES('target-code','target-1',NULL,NULL);
|
||||
`);
|
||||
mocks.audit.mockRejectedValueOnce(new Error('audit failed'));
|
||||
|
||||
await expect(
|
||||
applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: 'target-1',
|
||||
...access(['role-admin'], []),
|
||||
expectedCurrent: access([], []),
|
||||
}),
|
||||
).rejects.toThrow('audit failed');
|
||||
|
||||
const role = await (
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).get('target-1');
|
||||
const sessions = await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get();
|
||||
expect(role).toBeUndefined();
|
||||
expect(Number(sessions.count)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { AuthUser } from './session';
|
||||
import { hasRecentAal2, RECENT_AAL2_WINDOW_MS } from './authorization';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
countActiveFullAdministrators,
|
||||
hasActiveTotpFactor,
|
||||
isFullAdministrator,
|
||||
isMfaReady,
|
||||
} from './bootstrap';
|
||||
|
||||
export type AuthoritySnapshot = {
|
||||
roleIds: string[];
|
||||
permissionIds: string[];
|
||||
};
|
||||
|
||||
type AuditValue = Record<string, unknown> | unknown[] | null;
|
||||
|
||||
export type AuthorityAudit = {
|
||||
actorId: string;
|
||||
category: string;
|
||||
action: string;
|
||||
entityId: string;
|
||||
before?: AuditValue;
|
||||
after?: AuditValue;
|
||||
};
|
||||
|
||||
export type AuthorityChangeInput = {
|
||||
database: Database;
|
||||
operator: AuthUser;
|
||||
targetId: string;
|
||||
roleIds: string[];
|
||||
permissionIds: string[];
|
||||
expectedCurrent?: AuthoritySnapshot;
|
||||
audit?: AuthorityAudit;
|
||||
};
|
||||
|
||||
export type AuthorityChangeResult = {
|
||||
before: AuthoritySnapshot;
|
||||
after: AuthoritySnapshot;
|
||||
sessionsRevoked: number;
|
||||
};
|
||||
|
||||
export class AuthorityChangeError extends Error {
|
||||
readonly status: 400 | 403 | 404 | 409;
|
||||
|
||||
constructor(status: 400 | 403 | 404 | 409, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthorityChangeError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = (values: string[]) => [...values].sort();
|
||||
|
||||
function sameSnapshot(
|
||||
left: AuthoritySnapshot,
|
||||
right: AuthoritySnapshot,
|
||||
): boolean {
|
||||
return (
|
||||
JSON.stringify({
|
||||
roleIds: sorted(left.roleIds),
|
||||
permissionIds: sorted(left.permissionIds),
|
||||
}) ===
|
||||
JSON.stringify({
|
||||
roleIds: sorted(right.roleIds),
|
||||
permissionIds: sorted(right.permissionIds),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isRecentAal2(user: AuthUser): boolean {
|
||||
return hasRecentAal2(user, Date.now());
|
||||
}
|
||||
|
||||
export async function applyAuthorityChange(
|
||||
input: AuthorityChangeInput,
|
||||
): Promise<AuthorityChangeResult> {
|
||||
const database = input.database;
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
let transactionOpen = true;
|
||||
const rollback = async () => {
|
||||
if (!transactionOpen) return;
|
||||
transactionOpen = false;
|
||||
await database.exec('ROLLBACK');
|
||||
};
|
||||
|
||||
try {
|
||||
const operatorIsFullAdministrator =
|
||||
(await isFullAdministrator(database, input.operator.id)) &&
|
||||
input.operator.aal === 2 &&
|
||||
(await hasActiveTotpFactor(database, input.operator.id));
|
||||
const target = await (
|
||||
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||
).get(input.targetId);
|
||||
if (!target) throw new AuthorityChangeError(404, 'User not found.');
|
||||
|
||||
const roleIds = [...new Set(input.roleIds.map(String))];
|
||||
const permissionIds = [...new Set(input.permissionIds.map(String))];
|
||||
const validRoles = roleIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...roleIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
const validPermissionRows = permissionIds.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...permissionIds)
|
||||
: [];
|
||||
if (
|
||||
validRoles !== roleIds.length ||
|
||||
validPermissionRows.length !== permissionIds.length
|
||||
)
|
||||
throw new AuthorityChangeError(
|
||||
400,
|
||||
'One or more access selections are invalid.',
|
||||
);
|
||||
|
||||
const before: AuthoritySnapshot = {
|
||||
roleIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(input.targetId)
|
||||
).map((row) => String(row.role_id)),
|
||||
permissionIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||||
)
|
||||
).all(input.targetId)
|
||||
).map((row) => String(row.permission_id)),
|
||||
};
|
||||
if (input.expectedCurrent && !sameSnapshot(before, input.expectedCurrent))
|
||||
throw new AuthorityChangeError(
|
||||
409,
|
||||
'The user’s access changed after this event. Reversion was blocked.',
|
||||
);
|
||||
|
||||
const currentRoleSystemAdmin = Boolean(
|
||||
await (
|
||||
await database.prepare(`
|
||||
SELECT 1
|
||||
FROM user_roles
|
||||
JOIN role_permissions ON role_permissions.role_id=user_roles.role_id
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE user_roles.user_id=? AND permissions.name='system.admin'
|
||||
LIMIT 1
|
||||
`)
|
||||
).get(input.targetId),
|
||||
);
|
||||
const currentDirectSystemAdmin = before.permissionIds.includes(
|
||||
'permission-system-admin',
|
||||
);
|
||||
const currentSystemAdmin =
|
||||
currentRoleSystemAdmin || currentDirectSystemAdmin;
|
||||
const requestedDirectSystemAdmin = validPermissionRows.some(
|
||||
(permission) => String(permission.name) === 'system.admin',
|
||||
);
|
||||
const requestedRoleSystemAdmin = Boolean(
|
||||
roleIds.length &&
|
||||
(await (
|
||||
await database.prepare(`
|
||||
SELECT 1
|
||||
FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE role_permissions.role_id IN (${roleIds.map(() => '?').join(',')})
|
||||
AND permissions.name='system.admin'
|
||||
LIMIT 1
|
||||
`)
|
||||
).get(...roleIds)),
|
||||
);
|
||||
const requestedSystemAdmin =
|
||||
requestedDirectSystemAdmin || requestedRoleSystemAdmin;
|
||||
const requestedCanonicalAdministrator = roleIds.includes('role-admin');
|
||||
const targetIsFullAdministrator = await isFullAdministrator(
|
||||
database,
|
||||
input.targetId,
|
||||
);
|
||||
const targetHadAdministratorAuthority =
|
||||
targetIsFullAdministrator || before.roleIds.includes('role-admin');
|
||||
const changesAdministratorAccess =
|
||||
targetIsFullAdministrator ||
|
||||
currentSystemAdmin !== requestedSystemAdmin ||
|
||||
currentDirectSystemAdmin !== requestedDirectSystemAdmin ||
|
||||
requestedCanonicalAdministrator !== before.roleIds.includes('role-admin');
|
||||
|
||||
if (changesAdministratorAccess && !operatorIsFullAdministrator)
|
||||
throw new AuthorityChangeError(
|
||||
403,
|
||||
'Only a full administrator can grant, remove, or modify administrator access.',
|
||||
);
|
||||
if (changesAdministratorAccess && !isRecentAal2(input.operator))
|
||||
throw new AuthorityChangeError(
|
||||
403,
|
||||
`Recent AAL2 authentication is required for administrator changes within ${RECENT_AAL2_WINDOW_MS / 60_000} minutes.`,
|
||||
);
|
||||
if (
|
||||
(requestedSystemAdmin || requestedCanonicalAdministrator) &&
|
||||
(!targetHadAdministratorAuthority ||
|
||||
(requestedDirectSystemAdmin && !currentDirectSystemAdmin)) &&
|
||||
!(await isMfaReady(database, input.targetId))
|
||||
)
|
||||
throw new AuthorityChangeError(
|
||||
400,
|
||||
'The target must have an active TOTP factor and an unused recovery code before receiving administrator authority.',
|
||||
);
|
||||
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(input.targetId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(input.targetId);
|
||||
for (const roleId of roleIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
||||
)
|
||||
).run(input.targetId, roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(input.targetId, permissionId);
|
||||
|
||||
let sessionsRevoked = 0;
|
||||
if (changesAdministratorAccess) {
|
||||
const revoked = await (
|
||||
await database.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(input.targetId);
|
||||
sessionsRevoked = Number(revoked.changes ?? 0);
|
||||
}
|
||||
if ((await countActiveFullAdministrators(database)) < 1)
|
||||
throw new AuthorityChangeError(
|
||||
409,
|
||||
'The final active full administrator cannot lose administrator access.',
|
||||
);
|
||||
|
||||
const after: AuthoritySnapshot = { roleIds, permissionIds };
|
||||
const audit = input.audit ?? {
|
||||
actorId: input.operator.id,
|
||||
category: 'user_access',
|
||||
action: 'access.updated',
|
||||
entityId: input.targetId,
|
||||
before,
|
||||
after,
|
||||
};
|
||||
await recordIsacAudit({
|
||||
...audit,
|
||||
before: audit.before === undefined ? before : audit.before,
|
||||
after: audit.after === undefined ? after : audit.after,
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
transactionOpen = false;
|
||||
return { before, after, sessionsRevoked };
|
||||
} catch (error) {
|
||||
await rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
database: { prepare: vi.fn() },
|
||||
isFullAdministrator: vi.fn(),
|
||||
hasActiveTotpFactor: vi.fn(),
|
||||
isMfaReady: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
vi.mock('./bootstrap', () => ({
|
||||
isFullAdministrator: mocks.isFullAdministrator,
|
||||
hasActiveTotpFactor: mocks.hasActiveTotpFactor,
|
||||
isMfaReady: mocks.isMfaReady,
|
||||
}));
|
||||
|
||||
import {
|
||||
getPermissions,
|
||||
hasRecentAal2,
|
||||
requireRecentAal2,
|
||||
} from './authorization';
|
||||
|
||||
const user = (aal: 1 | 2, aal2VerifiedAt: string | null = null) =>
|
||||
({
|
||||
id: 'user-1',
|
||||
aal,
|
||||
authLevel: aal,
|
||||
aal2VerifiedAt,
|
||||
}) as never;
|
||||
|
||||
describe('privileged authorization assurance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isFullAdministrator.mockResolvedValue(false);
|
||||
mocks.hasActiveTotpFactor.mockResolvedValue(false);
|
||||
mocks.database.prepare.mockResolvedValue({
|
||||
all: vi.fn(async () => [
|
||||
{ name: 'system.admin' },
|
||||
{ name: 'users.view' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not turn an AAL1 or unenrolled administrator into wildcard access', async () => {
|
||||
mocks.isFullAdministrator.mockResolvedValue(true);
|
||||
const permissions = await getPermissions(user(1));
|
||||
expect(permissions).toContain('users.view');
|
||||
expect(permissions).toContain('isac.access');
|
||||
expect(permissions).not.toContain('*');
|
||||
expect(permissions).not.toContain('system.admin');
|
||||
});
|
||||
|
||||
it('requires both canonical administration, active TOTP, and AAL2 for wildcard access', async () => {
|
||||
mocks.isFullAdministrator.mockResolvedValue(true);
|
||||
mocks.hasActiveTotpFactor.mockResolvedValue(true);
|
||||
const permissions = await getPermissions(user(2));
|
||||
expect(permissions).toEqual(['*']);
|
||||
});
|
||||
|
||||
it('does not treat a direct system.admin grant as unrestricted access', async () => {
|
||||
const permissions = await getPermissions(user(2));
|
||||
expect(permissions).toContain('users.view');
|
||||
expect(permissions).not.toContain('*');
|
||||
expect(permissions).not.toContain('system.admin');
|
||||
});
|
||||
|
||||
it('recognizes only AAL2 evidence from the last ten minutes', () => {
|
||||
const now = Date.parse('2026-08-07T12:00:00.000Z');
|
||||
expect(hasRecentAal2(user(1, '2026-08-07T11:59:00.000Z'), now)).toBe(false);
|
||||
expect(hasRecentAal2(user(2, '2026-08-07T11:59:00.000Z'), now)).toBe(true);
|
||||
expect(hasRecentAal2(user(2, '2026-08-07T11:49:59.999Z'), now)).toBe(false);
|
||||
expect(() =>
|
||||
requireRecentAal2(user(1, '2026-08-07T11:59:00.000Z'), now),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { AuthUser } from './session';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import {
|
||||
hasActiveTotpFactor,
|
||||
isFullAdministrator,
|
||||
isMfaReady,
|
||||
} from './bootstrap';
|
||||
|
||||
export const RECENT_AAL2_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
const REGISTERED_AGENT_PERMISSIONS = [
|
||||
'isac.access',
|
||||
@@ -10,8 +17,14 @@ const REGISTERED_AGENT_PERMISSIONS = [
|
||||
|
||||
export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
||||
if (!user) return [];
|
||||
if (user.role === 'admin') return ['*'];
|
||||
const database = await getDatabase();
|
||||
const canonicalAdministrator = await isFullAdministrator(database, user.id);
|
||||
if (
|
||||
canonicalAdministrator &&
|
||||
user.aal === 2 &&
|
||||
(await hasActiveTotpFactor(database, user.id))
|
||||
)
|
||||
return ['*'];
|
||||
const rows = await (
|
||||
await database.prepare(`
|
||||
SELECT permissions.name FROM permissions
|
||||
@@ -27,10 +40,12 @@ export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
||||
const permissions = [
|
||||
...new Set([
|
||||
...REGISTERED_AGENT_PERMISSIONS,
|
||||
...rows.map((row) => String(row.name)),
|
||||
...rows
|
||||
.map((row) => String(row.name))
|
||||
.filter((permission) => permission !== 'system.admin'),
|
||||
]),
|
||||
];
|
||||
return permissions.includes('system.admin') ? ['*'] : permissions;
|
||||
return permissions;
|
||||
}
|
||||
|
||||
export async function hasPermission(
|
||||
@@ -67,7 +82,60 @@ export async function requireDataView(
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: AuthUser | null): asserts user is AuthUser {
|
||||
export async function requireAdmin(user: AuthUser | null): Promise<AuthUser> {
|
||||
if (!user) error(401, 'Sign in required');
|
||||
if (user.role !== 'admin') error(403, 'Administrator permission required');
|
||||
const database = await getDatabase();
|
||||
if (!(await isFullAdministrator(database, user.id)))
|
||||
error(403, 'Administrator permission required');
|
||||
if (!(await hasActiveTotpFactor(database, user.id)))
|
||||
error(403, 'Administrator MFA enrollment required');
|
||||
requireAal2(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export function hasAal2(user: AuthUser | null): boolean {
|
||||
return user?.aal === 2;
|
||||
}
|
||||
|
||||
export function hasRecentAal2(
|
||||
user: AuthUser | null,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
if (!hasAal2(user) || !user?.aal2VerifiedAt) return false;
|
||||
const verifiedAt = Date.parse(user.aal2VerifiedAt);
|
||||
return (
|
||||
Number.isFinite(verifiedAt) &&
|
||||
verifiedAt <= now &&
|
||||
now - verifiedAt <= RECENT_AAL2_WINDOW_MS
|
||||
);
|
||||
}
|
||||
|
||||
export function requireAal2(user: AuthUser | null): AuthUser {
|
||||
if (!user) error(401, 'Sign in required');
|
||||
if (!hasAal2(user)) error(403, 'AAL2 authentication required');
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireRecentAal2(
|
||||
user: AuthUser | null,
|
||||
now = Date.now(),
|
||||
): AuthUser {
|
||||
if (!user) error(401, 'Sign in required');
|
||||
if (!hasRecentAal2(user, now))
|
||||
error(403, 'Recent AAL2 authentication required');
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function isAdministratorMfaReady(
|
||||
user: AuthUser | null,
|
||||
): Promise<boolean> {
|
||||
if (!user) return false;
|
||||
return isMfaReady(await getDatabase(), user.id);
|
||||
}
|
||||
|
||||
export async function targetCanReceiveAdministratorAuthority(
|
||||
database: Parameters<typeof isMfaReady>[0],
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
return isMfaReady(database, userId);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,91 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
|
||||
/**
|
||||
* Atomically promotes the authenticated user when no administrator exists.
|
||||
* The conditional UPDATE ensures concurrent first sign-ins cannot both win.
|
||||
* Returns whether the account has the canonical unrestricted administrator
|
||||
* relationship. The role column is a legacy projection and is not authority.
|
||||
* Direct system.admin grants are intentionally not treated as full
|
||||
* administration; they cannot bootstrap or escalate an account.
|
||||
*/
|
||||
export async function ensureBootstrapAdmin(
|
||||
export async function isFullAdministrator(
|
||||
database: Database,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const promote = await database.prepare(`
|
||||
UPDATE users
|
||||
SET role = 'admin'
|
||||
WHERE id = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM users WHERE role = 'admin'
|
||||
)
|
||||
`);
|
||||
await promote.run(userId);
|
||||
): Promise<boolean> {
|
||||
const row = await (
|
||||
await database.prepare(`
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id=?
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_roles
|
||||
WHERE user_roles.user_id=users.id
|
||||
AND user_roles.role_id='role-admin'
|
||||
)
|
||||
LIMIT 1
|
||||
`)
|
||||
).get(userId);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/** Count active accounts holding the canonical unrestricted relationship. */
|
||||
export async function countActiveFullAdministrators(
|
||||
database: Database,
|
||||
now = new Date(),
|
||||
): Promise<number> {
|
||||
const row = 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.toISOString());
|
||||
const count = Number(row?.count);
|
||||
if (!Number.isSafeInteger(count) || count < 0)
|
||||
throw new Error('Unable to determine active administrator count.');
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function hasActiveTotpFactor(
|
||||
database: Database,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
"SELECT 1 FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
|
||||
)
|
||||
).get(userId);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function hasUnusedRecoveryCode(
|
||||
database: Database,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT 1 FROM mfa_recovery_codes WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL LIMIT 1',
|
||||
)
|
||||
).get(userId);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function isMfaReady(
|
||||
database: Database,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await hasActiveTotpFactor(database, userId)) &&
|
||||
(await hasUnusedRecoveryCode(database, userId))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
export const MFA_TOTP_PARAMETERS = {
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
periodSeconds: 30,
|
||||
} as const;
|
||||
|
||||
const ENVELOPE_VERSION = 'mfa1';
|
||||
const KEY_BYTES = 32;
|
||||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
|
||||
export type MfaEncryptionKey = {
|
||||
id: string;
|
||||
key: Buffer;
|
||||
};
|
||||
|
||||
export class MfaCryptoError extends Error {
|
||||
constructor() {
|
||||
super('Unable to protect the MFA secret.');
|
||||
this.name = 'MfaCryptoError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DecryptedTotpSecret = {
|
||||
secret: Buffer;
|
||||
keyId: string;
|
||||
needsRewrap: boolean;
|
||||
};
|
||||
|
||||
type TotpAadContext = {
|
||||
factorId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
function isCanonicalBase64Url(value: string): boolean {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) return false;
|
||||
try {
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
return (
|
||||
decoded.length === KEY_BYTES && decoded.toString('base64url') === value
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the app-only keyring. No fallback or generated key is permitted. */
|
||||
export function parseMfaTotpEncryptionKeys(raw: string): MfaEncryptionKey[] {
|
||||
const entries = raw.split(',').map((entry) => entry.trim());
|
||||
if (
|
||||
entries.length < 1 ||
|
||||
entries.length > 3 ||
|
||||
entries.some((entry) => !entry)
|
||||
)
|
||||
throw new MfaCryptoError();
|
||||
|
||||
const ids = new Set<string>();
|
||||
const encodedKeys = new Set<string>();
|
||||
const keys: MfaEncryptionKey[] = [];
|
||||
for (const entry of entries) {
|
||||
const separator = entry.indexOf(':');
|
||||
const id = separator > 0 ? entry.slice(0, separator) : '';
|
||||
const encoded = separator > 0 ? entry.slice(separator + 1) : '';
|
||||
if (
|
||||
!/^[A-Za-z0-9_-]{1,64}$/.test(id) ||
|
||||
encoded.length !== 43 ||
|
||||
!isCanonicalBase64Url(encoded) ||
|
||||
ids.has(id) ||
|
||||
encodedKeys.has(encoded)
|
||||
)
|
||||
throw new MfaCryptoError();
|
||||
ids.add(id);
|
||||
encodedKeys.add(encoded);
|
||||
keys.push({ id, key: Buffer.from(encoded, 'base64url') });
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function getMfaTotpEncryptionKeys(): MfaEncryptionKey[] {
|
||||
const raw = env.MFA_TOTP_ENCRYPTION_KEYS?.trim();
|
||||
if (!raw) throw new MfaCryptoError();
|
||||
return parseMfaTotpEncryptionKeys(raw);
|
||||
}
|
||||
|
||||
function aadFor({ factorId, userId }: TotpAadContext): Buffer {
|
||||
if (!factorId || !userId) throw new MfaCryptoError();
|
||||
return Buffer.from(
|
||||
JSON.stringify({
|
||||
version: ENVELOPE_VERSION,
|
||||
factorId,
|
||||
userId,
|
||||
algorithm: MFA_TOTP_PARAMETERS.algorithm,
|
||||
digits: MFA_TOTP_PARAMETERS.digits,
|
||||
periodSeconds: MFA_TOTP_PARAMETERS.periodSeconds,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function activeKey(keys: MfaEncryptionKey[]): MfaEncryptionKey {
|
||||
const key = keys[0];
|
||||
if (!key || key.key.length !== KEY_BYTES) throw new MfaCryptoError();
|
||||
return key;
|
||||
}
|
||||
|
||||
function envelopePart(value: Buffer): string {
|
||||
return value.toString('base64url');
|
||||
}
|
||||
|
||||
function decodeEnvelope(envelope: string): {
|
||||
keyId: string;
|
||||
iv: Buffer;
|
||||
ciphertext: Buffer;
|
||||
tag: Buffer;
|
||||
} {
|
||||
const parts = envelope.split('.');
|
||||
if (parts.length !== 5 || parts[0] !== ENVELOPE_VERSION)
|
||||
throw new MfaCryptoError();
|
||||
const [, keyId, encodedIv, encodedCiphertext, encodedTag] = parts;
|
||||
if (
|
||||
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
|
||||
!encodedIv ||
|
||||
!encodedCiphertext ||
|
||||
!encodedTag
|
||||
)
|
||||
throw new MfaCryptoError();
|
||||
try {
|
||||
const iv = Buffer.from(encodedIv, 'base64url');
|
||||
const ciphertext = Buffer.from(encodedCiphertext, 'base64url');
|
||||
const tag = Buffer.from(encodedTag, 'base64url');
|
||||
if (
|
||||
iv.length !== IV_BYTES ||
|
||||
ciphertext.length !== 20 ||
|
||||
tag.length !== TAG_BYTES ||
|
||||
iv.toString('base64url') !== encodedIv ||
|
||||
ciphertext.toString('base64url') !== encodedCiphertext ||
|
||||
tag.toString('base64url') !== encodedTag
|
||||
)
|
||||
throw new MfaCryptoError();
|
||||
return { keyId, iv, ciphertext, tag };
|
||||
} catch {
|
||||
throw new MfaCryptoError();
|
||||
}
|
||||
}
|
||||
|
||||
function keyById(keys: MfaEncryptionKey[], id: string): MfaEncryptionKey {
|
||||
const key = keys.find((candidate) => candidate.id === id);
|
||||
if (!key || key.key.length !== KEY_BYTES) throw new MfaCryptoError();
|
||||
return key;
|
||||
}
|
||||
|
||||
export function encryptTotpSecret(
|
||||
context: TotpAadContext & { secret: Uint8Array },
|
||||
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
|
||||
): string {
|
||||
try {
|
||||
if (context.secret.length !== 20) throw new MfaCryptoError();
|
||||
const key = activeKey(keys);
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv('aes-256-gcm', key.key, iv);
|
||||
cipher.setAAD(aadFor(context));
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(context.secret),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [
|
||||
ENVELOPE_VERSION,
|
||||
key.id,
|
||||
envelopePart(iv),
|
||||
envelopePart(ciphertext),
|
||||
envelopePart(tag),
|
||||
].join('.');
|
||||
} catch (error) {
|
||||
if (error instanceof MfaCryptoError) throw error;
|
||||
throw new MfaCryptoError();
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptTotpSecret(
|
||||
context: TotpAadContext & { envelope: string },
|
||||
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
|
||||
): DecryptedTotpSecret {
|
||||
try {
|
||||
const envelope = decodeEnvelope(context.envelope);
|
||||
const key = keyById(keys, envelope.keyId);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key.key, envelope.iv);
|
||||
decipher.setAAD(aadFor(context));
|
||||
decipher.setAuthTag(envelope.tag);
|
||||
const secret = Buffer.concat([
|
||||
decipher.update(envelope.ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
if (secret.length !== 20) throw new MfaCryptoError();
|
||||
return {
|
||||
secret,
|
||||
keyId: key.id,
|
||||
needsRewrap: key.id !== activeKey(keys).id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof MfaCryptoError) throw error;
|
||||
throw new MfaCryptoError();
|
||||
}
|
||||
}
|
||||
|
||||
export function rewrapTotpSecret(
|
||||
context: TotpAadContext & { envelope: string },
|
||||
keys: MfaEncryptionKey[] = getMfaTotpEncryptionKeys(),
|
||||
): { envelope: string; previousKeyId: string; activeKeyId: string } {
|
||||
const decrypted = decryptTotpSecret(context, keys);
|
||||
const active = activeKey(keys);
|
||||
return {
|
||||
envelope: encryptTotpSecret(
|
||||
{
|
||||
factorId: context.factorId,
|
||||
userId: context.userId,
|
||||
secret: decrypted.secret,
|
||||
},
|
||||
keys,
|
||||
),
|
||||
previousKeyId: decrypted.keyId,
|
||||
activeKeyId: active.id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
import { Secret } from 'otpauth';
|
||||
|
||||
const { keyA, keyB } = vi.hoisted(() => ({
|
||||
keyA: Buffer.alloc(32, 0x11).toString('base64url'),
|
||||
keyB: Buffer.alloc(32, 0x22).toString('base64url'),
|
||||
}));
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: {
|
||||
MFA_TOTP_ENCRYPTION_KEYS: `active:${keyA},retained:${keyB}`,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
decryptTotpSecret,
|
||||
encryptTotpSecret,
|
||||
parseMfaTotpEncryptionKeys,
|
||||
rewrapTotpSecret,
|
||||
MfaCryptoError,
|
||||
} from './mfa-crypto';
|
||||
import {
|
||||
beginTotpEnrollment,
|
||||
generateTotpCode,
|
||||
verifyTotpFactor,
|
||||
} from './mfa-totp';
|
||||
import {
|
||||
consumeRecoveryCode,
|
||||
formatRecoveryCode,
|
||||
issueRecoveryCodes,
|
||||
normalizeRecoveryCode,
|
||||
} from './mfa-recovery';
|
||||
import {
|
||||
consumeAuthenticationTransaction,
|
||||
createAuthenticationTransaction,
|
||||
getAuthenticationTransaction,
|
||||
recordAuthenticationAttempt,
|
||||
validateAuthenticationReturnPath,
|
||||
} from './authentication-transactions';
|
||||
|
||||
const now = new Date('2026-08-07T12:00:00.000Z');
|
||||
|
||||
function cookieJar() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
values,
|
||||
get: (name: string) => values.get(name),
|
||||
set: vi.fn((name: string, value: string) => values.set(name, value)),
|
||||
delete: vi.fn((name: string) => values.delete(name)),
|
||||
};
|
||||
}
|
||||
|
||||
async function mfaDatabase(userId = 'user-1'): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users (id TEXT PRIMARY KEY);
|
||||
INSERT INTO users(id) VALUES ('${userId}');
|
||||
CREATE TABLE mfa_totp_factors (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, state TEXT NOT NULL,
|
||||
encrypted_secret TEXT NOT NULL, encryption_key_id TEXT NOT NULL,
|
||||
issuer TEXT NOT NULL, account_label TEXT NOT NULL, algorithm TEXT NOT NULL,
|
||||
digits INTEGER NOT NULL, period_seconds INTEGER NOT NULL,
|
||||
last_counter INTEGER, failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT, expires_at TEXT, created_at TEXT NOT NULL,
|
||||
activated_at TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE mfa_recovery_codes (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, consumed_at TEXT, revoked_at TEXT
|
||||
);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
async function transactionDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE authentication_transactions (
|
||||
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL,
|
||||
provider TEXT, kind TEXT NOT NULL, return_path TEXT NOT NULL, required_aal INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('MFA encryption foundation', () => {
|
||||
it('rejects malformed keyrings and reports rotation without exposing keys', () => {
|
||||
expect(() => parseMfaTotpEncryptionKeys('')).toThrow(MfaCryptoError);
|
||||
expect(() => parseMfaTotpEncryptionKeys(`one:${keyA},two:${keyA}`)).toThrow(
|
||||
MfaCryptoError,
|
||||
);
|
||||
expect(() =>
|
||||
parseMfaTotpEncryptionKeys(`one:${keyA}=,two:${keyB}`),
|
||||
).toThrow(MfaCryptoError);
|
||||
|
||||
const secret = Buffer.alloc(20, 0x33);
|
||||
const oldKeys = parseMfaTotpEncryptionKeys(`old:${keyB},active:${keyA}`);
|
||||
const envelope = encryptTotpSecret(
|
||||
{ factorId: 'factor-1', userId: 'user-1', secret },
|
||||
oldKeys,
|
||||
);
|
||||
const decrypted = decryptTotpSecret(
|
||||
{ factorId: 'factor-1', userId: 'user-1', envelope },
|
||||
parseMfaTotpEncryptionKeys(`active:${keyA},old:${keyB}`),
|
||||
);
|
||||
expect(decrypted.secret).toEqual(secret);
|
||||
expect(decrypted.needsRewrap).toBe(true);
|
||||
const rewrapped = rewrapTotpSecret(
|
||||
{ factorId: 'factor-1', userId: 'user-1', envelope },
|
||||
parseMfaTotpEncryptionKeys(`active:${keyA},old:${keyB}`),
|
||||
);
|
||||
expect(rewrapped.previousKeyId).toBe('old');
|
||||
expect(rewrapped.activeKeyId).toBe('active');
|
||||
expect(() =>
|
||||
decryptTotpSecret(
|
||||
{ factorId: 'factor-2', userId: 'user-1', envelope },
|
||||
oldKeys,
|
||||
),
|
||||
).toThrow(MfaCryptoError);
|
||||
expect(() =>
|
||||
decryptTotpSecret(
|
||||
{ factorId: 'factor-1', userId: 'user-1', envelope: `${envelope}x` },
|
||||
oldKeys,
|
||||
),
|
||||
).toThrow(MfaCryptoError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TOTP factors', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await mfaDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('uses the one-period window and atomically rejects counter replay', async () => {
|
||||
const enrollment = await beginTotpEnrollment(database, {
|
||||
userId: 'user-1',
|
||||
now,
|
||||
});
|
||||
const secret = Secret.fromBase32(enrollment.secretBase32).bytes;
|
||||
const nextPeriodCode = generateTotpCode(secret, now.getTime() + 30_000);
|
||||
const verified = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: nextPeriodCode,
|
||||
now,
|
||||
});
|
||||
expect(verified.status).toBe('verified');
|
||||
const replay = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: nextPeriodCode,
|
||||
now,
|
||||
});
|
||||
expect(replay.status).toBe('replayed');
|
||||
});
|
||||
|
||||
it('locks after five failures and expires pending enrollment', async () => {
|
||||
const enrollment = await beginTotpEnrollment(database, {
|
||||
userId: 'user-1',
|
||||
now,
|
||||
});
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: '000000',
|
||||
now,
|
||||
});
|
||||
}
|
||||
const locked = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: '000000',
|
||||
now,
|
||||
});
|
||||
expect(locked.status).toBe('locked');
|
||||
|
||||
const expired = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId: enrollment.factorId,
|
||||
token: '000000',
|
||||
now: new Date(now.getTime() + 11 * 60_000),
|
||||
});
|
||||
expect(expired.status).toBe('expired');
|
||||
});
|
||||
|
||||
it('rewraps a verified factor under the active encryption key atomically', async () => {
|
||||
const secret = Buffer.alloc(20, 0x44);
|
||||
const factorId = 'factor-old-key';
|
||||
const oldEnvelope = encryptTotpSecret(
|
||||
{ factorId, userId: 'user-1', secret },
|
||||
parseMfaTotpEncryptionKeys(`retained:${keyB},active:${keyA}`),
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO mfa_totp_factors(
|
||||
id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,
|
||||
algorithm,digits,period_seconds,failed_attempts,created_at,activated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
factorId,
|
||||
'user-1',
|
||||
'active',
|
||||
oldEnvelope,
|
||||
'retained',
|
||||
'SHD.build',
|
||||
'user-1',
|
||||
'SHA1',
|
||||
6,
|
||||
30,
|
||||
0,
|
||||
now.toISOString(),
|
||||
now.toISOString(),
|
||||
);
|
||||
const result = await verifyTotpFactor(database, {
|
||||
userId: 'user-1',
|
||||
factorId,
|
||||
token: generateTotpCode(secret, now.getTime()),
|
||||
now,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
status: 'verified',
|
||||
needsRewrap: true,
|
||||
keyId: 'retained',
|
||||
});
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT encrypted_secret,encryption_key_id FROM mfa_totp_factors WHERE id=?',
|
||||
)
|
||||
).get(factorId);
|
||||
expect(row.encryption_key_id).toBe('active');
|
||||
expect(row.encrypted_secret).not.toBe(oldEnvelope);
|
||||
expect(
|
||||
decryptTotpSecret(
|
||||
{
|
||||
factorId,
|
||||
userId: 'user-1',
|
||||
envelope: String(row.encrypted_secret),
|
||||
},
|
||||
parseMfaTotpEncryptionKeys(`active:${keyA},retained:${keyB}`),
|
||||
).secret,
|
||||
).toEqual(secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recovery codes', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await mfaDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('issues ten formatted codes and consumes one atomically', async () => {
|
||||
const issued = await issueRecoveryCodes(database, {
|
||||
userId: 'user-1',
|
||||
now,
|
||||
});
|
||||
expect(issued.codes).toHaveLength(10);
|
||||
expect(issued.codes[0]).toMatch(/^[0-9A-F]{4}(?:-[0-9A-F]{4}){7}$/);
|
||||
expect(normalizeRecoveryCode(issued.codes[0])).toHaveLength(32);
|
||||
expect(formatRecoveryCode(normalizeRecoveryCode(issued.codes[0])!)).toBe(
|
||||
issued.codes[0],
|
||||
);
|
||||
expect(
|
||||
await consumeRecoveryCode(database, {
|
||||
userId: 'user-1',
|
||||
code: issued.codes[0],
|
||||
now,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await consumeRecoveryCode(database, {
|
||||
userId: 'user-1',
|
||||
code: issued.codes[0],
|
||||
now,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('revokes unused codes when regenerated', async () => {
|
||||
await issueRecoveryCodes(database, { userId: 'user-1', now });
|
||||
await issueRecoveryCodes(database, {
|
||||
userId: 'user-1',
|
||||
now: new Date(now.getTime() + 1_000),
|
||||
});
|
||||
const revoked = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE revoked_at IS NOT NULL',
|
||||
)
|
||||
).get();
|
||||
expect(Number(revoked.count)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication transactions', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await transactionDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => database.close());
|
||||
|
||||
it('enforces local return paths, five attempts, and one-time consumption', async () => {
|
||||
expect(validateAuthenticationReturnPath('/builder?tab=weapons')).toBe(
|
||||
'/builder?tab=weapons',
|
||||
);
|
||||
expect(
|
||||
validateAuthenticationReturnPath('https://evil.example/'),
|
||||
).toBeNull();
|
||||
expect(validateAuthenticationReturnPath('//evil.example')).toBeNull();
|
||||
expect(validateAuthenticationReturnPath('/%2F%2Fevil.example')).toBeNull();
|
||||
|
||||
const cookies = cookieJar();
|
||||
const transaction = await createAuthenticationTransaction(
|
||||
cookies as never,
|
||||
database,
|
||||
{
|
||||
userId: 'user-1',
|
||||
kind: 'login',
|
||||
provider: 'password',
|
||||
returnPath: '/builder',
|
||||
now,
|
||||
},
|
||||
);
|
||||
expect(cookies.set).toHaveBeenCalledWith(
|
||||
'shd_auth_tx',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
path: '/auth/two-factor',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 300,
|
||||
}),
|
||||
);
|
||||
expect(transaction.provider).toBe('password');
|
||||
const stored = await (
|
||||
await database.prepare(
|
||||
'SELECT token_hash FROM authentication_transactions',
|
||||
)
|
||||
).get();
|
||||
expect(String(stored.token_hash)).not.toBe(
|
||||
cookies.values.get('shd_auth_tx'),
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
expect(
|
||||
(await recordAuthenticationAttempt(cookies as never, database, now))
|
||||
.accepted,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
await getAuthenticationTransaction(cookies as never, database, now),
|
||||
).toBeNull();
|
||||
|
||||
const secondCookies = cookieJar();
|
||||
const second = await createAuthenticationTransaction(
|
||||
secondCookies as never,
|
||||
database,
|
||||
{
|
||||
userId: 'user-1',
|
||||
kind: 'step_up',
|
||||
returnPath: '/account/security',
|
||||
now,
|
||||
},
|
||||
);
|
||||
const consumed = await consumeAuthenticationTransaction(
|
||||
secondCookies as never,
|
||||
database,
|
||||
now,
|
||||
);
|
||||
expect(consumed).toMatchObject({
|
||||
id: second.id,
|
||||
kind: 'step_up',
|
||||
requiredAal: 2,
|
||||
});
|
||||
expect(
|
||||
await consumeAuthenticationTransaction(
|
||||
secondCookies as never,
|
||||
database,
|
||||
now,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
export const RECOVERY_CODE_COUNT = 10;
|
||||
export const RECOVERY_CODE_BYTES = 16;
|
||||
const RECOVERY_CODE_DOMAIN = 'shd.build:mfa-recovery-code:v1';
|
||||
|
||||
export type RecoveryDatabase = Pick<Database, 'prepare' | 'transactionAsync'>;
|
||||
export type RecoveryTransaction = Pick<Database, 'prepare'>;
|
||||
|
||||
export class RecoveryCodeError extends Error {
|
||||
constructor(message = 'Unable to process recovery codes.') {
|
||||
super(message);
|
||||
this.name = 'RecoveryCodeError';
|
||||
}
|
||||
}
|
||||
|
||||
export type IssuedRecoveryCodes = {
|
||||
codes: string[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function databaseOrDefault(
|
||||
database?: RecoveryDatabase,
|
||||
): Promise<RecoveryDatabase> {
|
||||
return database
|
||||
? Promise.resolve(database)
|
||||
: (getDatabase() as Promise<RecoveryDatabase>);
|
||||
}
|
||||
|
||||
function timestamp(value: Date | string | number | undefined): string {
|
||||
const date =
|
||||
value instanceof Date ? new Date(value) : new Date(value ?? Date.now());
|
||||
if (!Number.isFinite(date.getTime())) throw new RecoveryCodeError();
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/** Return the canonical code used for hashing, or null for malformed input. */
|
||||
export function normalizeRecoveryCode(input: string): string | null {
|
||||
if (typeof input !== 'string') return null;
|
||||
const normalized = input
|
||||
.normalize('NFKC')
|
||||
.replace(/[\s-]+/gu, '')
|
||||
.toUpperCase();
|
||||
return /^[0-9A-F]{32}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
/** Add display separators without changing the canonical validation format. */
|
||||
export function formatRecoveryCode(canonicalCode: string): string {
|
||||
const normalized = normalizeRecoveryCode(canonicalCode);
|
||||
if (!normalized) throw new RecoveryCodeError();
|
||||
return normalized.match(/.{1,4}/g)?.join('-') ?? normalized;
|
||||
}
|
||||
|
||||
export function generateRecoveryCode(): { canonical: string; display: string } {
|
||||
const canonical = randomBytes(RECOVERY_CODE_BYTES)
|
||||
.toString('hex')
|
||||
.toUpperCase();
|
||||
return { canonical, display: formatRecoveryCode(canonical) };
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(userId: string, code: string): string {
|
||||
const normalized = normalizeRecoveryCode(code);
|
||||
if (!userId || !normalized) throw new RecoveryCodeError();
|
||||
return createHash('sha256')
|
||||
.update(RECOVERY_CODE_DOMAIN, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(userId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(normalized, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export async function issueRecoveryCodesInTransaction(
|
||||
transaction: RecoveryTransaction,
|
||||
options: { userId: string; now?: Date | string | number },
|
||||
): Promise<IssuedRecoveryCodes> {
|
||||
if (!options.userId) throw new RecoveryCodeError();
|
||||
const createdAt = timestamp(options.now);
|
||||
const generated = Array.from(
|
||||
{ length: RECOVERY_CODE_COUNT },
|
||||
generateRecoveryCode,
|
||||
);
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'UPDATE mfa_recovery_codes SET revoked_at=? WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL',
|
||||
)
|
||||
).run(createdAt, options.userId);
|
||||
for (const code of generated) {
|
||||
await (
|
||||
await transaction.prepare(
|
||||
'INSERT INTO mfa_recovery_codes(id,user_id,code_hash,created_at) VALUES(?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
options.userId,
|
||||
hashRecoveryCode(options.userId, code.canonical),
|
||||
createdAt,
|
||||
);
|
||||
}
|
||||
return { codes: generated.map((code) => code.display), createdAt };
|
||||
}
|
||||
|
||||
export async function issueRecoveryCodes(
|
||||
databaseInput: RecoveryDatabase | undefined,
|
||||
options: { userId: string; now?: Date | string | number },
|
||||
): Promise<IssuedRecoveryCodes> {
|
||||
if (!options.userId) throw new RecoveryCodeError();
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const issued = database.transactionAsync(async (transaction) => {
|
||||
return issueRecoveryCodesInTransaction(transaction, options);
|
||||
});
|
||||
return await issued.immediate();
|
||||
}
|
||||
|
||||
export const regenerateRecoveryCodes = issueRecoveryCodes;
|
||||
|
||||
export async function consumeRecoveryCode(
|
||||
databaseInput: RecoveryDatabase | undefined,
|
||||
options: { userId: string; code: string; now?: Date | string | number },
|
||||
): Promise<boolean> {
|
||||
const normalized = normalizeRecoveryCode(options.code);
|
||||
if (!normalized || !options.userId) return false;
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const consumed = database.transactionAsync((transaction) =>
|
||||
consumeRecoveryCodeInTransaction(transaction, {
|
||||
...options,
|
||||
code: normalized,
|
||||
}),
|
||||
);
|
||||
return await consumed.immediate();
|
||||
}
|
||||
|
||||
/** Consume one recovery code using the caller's transaction. */
|
||||
export async function consumeRecoveryCodeInTransaction(
|
||||
transaction: RecoveryTransaction,
|
||||
options: { userId: string; code: string; now?: Date | string | number },
|
||||
): Promise<boolean> {
|
||||
const normalized = normalizeRecoveryCode(options.code);
|
||||
if (!normalized || !options.userId) return false;
|
||||
const consumedAt = timestamp(options.now);
|
||||
const result = await (
|
||||
await transaction.prepare(
|
||||
`UPDATE mfa_recovery_codes
|
||||
SET consumed_at=?
|
||||
WHERE user_id=? AND code_hash=? AND consumed_at IS NULL AND revoked_at IS NULL`,
|
||||
)
|
||||
).run(
|
||||
consumedAt,
|
||||
options.userId,
|
||||
hashRecoveryCode(options.userId, normalized),
|
||||
);
|
||||
return result.changes === 1;
|
||||
}
|
||||
|
||||
export const useRecoveryCode = consumeRecoveryCode;
|
||||
@@ -0,0 +1,416 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { Secret, TOTP } from 'otpauth';
|
||||
import {
|
||||
decryptTotpSecret,
|
||||
encryptTotpSecret,
|
||||
getMfaTotpEncryptionKeys,
|
||||
MFA_TOTP_PARAMETERS,
|
||||
rewrapTotpSecret,
|
||||
} from './mfa-crypto';
|
||||
|
||||
export const TOTP_ISSUER = 'SHD.build';
|
||||
export const TOTP_SECRET_BYTES = 20;
|
||||
export const TOTP_WINDOW = 1;
|
||||
export const TOTP_PENDING_SECONDS = 10 * 60;
|
||||
export const TOTP_FAILURE_LIMIT = 5;
|
||||
export const TOTP_LOCK_SECONDS = 5 * 60;
|
||||
|
||||
export type TotpDatabase = Pick<Database, 'prepare' | 'transactionAsync'>;
|
||||
export type TotpTransaction = Pick<Database, 'prepare'>;
|
||||
|
||||
export class TotpServiceError extends Error {
|
||||
constructor(message = 'Unable to process the TOTP factor.') {
|
||||
super(message);
|
||||
this.name = 'TotpServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
export type TotpEnrollment = {
|
||||
factorId: string;
|
||||
secretBase32: string;
|
||||
provisioningUri: string;
|
||||
expiresAt: string;
|
||||
replacedFactorId?: string;
|
||||
};
|
||||
|
||||
export type TotpEnrollmentOptions = {
|
||||
userId: string;
|
||||
accountLabel?: string;
|
||||
now?: Date | string | number;
|
||||
replaceActive?: boolean;
|
||||
};
|
||||
|
||||
export type TotpVerificationResult =
|
||||
| {
|
||||
status: 'verified';
|
||||
counter: number;
|
||||
needsRewrap: boolean;
|
||||
keyId: string;
|
||||
}
|
||||
| {
|
||||
status: 'invalid' | 'replayed' | 'expired';
|
||||
lockedUntil: string | null;
|
||||
}
|
||||
| {
|
||||
status: 'locked';
|
||||
lockedUntil: string;
|
||||
};
|
||||
|
||||
export type TotpVerificationOptions = {
|
||||
userId: string;
|
||||
factorId: string;
|
||||
token: string;
|
||||
now?: Date | string | number;
|
||||
};
|
||||
|
||||
type TotpFactorRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
state: 'pending' | 'active' | 'revoked';
|
||||
encrypted_secret: string;
|
||||
encryption_key_id: string;
|
||||
issuer: string;
|
||||
account_label: string;
|
||||
last_counter: number | null;
|
||||
failed_attempts: number;
|
||||
locked_until: string | null;
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
function databaseOrDefault(database?: TotpDatabase): Promise<TotpDatabase> {
|
||||
return database
|
||||
? Promise.resolve(database)
|
||||
: (getDatabase() as Promise<TotpDatabase>);
|
||||
}
|
||||
|
||||
function asTimestamp(value: Date | string | number | undefined): Date {
|
||||
const result =
|
||||
value instanceof Date
|
||||
? new Date(value.getTime())
|
||||
: new Date(value ?? Date.now());
|
||||
if (!Number.isFinite(result.getTime())) throw new TotpServiceError();
|
||||
return result;
|
||||
}
|
||||
|
||||
function toSecret(secret: Uint8Array): Secret {
|
||||
if (secret.length !== TOTP_SECRET_BYTES) throw new TotpServiceError();
|
||||
const bytes = Uint8Array.from(secret);
|
||||
return new Secret({ buffer: bytes.buffer as ArrayBuffer });
|
||||
}
|
||||
|
||||
function totpFor(secret: Uint8Array, label: string): TOTP {
|
||||
return new TOTP({
|
||||
issuer: TOTP_ISSUER,
|
||||
label,
|
||||
issuerInLabel: true,
|
||||
secret: toSecret(secret),
|
||||
algorithm: MFA_TOTP_PARAMETERS.algorithm,
|
||||
digits: MFA_TOTP_PARAMETERS.digits,
|
||||
period: MFA_TOTP_PARAMETERS.periodSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
export function generateTotpSecret(): Buffer {
|
||||
return randomBytes(TOTP_SECRET_BYTES);
|
||||
}
|
||||
|
||||
export function generateTotpCode(
|
||||
secret: Uint8Array,
|
||||
timestamp = Date.now(),
|
||||
): string {
|
||||
return totpFor(secret, TOTP_ISSUER).generate({ timestamp });
|
||||
}
|
||||
|
||||
export function validateTotpCode(
|
||||
secret: Uint8Array,
|
||||
token: string,
|
||||
timestamp = Date.now(),
|
||||
): number | null {
|
||||
if (!/^\d{6}$/.test(token)) return null;
|
||||
return totpFor(secret, TOTP_ISSUER).validate({
|
||||
token,
|
||||
timestamp,
|
||||
window: TOTP_WINDOW,
|
||||
});
|
||||
}
|
||||
|
||||
export function totpCounter(timestamp = Date.now()): number {
|
||||
return TOTP.counter({
|
||||
period: MFA_TOTP_PARAMETERS.periodSeconds,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
function provisioningUri(secret: Uint8Array, label: string): string {
|
||||
return totpFor(secret, label).toString();
|
||||
}
|
||||
|
||||
export async function beginTotpEnrollmentInTransaction(
|
||||
transaction: TotpTransaction,
|
||||
options: TotpEnrollmentOptions,
|
||||
): Promise<TotpEnrollment> {
|
||||
if (!options.userId || options.userId.length > 200)
|
||||
throw new TotpServiceError();
|
||||
const now = asTimestamp(options.now);
|
||||
const nowIso = now.toISOString();
|
||||
const expiresAt = new Date(
|
||||
now.getTime() + TOTP_PENDING_SECONDS * 1000,
|
||||
).toISOString();
|
||||
const factorId = randomUUID();
|
||||
const accountLabel = (options.accountLabel ?? options.userId).trim();
|
||||
if (!accountLabel || accountLabel.length > 200) throw new TotpServiceError();
|
||||
const secret = generateTotpSecret();
|
||||
const keys = getMfaTotpEncryptionKeys();
|
||||
const encryptedSecret = encryptTotpSecret(
|
||||
{ factorId, userId: options.userId, secret },
|
||||
keys,
|
||||
);
|
||||
|
||||
const current = (await (
|
||||
await transaction.prepare(
|
||||
"SELECT id,state,expires_at FROM mfa_totp_factors WHERE user_id=? AND state IN ('pending','active')",
|
||||
)
|
||||
).all(options.userId)) as Array<{
|
||||
id: string;
|
||||
state: 'pending' | 'active';
|
||||
expires_at: string | null;
|
||||
}>;
|
||||
const pending = current.find((row) => row.state === 'pending');
|
||||
if (pending && (!pending.expires_at || pending.expires_at <= nowIso)) {
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND user_id=? AND state='pending'",
|
||||
)
|
||||
).run(nowIso, pending.id, options.userId);
|
||||
} else if (pending) {
|
||||
throw new TotpServiceError();
|
||||
}
|
||||
const active = current.find((row) => row.state === 'active');
|
||||
if (active && !options.replaceActive) throw new TotpServiceError();
|
||||
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`INSERT INTO mfa_totp_factors(
|
||||
id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,
|
||||
algorithm,digits,period_seconds,failed_attempts,expires_at,created_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
factorId,
|
||||
options.userId,
|
||||
'pending',
|
||||
encryptedSecret,
|
||||
keys[0].id,
|
||||
TOTP_ISSUER,
|
||||
accountLabel,
|
||||
MFA_TOTP_PARAMETERS.algorithm,
|
||||
MFA_TOTP_PARAMETERS.digits,
|
||||
MFA_TOTP_PARAMETERS.periodSeconds,
|
||||
0,
|
||||
expiresAt,
|
||||
nowIso,
|
||||
);
|
||||
|
||||
return {
|
||||
factorId,
|
||||
secretBase32: toSecret(secret).base32,
|
||||
provisioningUri: provisioningUri(secret, accountLabel),
|
||||
expiresAt,
|
||||
...(active ? { replacedFactorId: active.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function beginTotpEnrollment(
|
||||
databaseInput: TotpDatabase | undefined,
|
||||
options: TotpEnrollmentOptions,
|
||||
): Promise<TotpEnrollment> {
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const insert = database.transactionAsync(
|
||||
async (transaction) =>
|
||||
await beginTotpEnrollmentInTransaction(transaction, options),
|
||||
);
|
||||
return await insert.immediate();
|
||||
}
|
||||
|
||||
export const startTotpEnrollment = beginTotpEnrollment;
|
||||
|
||||
function lockedUntilDate(row: TotpFactorRow, now: Date): string | null {
|
||||
if (!row.locked_until) return null;
|
||||
return new Date(row.locked_until) > now ? row.locked_until : null;
|
||||
}
|
||||
|
||||
export async function verifyTotpFactorInTransaction(
|
||||
transaction: TotpTransaction,
|
||||
options: TotpVerificationOptions,
|
||||
pendingOnly = false,
|
||||
activatePending = true,
|
||||
): Promise<TotpVerificationResult> {
|
||||
const now = asTimestamp(options.now);
|
||||
const nowIso = now.toISOString();
|
||||
const lockUntil = new Date(
|
||||
now.getTime() + TOTP_LOCK_SECONDS * 1000,
|
||||
).toISOString();
|
||||
|
||||
const row = (await (
|
||||
await transaction.prepare(
|
||||
'SELECT id,user_id,state,encrypted_secret,encryption_key_id,issuer,account_label,last_counter,failed_attempts,locked_until,expires_at FROM mfa_totp_factors WHERE id=? AND user_id=? LIMIT 1',
|
||||
)
|
||||
).get(options.factorId, options.userId)) as TotpFactorRow | undefined;
|
||||
if (
|
||||
!row ||
|
||||
row.state === 'revoked' ||
|
||||
(pendingOnly && row.state !== 'pending')
|
||||
)
|
||||
return { status: 'invalid', lockedUntil: null } as const;
|
||||
if (
|
||||
row.state === 'pending' &&
|
||||
(!row.expires_at || row.expires_at <= nowIso)
|
||||
) {
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND state='pending'",
|
||||
)
|
||||
).run(nowIso, row.id);
|
||||
return { status: 'expired', lockedUntil: null } as const;
|
||||
}
|
||||
const existingLock = lockedUntilDate(row, now);
|
||||
if (existingLock)
|
||||
return { status: 'locked', lockedUntil: existingLock } as const;
|
||||
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = decryptTotpSecret(
|
||||
{
|
||||
factorId: row.id,
|
||||
userId: row.user_id,
|
||||
envelope: row.encrypted_secret,
|
||||
},
|
||||
getMfaTotpEncryptionKeys(),
|
||||
);
|
||||
} catch {
|
||||
throw new TotpServiceError();
|
||||
}
|
||||
const delta = validateTotpCode(
|
||||
decrypted.secret,
|
||||
options.token,
|
||||
now.getTime(),
|
||||
);
|
||||
const currentCounter = totpCounter(now.getTime());
|
||||
const matchedCounter = delta === null ? null : currentCounter + delta;
|
||||
if (matchedCounter !== null) {
|
||||
const update =
|
||||
pendingOnly && !activatePending
|
||||
? `UPDATE mfa_totp_factors
|
||||
SET last_counter=?,failed_attempts=0,locked_until=NULL
|
||||
WHERE id=? AND user_id=? AND state='pending'
|
||||
AND (last_counter IS NULL OR last_counter<?)
|
||||
AND (locked_until IS NULL OR locked_until<=?)`
|
||||
: `UPDATE mfa_totp_factors
|
||||
SET last_counter=?,failed_attempts=0,locked_until=NULL,
|
||||
state=CASE WHEN state='pending' THEN 'active' ELSE state END,
|
||||
expires_at=CASE WHEN state='pending' THEN NULL ELSE expires_at END,
|
||||
activated_at=CASE WHEN state='pending' THEN ? ELSE activated_at END
|
||||
WHERE id=? AND user_id=? AND state IN ('pending','active')
|
||||
AND (last_counter IS NULL OR last_counter<?)
|
||||
AND (locked_until IS NULL OR locked_until<=?)`;
|
||||
const updated = await (
|
||||
await transaction.prepare(update)
|
||||
).run(
|
||||
...(pendingOnly && !activatePending
|
||||
? [matchedCounter, row.id, options.userId, matchedCounter, nowIso]
|
||||
: [
|
||||
matchedCounter,
|
||||
nowIso,
|
||||
row.id,
|
||||
options.userId,
|
||||
matchedCounter,
|
||||
nowIso,
|
||||
]),
|
||||
);
|
||||
if (updated.changes === 1) {
|
||||
if (decrypted.needsRewrap) {
|
||||
const rewrapped = rewrapTotpSecret(
|
||||
{
|
||||
factorId: row.id,
|
||||
userId: row.user_id,
|
||||
envelope: row.encrypted_secret,
|
||||
},
|
||||
getMfaTotpEncryptionKeys(),
|
||||
);
|
||||
const rewrappedRow = await (
|
||||
await transaction.prepare(
|
||||
`UPDATE mfa_totp_factors
|
||||
SET encrypted_secret=?,encryption_key_id=?
|
||||
WHERE id=? AND user_id=? AND state IN ('pending','active')`,
|
||||
)
|
||||
).run(
|
||||
rewrapped.envelope,
|
||||
rewrapped.activeKeyId,
|
||||
row.id,
|
||||
options.userId,
|
||||
);
|
||||
if (rewrappedRow.changes !== 1) throw new TotpServiceError();
|
||||
}
|
||||
return {
|
||||
status: 'verified',
|
||||
counter: matchedCounter,
|
||||
needsRewrap: decrypted.needsRewrap,
|
||||
keyId: decrypted.keyId,
|
||||
} as const;
|
||||
}
|
||||
return { status: 'replayed', lockedUntil: null } as const;
|
||||
}
|
||||
|
||||
const failedAttempts =
|
||||
row.locked_until && row.locked_until <= nowIso
|
||||
? 1
|
||||
: row.failed_attempts + 1;
|
||||
const locked = failedAttempts >= TOTP_FAILURE_LIMIT;
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`UPDATE mfa_totp_factors
|
||||
SET failed_attempts=?,locked_until=?
|
||||
WHERE id=? AND user_id=? AND state IN ('pending','active')
|
||||
AND (locked_until IS NULL OR locked_until<=?)`,
|
||||
)
|
||||
).run(
|
||||
failedAttempts,
|
||||
locked ? lockUntil : null,
|
||||
row.id,
|
||||
options.userId,
|
||||
nowIso,
|
||||
);
|
||||
return {
|
||||
status: 'invalid',
|
||||
lockedUntil: locked ? lockUntil : null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function verifyTotpFactor(
|
||||
databaseInput: TotpDatabase | undefined,
|
||||
options: TotpVerificationOptions,
|
||||
): Promise<TotpVerificationResult> {
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const verify = database.transactionAsync((transaction) =>
|
||||
verifyTotpFactorInTransaction(transaction, options),
|
||||
);
|
||||
return await verify.immediate();
|
||||
}
|
||||
|
||||
export const verifyTotp = verifyTotpFactor;
|
||||
|
||||
export async function revokeTotpFactor(
|
||||
databaseInput: TotpDatabase | undefined,
|
||||
options: { userId: string; factorId: string; now?: Date | string | number },
|
||||
): Promise<boolean> {
|
||||
const database = await databaseOrDefault(databaseInput);
|
||||
const now = asTimestamp(options.now).toISOString();
|
||||
const result = await (
|
||||
await database.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND user_id=? AND state IN ('pending','active')",
|
||||
)
|
||||
).run(now, options.factorId, options.userId);
|
||||
return result.changes === 1;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
database: { prepare: vi.fn() },
|
||||
hasActiveTotpFactor: vi.fn(),
|
||||
isFullAdministrator: vi.fn(),
|
||||
createAuthenticationTransaction: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
recordLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./bootstrap', () => ({
|
||||
hasActiveTotpFactor: mocks.hasActiveTotpFactor,
|
||||
isFullAdministrator: mocks.isFullAdministrator,
|
||||
}));
|
||||
vi.mock('./authentication-transactions', () => ({
|
||||
createAuthenticationTransaction: mocks.createAuthenticationTransaction,
|
||||
}));
|
||||
vi.mock('./session', () => ({
|
||||
createSession: mocks.createSession,
|
||||
recordLogin: mocks.recordLogin,
|
||||
}));
|
||||
|
||||
import { completePrimaryAuthentication } from './primary-authentication';
|
||||
|
||||
const providers = ['password', 'magic_link', 'discord', 'passkey'] as const;
|
||||
|
||||
describe('primary authentication completion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.database.prepare.mockResolvedValue({
|
||||
get: vi.fn(async () => ({ id: 'user-1' })),
|
||||
});
|
||||
mocks.hasActiveTotpFactor.mockResolvedValue(true);
|
||||
mocks.isFullAdministrator.mockResolvedValue(false);
|
||||
mocks.createAuthenticationTransaction.mockResolvedValue({
|
||||
id: 'tx-1',
|
||||
userId: 'user-1',
|
||||
provider: 'password',
|
||||
kind: 'login',
|
||||
returnPath: '/account',
|
||||
requiredAal: 2,
|
||||
attemptCount: 0,
|
||||
expiresAt: '2026-08-07T12:05:00.000Z',
|
||||
});
|
||||
mocks.createSession.mockResolvedValue('session-1');
|
||||
});
|
||||
|
||||
it.each(providers)(
|
||||
'cannot bypass active MFA after %s verification',
|
||||
async (provider) => {
|
||||
const result = await completePrimaryAuthentication({
|
||||
database: mocks.database as never,
|
||||
cookies: {} as never,
|
||||
userId: 'user-1',
|
||||
provider,
|
||||
returnPath: '/account',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'mfa',
|
||||
redirectTo: '/auth/two-factor',
|
||||
});
|
||||
expect(mocks.createSession).not.toHaveBeenCalled();
|
||||
expect(mocks.createAuthenticationTransaction).toHaveBeenCalledWith(
|
||||
{},
|
||||
mocks.database,
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
kind: 'login',
|
||||
provider,
|
||||
}),
|
||||
);
|
||||
expect(mocks.recordLogin).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
provider,
|
||||
false,
|
||||
expect.objectContaining({
|
||||
failureReason: 'mfa_required',
|
||||
riskFlags: ['primary_authenticated_mfa_required'],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('gives an unenrolled administrator only an enrollment session', async () => {
|
||||
mocks.hasActiveTotpFactor.mockResolvedValue(false);
|
||||
mocks.isFullAdministrator.mockResolvedValue(true);
|
||||
|
||||
const result = await completePrimaryAuthentication({
|
||||
database: mocks.database as never,
|
||||
cookies: {} as never,
|
||||
userId: 'user-1',
|
||||
provider: 'password',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'session',
|
||||
aal: 1,
|
||||
redirectTo: '/account/security?required=admin',
|
||||
});
|
||||
expect(mocks.createAuthenticationTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { hasActiveTotpFactor, isFullAdministrator } from './bootstrap';
|
||||
import {
|
||||
createAuthenticationTransaction,
|
||||
type AuthenticationTransaction,
|
||||
} from './authentication-transactions';
|
||||
import { createSession, recordLogin, type SessionAuthLevel } from './session';
|
||||
|
||||
export type PrimaryAuthenticationProvider =
|
||||
'password' | 'discord' | 'magic_link' | 'passkey';
|
||||
|
||||
export class PrimaryAuthenticationError extends Error {
|
||||
readonly code: 'account_restricted' | 'account_missing';
|
||||
|
||||
constructor(code: 'account_restricted' | 'account_missing') {
|
||||
super('This account is not permitted to sign in.');
|
||||
this.name = 'PrimaryAuthenticationError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type PrimaryAuthenticationResult =
|
||||
| {
|
||||
kind: 'session';
|
||||
sessionId: string;
|
||||
aal: SessionAuthLevel;
|
||||
redirectTo: string;
|
||||
}
|
||||
| {
|
||||
kind: 'mfa';
|
||||
transaction: AuthenticationTransaction;
|
||||
redirectTo: '/auth/two-factor';
|
||||
};
|
||||
|
||||
export type CompletePrimaryAuthenticationInput = {
|
||||
database: Database;
|
||||
cookies: Cookies;
|
||||
userId: string;
|
||||
provider: PrimaryAuthenticationProvider;
|
||||
request?: Request;
|
||||
getClientAddress?: () => string;
|
||||
credentialId?: string | null;
|
||||
returnPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Completes a verified password, email, Discord, or passkey identity. This is
|
||||
* the only primary-auth path allowed to create a session. Accounts with an
|
||||
* active TOTP factor receive an opaque MFA transaction instead.
|
||||
*/
|
||||
export async function completePrimaryAuthentication(
|
||||
input: CompletePrimaryAuthenticationInput,
|
||||
): Promise<PrimaryAuthenticationResult> {
|
||||
const status = await (
|
||||
await input.database.prepare(`
|
||||
SELECT users.id,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at
|
||||
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 users.id=?
|
||||
`)
|
||||
).get(input.userId);
|
||||
const banned =
|
||||
typeof status?.banned_until === 'string' &&
|
||||
new Date(status.banned_until) > new Date();
|
||||
if (!status) throw new PrimaryAuthenticationError('account_missing');
|
||||
if (status.deleted_at || status.blocked_at || banned) {
|
||||
await recordLogin(input.userId, input.provider, false, {
|
||||
request: input.request,
|
||||
getClientAddress: input.getClientAddress,
|
||||
credentialId: input.credentialId,
|
||||
failureReason: 'account_restricted',
|
||||
});
|
||||
throw new PrimaryAuthenticationError('account_restricted');
|
||||
}
|
||||
|
||||
const activeFactor = await hasActiveTotpFactor(input.database, input.userId);
|
||||
const fullAdministrator = await isFullAdministrator(
|
||||
input.database,
|
||||
input.userId,
|
||||
);
|
||||
if (!activeFactor) {
|
||||
const sessionId = await createSession(input.cookies, input.userId, {
|
||||
aal: 1,
|
||||
});
|
||||
const redirectTo = fullAdministrator
|
||||
? '/account/security?required=admin'
|
||||
: (input.returnPath ?? '/account');
|
||||
await recordLogin(input.userId, input.provider, true, {
|
||||
request: input.request,
|
||||
getClientAddress: input.getClientAddress,
|
||||
credentialId: input.credentialId,
|
||||
sessionId,
|
||||
aal: 1,
|
||||
});
|
||||
return { kind: 'session', sessionId, aal: 1, redirectTo };
|
||||
}
|
||||
|
||||
const transaction = await createAuthenticationTransaction(
|
||||
input.cookies,
|
||||
input.database,
|
||||
{
|
||||
userId: input.userId,
|
||||
kind: 'login',
|
||||
provider: input.provider,
|
||||
returnPath: input.returnPath ?? '/account',
|
||||
},
|
||||
);
|
||||
await recordLogin(input.userId, input.provider, false, {
|
||||
request: input.request,
|
||||
getClientAddress: input.getClientAddress,
|
||||
credentialId: input.credentialId,
|
||||
failureReason: 'mfa_required',
|
||||
riskFlags: ['primary_authenticated_mfa_required'],
|
||||
aal: 1,
|
||||
});
|
||||
return { kind: 'mfa', transaction, redirectTo: '/auth/two-factor' };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ database: null as Database | null }));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
vi.mock('$lib/avatar', () => ({
|
||||
avatarUrl: vi.fn(() => '/missing-item-icon.svg'),
|
||||
}));
|
||||
|
||||
import { createSession, getSessionUser } from './session';
|
||||
|
||||
function cookieJar() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
values,
|
||||
get: (name: string) => values.get(name),
|
||||
set: vi.fn((name: string, value: string) => values.set(name, value)),
|
||||
delete: vi.fn((name: string) => values.delete(name)),
|
||||
};
|
||||
}
|
||||
|
||||
async function createDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL, login_username TEXT,
|
||||
role TEXT NOT NULL, discord_id TEXT
|
||||
);
|
||||
CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY, display_name TEXT, public_slug TEXT
|
||||
);
|
||||
CREATE TABLE user_avatars (
|
||||
user_id TEXT PRIMARY KEY, source TEXT, value TEXT, updated_at TEXT
|
||||
);
|
||||
CREATE TABLE user_status (
|
||||
user_id TEXT PRIMARY KEY, last_active_at TEXT, blocked_at TEXT,
|
||||
banned_until TEXT, updated_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE deleted_accounts (user_id TEXT PRIMARY KEY, deleted_at TEXT);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL,
|
||||
aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT
|
||||
);
|
||||
INSERT INTO users(id,username,login_username,role,discord_id)
|
||||
VALUES ('user-1','agent','agent','user',NULL);
|
||||
INSERT INTO user_profiles(user_id,display_name,public_slug)
|
||||
VALUES ('user-1','Agent','agent');
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
describe('session AAL foundation', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
mocks.database = database;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
mocks.database = null;
|
||||
await database.close();
|
||||
});
|
||||
|
||||
it('preserves legacy AAL1 calls and supports AAL2 metadata', async () => {
|
||||
const aal2Cookies = cookieJar();
|
||||
await createSession(aal2Cookies as never, 'user-1', { aal: 2 });
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT token_hash,aal,aal_verified_at,aal2_verified_at FROM sessions',
|
||||
)
|
||||
).get();
|
||||
expect(row.aal).toBe(2);
|
||||
expect(row.aal_verified_at).toBeTruthy();
|
||||
expect(row.aal2_verified_at).toBeTruthy();
|
||||
expect(String(row.token_hash)).not.toBe(
|
||||
aal2Cookies.values.get('shd_session'),
|
||||
);
|
||||
|
||||
const user = await getSessionUser(aal2Cookies as never);
|
||||
expect(user).toMatchObject({
|
||||
id: 'user-1',
|
||||
aal: 2,
|
||||
authLevel: 2,
|
||||
aalVerifiedAt: expect.any(String),
|
||||
aal2VerifiedAt: expect.any(String),
|
||||
});
|
||||
|
||||
const aal1Cookies = cookieJar();
|
||||
await createSession(aal1Cookies as never, 'user-1');
|
||||
const aal1 = await (
|
||||
await database.prepare(
|
||||
'SELECT aal,aal2_verified_at FROM sessions ORDER BY created_at DESC LIMIT 1',
|
||||
)
|
||||
).get();
|
||||
expect(aal1.aal).toBe(1);
|
||||
expect(aal1.aal2_verified_at).toBeNull();
|
||||
});
|
||||
});
|
||||
+138
-19
@@ -1,5 +1,6 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
@@ -16,6 +17,27 @@ export type AuthUser = {
|
||||
role: string;
|
||||
discordLinked: boolean;
|
||||
avatarUrl: string;
|
||||
/** Server-only authentication assurance metadata; no raw token is exposed. */
|
||||
aal: 1 | 2;
|
||||
authLevel: 1 | 2;
|
||||
sessionCreatedAt: string;
|
||||
aalVerifiedAt: string;
|
||||
aal2VerifiedAt: string | null;
|
||||
};
|
||||
|
||||
export type SessionAuthLevel = 1 | 2;
|
||||
export type SessionTransaction = Pick<Database, 'prepare'>;
|
||||
|
||||
export type PendingSession = {
|
||||
sessionId: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type CreateSessionOptions = {
|
||||
aal?: SessionAuthLevel;
|
||||
authLevel?: SessionAuthLevel;
|
||||
aalVerifiedAt?: Date | string;
|
||||
aal2VerifiedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
type LoginRequestContext = {
|
||||
@@ -25,23 +47,43 @@ type LoginRequestContext = {
|
||||
credentialId?: string | null;
|
||||
sessionId?: string | null;
|
||||
riskFlags?: string[] | null;
|
||||
aal?: SessionAuthLevel;
|
||||
aal2VerifiedAt?: string | null;
|
||||
};
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
cookies: Cookies,
|
||||
function sessionCookieOptions() {
|
||||
return {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: !dev,
|
||||
maxAge: SESSION_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
export function setSessionCookie(cookies: Cookies, token: string): void {
|
||||
cookies.set(COOKIE_NAME, token, sessionCookieOptions());
|
||||
}
|
||||
|
||||
export async function createSessionInTransaction(
|
||||
transaction: SessionTransaction,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
options: CreateSessionOptions = {},
|
||||
): Promise<PendingSession> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const sessionId = randomUUID();
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
||||
const insert = await database.prepare(
|
||||
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
const aal = options.authLevel ?? options.aal ?? 1;
|
||||
const aalVerifiedAt = new Date(options.aalVerifiedAt ?? now).toISOString();
|
||||
const aal2VerifiedAt =
|
||||
aal === 2 ? new Date(options.aal2VerifiedAt ?? now).toISOString() : null;
|
||||
const insert = await transaction.prepare(
|
||||
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at, aal, aal_verified_at, aal2_verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
|
||||
await insert.run(
|
||||
@@ -50,16 +92,67 @@ export async function createSession(
|
||||
hashToken(token),
|
||||
expiresAt.toISOString(),
|
||||
now.toISOString(),
|
||||
aal,
|
||||
aalVerifiedAt,
|
||||
aal2VerifiedAt,
|
||||
);
|
||||
|
||||
cookies.set(COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !dev,
|
||||
maxAge: SESSION_SECONDS,
|
||||
});
|
||||
return sessionId;
|
||||
return { sessionId, token };
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
cookies: Cookies,
|
||||
userId: string,
|
||||
options: CreateSessionOptions = {},
|
||||
): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
const operation = database.transactionAsync((transaction) =>
|
||||
createSessionInTransaction(transaction, userId, options),
|
||||
);
|
||||
const session = await operation.immediate();
|
||||
setSessionCookie(cookies, session.token);
|
||||
return session.sessionId;
|
||||
}
|
||||
|
||||
/** Upgrade the current cookie-backed session without exposing or reissuing its token. */
|
||||
export async function upgradeSessionToAal2InTransaction(
|
||||
transaction: SessionTransaction,
|
||||
cookies: Cookies,
|
||||
userId: string,
|
||||
verifiedAt = new Date(),
|
||||
): Promise<string | null> {
|
||||
const token = cookies.get(COOKIE_NAME);
|
||||
if (!token) return null;
|
||||
const result = await (
|
||||
await transaction.prepare(`
|
||||
UPDATE sessions
|
||||
SET aal=2,aal_verified_at=?,aal2_verified_at=?
|
||||
WHERE token_hash=? AND user_id=? AND expires_at>?
|
||||
`)
|
||||
).run(
|
||||
verifiedAt.toISOString(),
|
||||
verifiedAt.toISOString(),
|
||||
hashToken(token),
|
||||
userId,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
if (result.changes !== 1) return null;
|
||||
const row = await (
|
||||
await transaction.prepare('SELECT id FROM sessions WHERE token_hash=?')
|
||||
).get(hashToken(token));
|
||||
return row?.id ? String(row.id) : null;
|
||||
}
|
||||
|
||||
export async function upgradeSessionToAal2(
|
||||
cookies: Cookies,
|
||||
userId: string,
|
||||
verifiedAt = new Date(),
|
||||
): Promise<string | null> {
|
||||
const database = await getDatabase();
|
||||
const operation = database.transactionAsync((transaction) =>
|
||||
upgradeSessionToAal2InTransaction(transaction, cookies, userId, verifiedAt),
|
||||
);
|
||||
return await operation.immediate();
|
||||
}
|
||||
|
||||
function describeClient(userAgent: string) {
|
||||
@@ -110,6 +203,23 @@ export async function recordLogin(
|
||||
context: LoginRequestContext = {},
|
||||
): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
await recordLoginInTransaction(
|
||||
database,
|
||||
userId,
|
||||
provider,
|
||||
succeeded,
|
||||
context,
|
||||
);
|
||||
await purgeExpiredLoginMetadata();
|
||||
}
|
||||
|
||||
export async function recordLoginInTransaction(
|
||||
transaction: SessionTransaction,
|
||||
userId: string | null,
|
||||
provider: 'password' | 'discord' | 'magic_link' | 'passkey',
|
||||
succeeded: boolean,
|
||||
context: LoginRequestContext = {},
|
||||
): Promise<void> {
|
||||
const userAgent =
|
||||
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
||||
const headers = context.request?.headers;
|
||||
@@ -134,10 +244,10 @@ export async function recordLogin(
|
||||
/* unavailable in some development adapters */
|
||||
}
|
||||
await (
|
||||
await database.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
|
||||
await transaction.prepare(`INSERT INTO login_events(id,user_id,provider,succeeded,ip_address,user_agent,
|
||||
client_name,operating_system,device_type,location_country,location_region,location_city,
|
||||
failure_reason,risk_flags,credential_id,session_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
failure_reason,risk_flags,credential_id,session_id,aal,aal2_verified_at,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
userId,
|
||||
@@ -155,9 +265,10 @@ export async function recordLogin(
|
||||
riskFlags.length ? riskFlags.join(',') : null,
|
||||
context.credentialId ?? null,
|
||||
context.sessionId ?? null,
|
||||
context.aal ?? (succeeded ? 1 : null),
|
||||
context.aal2VerifiedAt ?? null,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await purgeExpiredLoginMetadata();
|
||||
}
|
||||
|
||||
export async function getSessionUser(
|
||||
@@ -173,7 +284,9 @@ export async function getSessionUser(
|
||||
users.role, users.discord_id, user_avatars.source AS avatar_source,
|
||||
user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at,
|
||||
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
|
||||
deleted_accounts.deleted_at
|
||||
deleted_accounts.deleted_at,
|
||||
sessions.aal,sessions.aal_verified_at,sessions.aal2_verified_at,
|
||||
sessions.created_at AS session_created_at
|
||||
FROM sessions
|
||||
JOIN users ON users.id = sessions.user_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
@@ -224,6 +337,12 @@ export async function getSessionUser(
|
||||
String(user.id),
|
||||
user.avatar_updated_at,
|
||||
),
|
||||
aal: Number(user.aal) === 2 ? 2 : 1,
|
||||
authLevel: Number(user.aal) === 2 ? 2 : 1,
|
||||
sessionCreatedAt: String(user.session_created_at),
|
||||
aalVerifiedAt: String(user.aal_verified_at),
|
||||
aal2VerifiedAt:
|
||||
typeof user.aal2_verified_at === 'string' ? user.aal2_verified_at : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createSession, getSessionUser } from '$lib/server/auth/session';
|
||||
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
|
||||
import {
|
||||
LastAdministratorDeletionError,
|
||||
softDeleteAccount,
|
||||
} from '$lib/server/auth/account-deletion';
|
||||
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
@@ -472,18 +475,26 @@ export const actions: Actions = {
|
||||
) {
|
||||
return fail(400, { deleteError: 'Your password is incorrect.' });
|
||||
}
|
||||
if (
|
||||
!(await softDeleteAccount(
|
||||
database,
|
||||
user.id,
|
||||
user.id,
|
||||
'Deleted by account owner',
|
||||
purgeSecurityData,
|
||||
))
|
||||
) {
|
||||
return fail(409, {
|
||||
deleteError: 'This account has already been deleted.',
|
||||
});
|
||||
try {
|
||||
if (
|
||||
!(await softDeleteAccount(
|
||||
database,
|
||||
user.id,
|
||||
user.id,
|
||||
'Deleted by account owner',
|
||||
purgeSecurityData,
|
||||
))
|
||||
)
|
||||
return fail(409, {
|
||||
deleteError: 'This account has already been deleted.',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LastAdministratorDeletionError)
|
||||
return fail(409, {
|
||||
deleteError:
|
||||
'The final active full administrator cannot delete this account.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
cookies.delete('shd_session', { path: '/' });
|
||||
redirect(303, '/?account_deleted=1');
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { ActionFailure } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, RequestEvent } from './$types';
|
||||
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { isFullAdministrator } from '$lib/server/auth/bootstrap';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import {
|
||||
beginTotpEnrollmentInTransaction,
|
||||
verifyTotpFactorInTransaction,
|
||||
TotpServiceError,
|
||||
} from '$lib/server/auth/mfa-totp';
|
||||
import {
|
||||
issueRecoveryCodesInTransaction,
|
||||
RecoveryCodeError,
|
||||
} from '$lib/server/auth/mfa-recovery';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
|
||||
const RECENT_AUTH_SECONDS = 10 * 60;
|
||||
const SESSION_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
type MfaFailure = {
|
||||
securityError: string;
|
||||
mfaError: string;
|
||||
mfaResultToken: string;
|
||||
resultToken: string;
|
||||
recoveryCodes: null;
|
||||
};
|
||||
|
||||
type FactorState = 'pending' | 'active' | 'revoked';
|
||||
|
||||
type FactorRow = {
|
||||
id: string;
|
||||
state: FactorState;
|
||||
created_at: string;
|
||||
activated_at: string | null;
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
function resultToken() {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
function failure(
|
||||
status: number,
|
||||
key: string,
|
||||
message: string,
|
||||
): ActionFailure<MfaFailure> {
|
||||
const token = resultToken();
|
||||
return fail(status, {
|
||||
securityError: message,
|
||||
[key]: message,
|
||||
mfaError: message,
|
||||
mfaResultToken: token,
|
||||
resultToken: token,
|
||||
recoveryCodes: null,
|
||||
});
|
||||
}
|
||||
|
||||
function isRecent(value: string | null | undefined, now: Date): boolean {
|
||||
if (!value) return false;
|
||||
const timestamp = Date.parse(value);
|
||||
return (
|
||||
Number.isFinite(timestamp) &&
|
||||
timestamp <= now.getTime() &&
|
||||
now.getTime() - timestamp <= RECENT_AUTH_SECONDS * 1000
|
||||
);
|
||||
}
|
||||
|
||||
function validIdentifier(value: FormDataEntryValue | null): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.normalize('NFKC').trim();
|
||||
return normalized.length > 0 && normalized.length <= 200 ? normalized : null;
|
||||
}
|
||||
|
||||
function hashSessionToken(token: string): string {
|
||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function noStore(setHeaders?: RequestEvent['setHeaders']): void {
|
||||
setHeaders?.({
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
});
|
||||
}
|
||||
|
||||
function setAal2SessionCookie(cookies: RequestEvent['cookies'], token: string) {
|
||||
cookies.set('shd_session', token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !dev,
|
||||
maxAge: SESSION_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticated(cookies: RequestEvent['cookies']) {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
return user;
|
||||
}
|
||||
|
||||
async function currentFactors(userId: string, now: Date) {
|
||||
const database = await getDatabase();
|
||||
const rows = (await (
|
||||
await database.prepare(
|
||||
`SELECT id,state,created_at,activated_at,expires_at
|
||||
FROM mfa_totp_factors
|
||||
WHERE user_id=? AND (state='active' OR (state='pending' AND expires_at>?))
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
).all(userId, now.toISOString())) as FactorRow[];
|
||||
return { database, rows };
|
||||
}
|
||||
|
||||
function assuranceFailure(
|
||||
user: Awaited<ReturnType<typeof authenticated>>,
|
||||
now: Date,
|
||||
) {
|
||||
if (user.aal === 2 && isRecent(user.aal2VerifiedAt, now)) return null;
|
||||
if (isRecent(user.aalVerifiedAt, now)) return null;
|
||||
return failure(
|
||||
403,
|
||||
'securityError',
|
||||
'Recent authentication is required before changing TOTP security settings.',
|
||||
);
|
||||
}
|
||||
|
||||
function recentAal2Failure(
|
||||
user: Awaited<ReturnType<typeof authenticated>>,
|
||||
now: Date,
|
||||
) {
|
||||
if (user.aal === 2 && isRecent(user.aal2VerifiedAt, now)) return null;
|
||||
return failure(
|
||||
403,
|
||||
'securityError',
|
||||
'Recent two-factor authentication is required for this operation.',
|
||||
);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, setHeaders }) => {
|
||||
noStore(setHeaders);
|
||||
const user = await authenticated(cookies);
|
||||
const now = new Date();
|
||||
const { database, rows } = await currentFactors(user.id, now);
|
||||
const required = await isFullAdministrator(database, user.id);
|
||||
const active = rows.find((row) => row.state === 'active') ?? null;
|
||||
const pending = rows.find((row) => row.state === 'pending') ?? null;
|
||||
const recovery = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM mfa_recovery_codes
|
||||
WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL`,
|
||||
)
|
||||
).get(user.id);
|
||||
|
||||
return {
|
||||
requiredByAdministratorRole: required,
|
||||
enabled: Boolean(active),
|
||||
activeFactorId: active ? String(active.id) : null,
|
||||
activeFactorCreatedAt: active ? String(active.created_at) : null,
|
||||
activeFactorActivatedAt: active?.activated_at
|
||||
? String(active.activated_at)
|
||||
: null,
|
||||
pendingFactorId: pending ? String(pending.id) : null,
|
||||
pendingFactorCreatedAt: pending ? String(pending.created_at) : null,
|
||||
pendingFactorExpiresAt: pending?.expires_at
|
||||
? String(pending.expires_at)
|
||||
: null,
|
||||
pendingFactor: pending
|
||||
? {
|
||||
id: String(pending.id),
|
||||
factorId: String(pending.id),
|
||||
expiresAt: pending.expires_at ? String(pending.expires_at) : null,
|
||||
}
|
||||
: null,
|
||||
activeFactor: active
|
||||
? {
|
||||
id: String(active.id),
|
||||
factorId: String(active.id),
|
||||
enabledAt: active.activated_at ? String(active.activated_at) : null,
|
||||
}
|
||||
: null,
|
||||
lastAssuranceAt: user.aal2VerifiedAt ?? user.aalVerifiedAt,
|
||||
recoveryCodeCount: Number(recovery?.count ?? 0),
|
||||
recoveryCodesRemaining: Number(recovery?.count ?? 0),
|
||||
totp: {
|
||||
required,
|
||||
requirement: required ? ('required' as const) : ('optional' as const),
|
||||
activeFactor: active
|
||||
? {
|
||||
id: String(active.id),
|
||||
createdAt: String(active.created_at),
|
||||
activatedAt: active.activated_at
|
||||
? String(active.activated_at)
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
pendingFactor: pending
|
||||
? {
|
||||
id: String(pending.id),
|
||||
createdAt: String(pending.created_at),
|
||||
expiresAt: pending.expires_at ? String(pending.expires_at) : null,
|
||||
}
|
||||
: null,
|
||||
recoveryCodesRemaining: Number(recovery?.count ?? 0),
|
||||
},
|
||||
sessionAssurance: {
|
||||
aal: user.aal,
|
||||
sessionCreatedAt: user.sessionCreatedAt,
|
||||
aalVerifiedAt: user.aalVerifiedAt,
|
||||
aal2VerifiedAt: user.aal2VerifiedAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const beginEnrollment = async ({
|
||||
cookies,
|
||||
request,
|
||||
setHeaders,
|
||||
}: RequestEvent) => {
|
||||
noStore(setHeaders);
|
||||
const user = await authenticated(cookies);
|
||||
const now = new Date();
|
||||
const { database, rows } = await currentFactors(user.id, now);
|
||||
const active = rows.find((row) => row.state === 'active');
|
||||
const pending = rows.find((row) => row.state === 'pending');
|
||||
if (pending)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'An enrollment is already waiting for confirmation.',
|
||||
);
|
||||
if (active) {
|
||||
const recent = recentAal2Failure(user, now);
|
||||
if (recent) return recent;
|
||||
} else {
|
||||
const recent = assuranceFailure(user, now);
|
||||
if (recent) return recent;
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const accountLabel = String(
|
||||
form.get('accountLabel') ?? user.loginUsername ?? user.username,
|
||||
)
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
try {
|
||||
const operation = database.transactionAsync(async (transaction) => {
|
||||
const enrollment = await beginTotpEnrollmentInTransaction(transaction, {
|
||||
userId: user.id,
|
||||
accountLabel,
|
||||
now,
|
||||
replaceActive: Boolean(active),
|
||||
});
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'identity',
|
||||
action: 'totp.enrollment_started',
|
||||
entityId: user.id,
|
||||
after: {
|
||||
factorId: enrollment.factorId,
|
||||
replacedFactorId: enrollment.replacedFactorId ?? null,
|
||||
expiresAt: enrollment.expiresAt,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
periodSeconds: 30,
|
||||
},
|
||||
database: transaction as unknown as Database,
|
||||
});
|
||||
return enrollment;
|
||||
});
|
||||
const enrollment = await operation.immediate();
|
||||
const token = resultToken();
|
||||
return {
|
||||
enrollmentStarted: true,
|
||||
factorId: enrollment.factorId,
|
||||
expiresAt: enrollment.expiresAt,
|
||||
secretBase32: enrollment.secretBase32,
|
||||
manualKey: enrollment.secretBase32,
|
||||
base32Key: enrollment.secretBase32,
|
||||
provisioningUri: enrollment.provisioningUri,
|
||||
otpauthUri: enrollment.provisioningUri,
|
||||
replacedFactorId: enrollment.replacedFactorId ?? null,
|
||||
resultToken: token,
|
||||
mfaResultToken: token,
|
||||
mfaError: null,
|
||||
recoveryCodes: null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TotpServiceError)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'TOTP enrollment could not be started. Try again after refreshing the page.',
|
||||
);
|
||||
return failure(
|
||||
500,
|
||||
'securityError',
|
||||
'TOTP enrollment could not be started. Try again later.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmEnrollment = async ({
|
||||
cookies,
|
||||
request,
|
||||
setHeaders,
|
||||
}: RequestEvent) => {
|
||||
noStore(setHeaders);
|
||||
const user = await authenticated(cookies);
|
||||
const form = await request.formData();
|
||||
const factorId = validIdentifier(
|
||||
form.get('factorId') ?? form.get('pendingFactorId'),
|
||||
);
|
||||
const token = String(form.get('code') ?? form.get('token') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (!factorId || !/^\d{6}$/.test(token))
|
||||
return failure(
|
||||
400,
|
||||
'securityError',
|
||||
'Enter the six-digit authenticator code.',
|
||||
);
|
||||
const now = new Date();
|
||||
const { database } = await currentFactors(user.id, now);
|
||||
const pending = await (
|
||||
await database.prepare(
|
||||
`SELECT id,created_at FROM mfa_totp_factors
|
||||
WHERE id=? AND user_id=? AND state='pending' LIMIT 1`,
|
||||
)
|
||||
).get(factorId, user.id);
|
||||
if (!pending)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'That TOTP enrollment is no longer available.',
|
||||
);
|
||||
|
||||
const activeFactor = await (
|
||||
await database.prepare(
|
||||
`SELECT id FROM mfa_totp_factors
|
||||
WHERE user_id=? AND state='active' LIMIT 1`,
|
||||
)
|
||||
).get(user.id);
|
||||
const assurance = activeFactor
|
||||
? recentAal2Failure(user, now)
|
||||
: assuranceFailure(user, now);
|
||||
if (assurance) return assurance;
|
||||
|
||||
const sessionToken = cookies.get('shd_session');
|
||||
if (!sessionToken)
|
||||
return failure(
|
||||
401,
|
||||
'securityError',
|
||||
'Your session has expired. Sign in again.',
|
||||
);
|
||||
|
||||
try {
|
||||
const operation = database.transactionAsync(async (transaction) => {
|
||||
const currentSession = await (
|
||||
await transaction.prepare(
|
||||
'SELECT id FROM sessions WHERE token_hash=? AND user_id=? LIMIT 1',
|
||||
)
|
||||
).get(hashSessionToken(sessionToken), user.id);
|
||||
if (!currentSession) return { kind: 'session_missing' as const };
|
||||
|
||||
const currentPending = await (
|
||||
await transaction.prepare(
|
||||
"SELECT id,state FROM mfa_totp_factors WHERE id=? AND user_id=? AND state='pending' LIMIT 1",
|
||||
)
|
||||
).get(factorId, user.id);
|
||||
if (!currentPending) return { kind: 'factor_missing' as const };
|
||||
|
||||
const verification = await verifyTotpFactorInTransaction(
|
||||
transaction,
|
||||
{ userId: user.id, factorId, token, now },
|
||||
true,
|
||||
false,
|
||||
);
|
||||
if (verification.status !== 'verified')
|
||||
return { kind: 'verification_failed' as const, verification };
|
||||
|
||||
const previousActive = (await (
|
||||
await transaction.prepare(
|
||||
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' AND id<>?",
|
||||
)
|
||||
).all(user.id, factorId)) as Array<{ id: string }>;
|
||||
if (previousActive.length) {
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE user_id=? AND state='active' AND id<>?",
|
||||
)
|
||||
).run(now.toISOString(), user.id, factorId);
|
||||
}
|
||||
const activated = await (
|
||||
await transaction.prepare(
|
||||
`UPDATE mfa_totp_factors
|
||||
SET state='active',expires_at=NULL,activated_at=?
|
||||
WHERE id=? AND user_id=? AND state='pending'`,
|
||||
)
|
||||
).run(now.toISOString(), factorId, user.id);
|
||||
if (activated.changes !== 1) throw new TotpServiceError();
|
||||
|
||||
const issued = await issueRecoveryCodesInTransaction(transaction, {
|
||||
userId: user.id,
|
||||
now,
|
||||
});
|
||||
const nextToken = randomBytes(32).toString('base64url');
|
||||
const nextSessionId = randomUUID();
|
||||
const sessionCreatedAt = now.toISOString();
|
||||
const sessionExpiresAt = new Date(
|
||||
now.getTime() + SESSION_SECONDS * 1000,
|
||||
).toISOString();
|
||||
await (
|
||||
await transaction.prepare(
|
||||
`INSERT INTO sessions(
|
||||
id,user_id,token_hash,expires_at,created_at,aal,aal_verified_at,aal2_verified_at
|
||||
) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
nextSessionId,
|
||||
user.id,
|
||||
hashSessionToken(nextToken),
|
||||
sessionExpiresAt,
|
||||
sessionCreatedAt,
|
||||
2,
|
||||
user.aalVerifiedAt,
|
||||
sessionCreatedAt,
|
||||
);
|
||||
const revokedSessions = await (
|
||||
await transaction.prepare(
|
||||
'DELETE FROM sessions WHERE user_id=? AND id<>?',
|
||||
)
|
||||
).run(user.id, nextSessionId);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'identity',
|
||||
action: 'totp.enrollment_confirmed',
|
||||
entityId: user.id,
|
||||
before: {
|
||||
pendingFactorId: factorId,
|
||||
previousActiveFactorIds: previousActive.map((row) => String(row.id)),
|
||||
},
|
||||
after: {
|
||||
activeFactorId: factorId,
|
||||
recoveryCodesIssued: issued.codes.length,
|
||||
sessionsRevoked: Number(revokedSessions.changes ?? 0),
|
||||
aal: 2,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
periodSeconds: 30,
|
||||
},
|
||||
database: transaction as unknown as Database,
|
||||
});
|
||||
return {
|
||||
kind: 'success' as const,
|
||||
nextToken,
|
||||
previousActiveFactorIds: previousActive.map((row) => String(row.id)),
|
||||
recoveryCodes: issued.codes,
|
||||
revokedSessionCount: Number(revokedSessions.changes ?? 0),
|
||||
};
|
||||
});
|
||||
const result = await operation.immediate();
|
||||
if (result.kind === 'session_missing')
|
||||
return failure(
|
||||
401,
|
||||
'securityError',
|
||||
'Your session has expired. Sign in again.',
|
||||
);
|
||||
if (result.kind === 'factor_missing')
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'That TOTP enrollment is no longer available.',
|
||||
);
|
||||
if (result.kind === 'verification_failed') {
|
||||
const message =
|
||||
result.verification.status === 'locked'
|
||||
? 'Too many invalid codes. Try again after the lock expires.'
|
||||
: result.verification.status === 'expired'
|
||||
? 'That TOTP enrollment has expired.'
|
||||
: 'That authenticator code is invalid or has already been used.';
|
||||
return failure(400, 'securityError', message);
|
||||
}
|
||||
|
||||
setAal2SessionCookie(cookies, result.nextToken);
|
||||
const responseToken = resultToken();
|
||||
return {
|
||||
enrollmentConfirmed: true,
|
||||
factorId,
|
||||
recoveryCodes: result.recoveryCodes,
|
||||
recoveryCodesIssued: result.recoveryCodes.length,
|
||||
resultToken: responseToken,
|
||||
mfaResultToken: responseToken,
|
||||
mfaError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TotpServiceError || error instanceof RecoveryCodeError)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'TOTP enrollment could not be confirmed.',
|
||||
);
|
||||
return failure(
|
||||
500,
|
||||
'securityError',
|
||||
'TOTP enrollment could not be confirmed. Try again later.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEnrollment = async ({
|
||||
cookies,
|
||||
request,
|
||||
setHeaders,
|
||||
}: RequestEvent) => {
|
||||
noStore(setHeaders);
|
||||
const user = await authenticated(cookies);
|
||||
const cancelForm = await request.formData();
|
||||
const requestedFactorId = validIdentifier(
|
||||
cancelForm.get('factorId') ?? cancelForm.get('pendingFactorId'),
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const pending = await (
|
||||
await database.prepare(
|
||||
requestedFactorId
|
||||
? "SELECT id,created_at,expires_at FROM mfa_totp_factors WHERE id=? AND user_id=? AND state='pending' LIMIT 1"
|
||||
: "SELECT id,created_at,expires_at FROM mfa_totp_factors WHERE user_id=? AND state='pending' LIMIT 1",
|
||||
)
|
||||
).get(...(requestedFactorId ? [requestedFactorId, user.id] : [user.id]));
|
||||
if (!pending)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'That TOTP enrollment is no longer available.',
|
||||
);
|
||||
const expired =
|
||||
typeof pending.expires_at !== 'string' || pending.expires_at <= now;
|
||||
let result: boolean | null;
|
||||
try {
|
||||
const cancellation = database.transactionAsync(async (transaction) => {
|
||||
const current = await (
|
||||
await transaction.prepare(
|
||||
"SELECT id FROM mfa_totp_factors WHERE id=? AND user_id=? AND state='pending' LIMIT 1",
|
||||
)
|
||||
).get(String(pending.id), user.id);
|
||||
if (!current) return null;
|
||||
const updated = await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE id=? AND user_id=? AND state='pending'",
|
||||
)
|
||||
).run(now, String(pending.id), user.id);
|
||||
if (updated.changes !== 1) return null;
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'identity',
|
||||
action: 'totp.enrollment_cancelled',
|
||||
entityId: user.id,
|
||||
before: { pendingFactorId: String(pending.id) },
|
||||
after: {
|
||||
state: 'revoked',
|
||||
factorId: String(pending.id),
|
||||
activeFactorRetained: true,
|
||||
},
|
||||
database: transaction as unknown as Database,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
result = await cancellation.immediate();
|
||||
} catch {
|
||||
return failure(
|
||||
500,
|
||||
'securityError',
|
||||
'TOTP enrollment could not be cancelled. Try again later.',
|
||||
);
|
||||
}
|
||||
if (!result)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'That TOTP enrollment is no longer available.',
|
||||
);
|
||||
if (expired)
|
||||
return failure(409, 'securityError', 'That TOTP enrollment has expired.');
|
||||
const responseToken = resultToken();
|
||||
return {
|
||||
enrollmentCancelled: true,
|
||||
factorId: String(pending.id),
|
||||
resultToken: responseToken,
|
||||
mfaResultToken: responseToken,
|
||||
mfaError: null,
|
||||
recoveryCodes: null,
|
||||
};
|
||||
};
|
||||
|
||||
const regenerateRecoveryCodes = async ({
|
||||
cookies,
|
||||
setHeaders,
|
||||
}: RequestEvent) => {
|
||||
noStore(setHeaders);
|
||||
const user = await authenticated(cookies);
|
||||
const now = new Date();
|
||||
const recent = recentAal2Failure(user, now);
|
||||
if (recent) return recent;
|
||||
const { database } = await currentFactors(user.id, now);
|
||||
const active = await (
|
||||
await database.prepare(
|
||||
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
|
||||
)
|
||||
).get(user.id);
|
||||
if (!active)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'Enable TOTP before regenerating recovery codes.',
|
||||
);
|
||||
try {
|
||||
const operation = database.transactionAsync(async (transaction) => {
|
||||
const current = await (
|
||||
await transaction.prepare(
|
||||
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
|
||||
)
|
||||
).get(user.id);
|
||||
if (!current) return null;
|
||||
const previous = await (
|
||||
await transaction.prepare(
|
||||
`SELECT COUNT(*) AS count FROM mfa_recovery_codes
|
||||
WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL`,
|
||||
)
|
||||
).get(user.id);
|
||||
const issued = await issueRecoveryCodesInTransaction(transaction, {
|
||||
userId: user.id,
|
||||
now,
|
||||
});
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'identity',
|
||||
action: 'totp.recovery_codes_regenerated',
|
||||
entityId: user.id,
|
||||
before: {
|
||||
factorId: String(current.id),
|
||||
unusedRecoveryCodes: Number(previous?.count ?? 0),
|
||||
},
|
||||
after: {
|
||||
factorId: String(current.id),
|
||||
previousCodesRevoked: Number(previous?.count ?? 0),
|
||||
recoveryCodesIssued: issued.codes.length,
|
||||
},
|
||||
database: transaction as unknown as Database,
|
||||
});
|
||||
return {
|
||||
factorId: String(current.id),
|
||||
previousCount: Number(previous?.count ?? 0),
|
||||
codes: issued.codes,
|
||||
};
|
||||
});
|
||||
const result = await operation.immediate();
|
||||
if (!result)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'Enable TOTP before regenerating recovery codes.',
|
||||
);
|
||||
const responseToken = resultToken();
|
||||
return {
|
||||
recoveryCodesRegenerated: true,
|
||||
recoveryCodes: result.codes,
|
||||
recoveryCodesIssued: result.codes.length,
|
||||
resultToken: responseToken,
|
||||
mfaResultToken: responseToken,
|
||||
mfaError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof RecoveryCodeError)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'Recovery codes could not be regenerated.',
|
||||
);
|
||||
return failure(
|
||||
500,
|
||||
'securityError',
|
||||
'Recovery codes could not be regenerated. Try again later.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const disableTotp = async ({ cookies, request, setHeaders }: RequestEvent) => {
|
||||
noStore(setHeaders);
|
||||
const user = await authenticated(cookies);
|
||||
const now = new Date();
|
||||
const recent = recentAal2Failure(user, now);
|
||||
if (recent) return recent;
|
||||
const database = await getDatabase();
|
||||
if (await isFullAdministrator(database, user.id))
|
||||
return failure(
|
||||
403,
|
||||
'securityError',
|
||||
'Full administrators must keep TOTP enabled.',
|
||||
);
|
||||
const disableForm = await request.formData();
|
||||
const requestedFactorId = validIdentifier(
|
||||
disableForm.get('factorId') ?? disableForm.get('activeFactorId'),
|
||||
);
|
||||
try {
|
||||
const operation = database.transactionAsync(async (transaction) => {
|
||||
const active = await (
|
||||
await transaction.prepare(
|
||||
requestedFactorId
|
||||
? "SELECT id FROM mfa_totp_factors WHERE id=? AND user_id=? AND state='active' LIMIT 1"
|
||||
: "SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
|
||||
)
|
||||
).get(...(requestedFactorId ? [requestedFactorId, user.id] : [user.id]));
|
||||
if (!active) return null;
|
||||
const factorId = String(active.id);
|
||||
const factors = (await (
|
||||
await transaction.prepare(
|
||||
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state IN ('pending','active')",
|
||||
)
|
||||
).all(user.id)) as Array<{ id: string }>;
|
||||
const revokedAt = now.toISOString();
|
||||
await (
|
||||
await transaction.prepare(
|
||||
"UPDATE mfa_totp_factors SET state='revoked',revoked_at=? WHERE user_id=? AND state IN ('pending','active')",
|
||||
)
|
||||
).run(revokedAt, user.id);
|
||||
const recovery = await (
|
||||
await transaction.prepare(
|
||||
'UPDATE mfa_recovery_codes SET revoked_at=? WHERE user_id=? AND consumed_at IS NULL AND revoked_at IS NULL',
|
||||
)
|
||||
).run(revokedAt, user.id);
|
||||
const transactions = await (
|
||||
await transaction.prepare(
|
||||
'DELETE FROM authentication_transactions WHERE user_id=? AND consumed_at IS NULL',
|
||||
)
|
||||
).run(user.id);
|
||||
const sessions = await (
|
||||
await transaction.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(user.id);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'identity',
|
||||
action: 'totp.disabled',
|
||||
entityId: user.id,
|
||||
before: {
|
||||
activeFactorId: factorId,
|
||||
factorIds: factors.map((factor) => String(factor.id)),
|
||||
},
|
||||
after: {
|
||||
factorsRevoked: factors.length,
|
||||
recoveryCodesRevoked: Number(recovery.changes ?? 0),
|
||||
authenticationTransactionsRevoked: Number(transactions.changes ?? 0),
|
||||
sessionsRevoked: Number(sessions.changes ?? 0),
|
||||
},
|
||||
database: transaction as unknown as Database,
|
||||
});
|
||||
return {
|
||||
activeFactorId: factorId,
|
||||
factorIds: factors.map((factor) => String(factor.id)),
|
||||
recoveryCodesRevoked: Number(recovery.changes ?? 0),
|
||||
transactionsRevoked: Number(transactions.changes ?? 0),
|
||||
sessionsRevoked: Number(sessions.changes ?? 0),
|
||||
};
|
||||
});
|
||||
const result = await operation.immediate();
|
||||
if (!result)
|
||||
return failure(
|
||||
409,
|
||||
'securityError',
|
||||
'That TOTP factor is no longer available.',
|
||||
);
|
||||
cookies.delete('shd_session', { path: '/' });
|
||||
const responseToken = resultToken();
|
||||
return {
|
||||
totpDisabled: true,
|
||||
resultToken: responseToken,
|
||||
mfaResultToken: responseToken,
|
||||
mfaError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TotpServiceError || error instanceof RecoveryCodeError)
|
||||
return failure(409, 'securityError', 'TOTP could not be disabled.');
|
||||
return failure(
|
||||
500,
|
||||
'securityError',
|
||||
'TOTP could not be disabled. Try again later.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
beginEnrollment,
|
||||
confirmEnrollment,
|
||||
cancelEnrollment,
|
||||
regenerateRecoveryCodes,
|
||||
disableTotp,
|
||||
beginTotpEnrollment: beginEnrollment,
|
||||
confirmTotpEnrollment: confirmEnrollment,
|
||||
cancelTotpEnrollment: cancelEnrollment,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
let { data: rawData, form } = $props();
|
||||
const data = $derived(rawData as any);
|
||||
let consumedToken = $state<string | null>(null);
|
||||
let confirmRegenerate = $state(false);
|
||||
let confirmDisable = $state(false);
|
||||
let copied = $state(false);
|
||||
let displayedRecoveryCodes = $state<string[] | null>(null);
|
||||
let setupSecret = $state<{ token: string; factorId: string; otpauthUri: string; manualKey: string; expiresAt: unknown } | null>(null);
|
||||
const result = $derived(form?.mfaResultToken && form.mfaResultToken !== consumedToken ? form : null);
|
||||
$effect(() => {
|
||||
if (form?.mfaResultToken && form.mfaResultToken !== consumedToken) {
|
||||
consumedToken = form.mfaResultToken;
|
||||
if (typeof form.otpauthUri === 'string' && typeof form.manualKey === 'string') {
|
||||
setupSecret = {
|
||||
token: form.mfaResultToken,
|
||||
factorId: String(form.factorId ?? (form as any).pendingFactorId ?? ''),
|
||||
otpauthUri: form.otpauthUri,
|
||||
manualKey: form.manualKey,
|
||||
expiresAt: form.expiresAt ?? (form as any).expires_at
|
||||
};
|
||||
} else {
|
||||
setupSecret = null;
|
||||
}
|
||||
if (Array.isArray(form.recoveryCodes) && form.recoveryCodes.length === 10) displayedRecoveryCodes = [...form.recoveryCodes];
|
||||
else displayedRecoveryCodes = null;
|
||||
}
|
||||
});
|
||||
const adminRequired = $derived(Boolean(data?.requiredByAdministratorRole));
|
||||
const enabled = $derived(Boolean(data?.enabled));
|
||||
const pending = $derived(data?.pendingFactor ?? null);
|
||||
const active = $derived(data?.activeFactor ?? null);
|
||||
const pendingId = $derived(pending ? String(pending.factorId ?? pending.id ?? '') : '');
|
||||
const setup = $derived(setupSecret ?? pending);
|
||||
const recoveryCodes = $derived(displayedRecoveryCodes);
|
||||
$effect(() => {
|
||||
if (setupSecret && pendingId && setupSecret.factorId && pendingId !== setupSecret.factorId) setupSecret = null;
|
||||
});
|
||||
async function copyCodes() {
|
||||
if (!recoveryCodes?.length) return;
|
||||
try { await navigator.clipboard.writeText(recoveryCodes.join('\n')); copied = true; } catch { copied = false; }
|
||||
}
|
||||
function date(value: unknown) { if (!value) return 'NOT RECORDED'; const parsed = new Date(String(value)); return Number.isNaN(parsed.valueOf()) ? String(value) : parsed.toLocaleString(); }
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Security — SHD.build</title></svelte:head>
|
||||
<main class="security-page"><section class="security-console">
|
||||
<div class="console-head"><span>ACCOUNT SECURITY</span><b>SHD // IDENTITY</b></div>
|
||||
<header class="security-header"><div><p class="eyebrow"><span></span> AUTHENTICATION POSTURE</p><h1>SECOND<br/><em>FACTOR.</em></h1></div><strong class:required={adminRequired}>{adminRequired ? (enabled ? 'REQUIRED · ACTIVE' : 'REQUIRED · SETUP NEEDED') : (enabled ? 'ACTIVE' : 'OPTIONAL')}</strong></header>
|
||||
<p class="security-intro">Time-based codes add a separate check to password, email, Discord, and passkey sign-in. SHD.build stores the factor, not a QR image or external QR-service request.</p>
|
||||
{#if form?.mfaError}<p class="error" role="alert" aria-live="polite">{form.mfaError}</p>{/if}
|
||||
{#if result && !recoveryCodes}<p class="success" role="status" aria-live="polite">Security settings updated.</p>{/if}
|
||||
{#if recoveryCodes}
|
||||
<section class="recovery-panel" aria-labelledby="recovery-title"><p class="eyebrow">ONE-TIME DISPLAY</p><h2 id="recovery-title">SAVE YOUR RECOVERY CODES</h2><p class="warning"><b>Save these ten codes now.</b> They are shown once and cannot be displayed again after this result is consumed or you leave this page.</p><div class="code-grid" aria-label="Ten recovery codes">{#each recoveryCodes as code}<code>{code}</code>{/each}</div><button type="button" class="secondary" onclick={copyCodes}>{copied ? 'COPIED ALL CODES' : 'COPY ALL CODES'}</button><p class="identity-note">If copying is unavailable, select the codes above and save them in a password manager or offline.</p></section>
|
||||
{/if}
|
||||
<section class="factor-card"><div class="card-heading"><div><p class="eyebrow">TOTP AUTHENTICATOR</p><h2>{enabled ? 'AUTHENTICATOR ACTIVE' : 'SET UP AUTHENTICATOR'}</h2></div><span class:active={enabled}>{enabled ? 'ACTIVE' : 'NOT CONFIGURED'}</span></div>
|
||||
{#if setup}<div class="setup-box"><h3>{enabled ? 'REPLACEMENT SETUP IN PROGRESS' : 'SETUP IN PROGRESS'}</h3><p>Open the link in your authenticator, or enter the key manually. Confirm with the six-digit code from the app before {date(setup.expiresAt ?? setup.expires_at)}.</p>{#if setup.otpauthUri}<a class="secondary action-link" href={setup.otpauthUri}>OPEN IN AUTHENTICATOR ↗</a>{/if}{#if setup.manualKey ?? setup.base32Key}<label class="field">MANUAL BASE32 KEY<input readonly value={setup.manualKey ?? setup.base32Key} aria-label="Manual Base32 authenticator key"/></label>{/if}<PendingForm action="?/confirmEnrollment"><label class="field">SIX-DIGIT CONFIRMATION CODE<input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6" required/></label><input type="hidden" name="factorId" value={setup.factorId ?? setup.id ?? ''}/><button class="primary">CONFIRM AND ACTIVATE →</button></PendingForm><PendingForm action="?/cancelEnrollment" toast={false}><button class="secondary">CANCEL SETUP</button></PendingForm></div>
|
||||
{:else if enabled}<p class="factor-meta">Enabled {date(active?.enabledAt ?? active?.enabled_at)} · Last assurance {date(data?.lastAssuranceAt ?? data?.last_assurance_at)} · Recovery codes remaining: <b>{data?.recoveryCodeCount ?? data?.recovery_count ?? 0}</b></p><div class="factor-actions"><button type="button" class="secondary" onclick={() => confirmRegenerate = true}>REGENERATE RECOVERY CODES</button><PendingForm action="?/beginEnrollment" toast={false}><button class="secondary">REPLACE AUTHENTICATOR</button></PendingForm>{#if !adminRequired}<button type="button" class="danger" onclick={() => confirmDisable = true}>DISABLE AUTHENTICATOR</button>{/if}</div>{#if confirmRegenerate}<div class="confirm-box"><b>REGENERATE RECOVERY CODES?</b><span>Existing recovery codes stop working immediately.</span><div><button type="button" class="secondary" onclick={() => confirmRegenerate = false}>CANCEL</button><PendingForm action="?/regenerateRecoveryCodes"><button class="primary">CONFIRM REGENERATE</button></PendingForm></div></div>{/if}{#if confirmDisable}<div class="confirm-box danger-box"><b>DISABLE AUTHENTICATOR?</b><span>This removes the second factor from this account.</span><div><button type="button" class="secondary" onclick={() => confirmDisable = false}>CANCEL</button><PendingForm action="?/disableTotp"><button class="danger">CONFIRM DISABLE</button></PendingForm></div></div>{/if}
|
||||
{:else}<p class="factor-meta">No authenticator is active. Setup takes one authenticator app and a few minutes.</p><PendingForm action="?/beginEnrollment" toast={false}><button class="primary">BEGIN AUTHENTICATOR SETUP →</button></PendingForm>{/if}
|
||||
</section>
|
||||
<a class="back-link" href="/account">← RETURN TO ACCOUNT</a>
|
||||
</section></main>
|
||||
|
||||
<style>
|
||||
.security-page{max-width:920px;margin:0 auto;padding:28px 14px}.security-console{display:grid;gap:18px}.console-head{display:flex;justify-content:space-between;border-bottom:1px solid var(--shd-border);padding:12px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.console-head b{color:var(--shd-text-dim);font-weight:700}.security-header{display:flex;align-items:end;justify-content:space-between;gap:18px;padding:12px 0}.security-header h1{margin:0;font-size:clamp(2.8rem,8vw,6rem);line-height:.84}.security-header em{color:var(--shd-orange);font-style:normal}.security-header strong{border:1px solid var(--shd-border);padding:10px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.security-header strong.required{color:var(--shd-orange);border-color:var(--shd-orange)}.security-intro,.factor-meta{color:var(--shd-text-muted);max-width:70ch}.factor-card,.recovery-panel{border:1px solid var(--shd-border);background:#0d1011;padding:clamp(16px,3vw,28px);display:grid;gap:16px}.card-heading{display:flex;justify-content:space-between;gap:12px}.card-heading h2,.recovery-panel h2{margin:0}.card-heading span{height:max-content;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900}.card-heading span.active{color:var(--shd-orange)}.setup-box{display:grid;gap:13px;border-left:2px solid var(--shd-orange);padding-left:16px}.setup-box h3{margin:0;color:var(--shd-orange)}.action-link{display:inline-flex;width:max-content;text-decoration:none}.factor-actions{display:flex;flex-wrap:wrap;gap:8px}.confirm-box{display:grid;gap:7px;border-top:1px solid var(--shd-border);padding-top:14px}.confirm-box>span{color:var(--shd-text-muted)}.confirm-box>div{display:flex;gap:8px;flex-wrap:wrap}.recovery-panel{border-color:var(--shd-orange)}.warning{border-left:2px solid var(--shd-orange);padding-left:10px;color:var(--shd-text-muted)}.code-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.code-grid code{padding:10px;background:#070909;border:1px solid var(--shd-border);color:var(--shd-text);font-size:1rem;letter-spacing:1px;user-select:all}.identity-note{color:var(--shd-text-dim)}.back-link{color:var(--shd-orange);text-underline-offset:3px}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--shd-orange);outline-offset:3px}@media(max-width:600px){.security-header{align-items:start;flex-direction:column}.code-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Secret } from 'otpauth';
|
||||
|
||||
const {
|
||||
key,
|
||||
databaseRef,
|
||||
userRef,
|
||||
administratorRef,
|
||||
auditEvents,
|
||||
auditFailure,
|
||||
} = vi.hoisted(() => ({
|
||||
key: Buffer.alloc(32, 0x71).toString('base64url'),
|
||||
databaseRef: { current: null as Database | null },
|
||||
userRef: { current: null as Record<string, unknown> | null },
|
||||
administratorRef: { current: false },
|
||||
auditEvents: [] as Array<Record<string, unknown>>,
|
||||
auditFailure: { current: false },
|
||||
}));
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { MFA_TOTP_ENCRYPTION_KEYS: `active:${key}` },
|
||||
}));
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => databaseRef.current),
|
||||
}));
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => userRef.current),
|
||||
}));
|
||||
vi.mock('$lib/server/auth/bootstrap', () => ({
|
||||
isFullAdministrator: vi.fn(async () => administratorRef.current),
|
||||
}));
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: vi.fn(async (event: Record<string, unknown>) => {
|
||||
if (auditFailure.current) throw new Error('audit unavailable');
|
||||
auditEvents.push(event);
|
||||
}),
|
||||
}));
|
||||
|
||||
import { generateTotpCode } from '$lib/server/auth/mfa-totp';
|
||||
import { actions, load } from './+page.server';
|
||||
|
||||
const SESSION_TOKEN = 'current-session-token';
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function cookies() {
|
||||
const values = new Map<string, string>([['shd_session', SESSION_TOKEN]]);
|
||||
return {
|
||||
values,
|
||||
get: (name: string) => values.get(name),
|
||||
set: vi.fn((name: string, value: string) => values.set(name, value)),
|
||||
delete: vi.fn((name: string) => values.delete(name)),
|
||||
};
|
||||
}
|
||||
|
||||
function event(
|
||||
cookieJar: ReturnType<typeof cookies>,
|
||||
fields: Record<string, string> = {},
|
||||
) {
|
||||
return {
|
||||
cookies: cookieJar,
|
||||
request: new Request('https://shd.build/account/security', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(fields),
|
||||
}),
|
||||
} as never;
|
||||
}
|
||||
|
||||
async function createDatabase(): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE mfa_totp_factors (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, state TEXT NOT NULL,
|
||||
encrypted_secret TEXT NOT NULL, encryption_key_id TEXT NOT NULL,
|
||||
issuer TEXT NOT NULL, account_label TEXT NOT NULL, algorithm TEXT NOT NULL,
|
||||
digits INTEGER NOT NULL, period_seconds INTEGER NOT NULL,
|
||||
last_counter INTEGER, failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT, expires_at TEXT, created_at TEXT NOT NULL,
|
||||
activated_at TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX mfa_active ON mfa_totp_factors(user_id)
|
||||
WHERE state='active';
|
||||
CREATE UNIQUE INDEX mfa_pending ON mfa_totp_factors(user_id)
|
||||
WHERE state='pending';
|
||||
CREATE TABLE mfa_recovery_codes (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, consumed_at TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE authentication_transactions (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, consumed_at TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL,
|
||||
aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT
|
||||
);
|
||||
CREATE TABLE audit_log (
|
||||
id TEXT PRIMARY KEY, actor_id TEXT NOT NULL, entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT,
|
||||
after_json TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
async function seedCurrentSession(database: Database) {
|
||||
const now = new Date();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO sessions(
|
||||
id,user_id,token_hash,expires_at,created_at,aal,aal_verified_at,aal2_verified_at
|
||||
) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
'session-1',
|
||||
'user-1',
|
||||
hashToken(SESSION_TOKEN),
|
||||
new Date(now.getTime() + 86_400_000).toISOString(),
|
||||
now.toISOString(),
|
||||
1,
|
||||
now.toISOString(),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function setUser(overrides: Record<string, unknown> = {}) {
|
||||
const now = new Date().toISOString();
|
||||
userRef.current = {
|
||||
id: 'user-1',
|
||||
username: 'agent',
|
||||
loginUsername: 'agent',
|
||||
role: 'user',
|
||||
aal: 1,
|
||||
authLevel: 1,
|
||||
sessionCreatedAt: now,
|
||||
aalVerifiedAt: now,
|
||||
aal2VerifiedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('account TOTP actions', () => {
|
||||
let database: Database;
|
||||
let cookieJar: ReturnType<typeof cookies>;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
databaseRef.current = database;
|
||||
administratorRef.current = false;
|
||||
auditFailure.current = false;
|
||||
auditEvents.length = 0;
|
||||
cookieJar = cookies();
|
||||
setUser();
|
||||
await seedCurrentSession(database);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
databaseRef.current = null;
|
||||
userRef.current = null;
|
||||
await database.close();
|
||||
});
|
||||
|
||||
it('loads safe state and returns recovery codes only in the confirmation result', async () => {
|
||||
const started = await actions.beginEnrollment(event(cookieJar));
|
||||
expect(started).toMatchObject({ enrollmentStarted: true });
|
||||
const enrollment = started as { factorId: string; secretBase32: string };
|
||||
const code = generateTotpCode(
|
||||
Secret.fromBase32(enrollment.secretBase32).bytes,
|
||||
);
|
||||
const confirmed = await actions.confirmEnrollment(
|
||||
event(cookieJar, { factorId: enrollment.factorId, code }),
|
||||
);
|
||||
expect(confirmed).toMatchObject({
|
||||
enrollmentConfirmed: true,
|
||||
recoveryCodesIssued: 10,
|
||||
mfaResultToken: expect.any(String),
|
||||
});
|
||||
const recoveryCodes = (confirmed as { recoveryCodes: string[] })
|
||||
.recoveryCodes;
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const loaded = await load({ cookies: cookieJar } as never);
|
||||
const serialized = JSON.stringify(loaded);
|
||||
expect(serialized).not.toContain(enrollment.secretBase32);
|
||||
expect(recoveryCodes.every((value) => !serialized.includes(value))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(loaded).toMatchObject({ enabled: true, recoveryCodeCount: 10 });
|
||||
expect(
|
||||
auditEvents.every((audit) => {
|
||||
const text = JSON.stringify(audit);
|
||||
return (
|
||||
!text.includes(enrollment.secretBase32) &&
|
||||
!recoveryCodes.some((code) => text.includes(code))
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rolls back confirmation when the audit append fails', async () => {
|
||||
const started = (await actions.beginEnrollment(event(cookieJar))) as {
|
||||
factorId: string;
|
||||
secretBase32: string;
|
||||
};
|
||||
const code = generateTotpCode(
|
||||
Secret.fromBase32(started.secretBase32).bytes,
|
||||
);
|
||||
auditFailure.current = true;
|
||||
const result = await actions.confirmEnrollment(
|
||||
event(cookieJar, { factorId: started.factorId, code }),
|
||||
);
|
||||
expect(result).toMatchObject({ status: 500 });
|
||||
const factor = await (
|
||||
await database.prepare(
|
||||
'SELECT state,last_counter FROM mfa_totp_factors WHERE id=?',
|
||||
)
|
||||
).get(started.factorId);
|
||||
expect(factor).toMatchObject({ state: 'pending', last_counter: null });
|
||||
const recovery = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE user_id=?',
|
||||
)
|
||||
).get('user-1');
|
||||
expect(Number(recovery?.count)).toBe(0);
|
||||
const sessions = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM sessions WHERE user_id=?',
|
||||
)
|
||||
).get('user-1');
|
||||
expect(Number(sessions?.count)).toBe(1);
|
||||
});
|
||||
|
||||
it('rotates the factor, reissues AAL2, and revokes other sessions', async () => {
|
||||
const started = (await actions.beginEnrollment(event(cookieJar))) as {
|
||||
factorId: string;
|
||||
secretBase32: string;
|
||||
};
|
||||
const firstCode = generateTotpCode(
|
||||
Secret.fromBase32(started.secretBase32).bytes,
|
||||
);
|
||||
await actions.confirmEnrollment(
|
||||
event(cookieJar, { factorId: started.factorId, code: firstCode }),
|
||||
);
|
||||
const currentToken = cookieJar.values.get('shd_session')!;
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at,aal,aal_verified_at,aal2_verified_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
'other-session',
|
||||
'user-1',
|
||||
hashToken('other-session-token'),
|
||||
new Date(Date.now() + 86_400_000).toISOString(),
|
||||
now,
|
||||
1,
|
||||
now,
|
||||
null,
|
||||
);
|
||||
setUser({
|
||||
aal: 2,
|
||||
authLevel: 2,
|
||||
aal2VerifiedAt: now,
|
||||
aalVerifiedAt: now,
|
||||
});
|
||||
|
||||
const replacement = (await actions.beginEnrollment(event(cookieJar))) as {
|
||||
factorId: string;
|
||||
secretBase32: string;
|
||||
};
|
||||
const retained = await (
|
||||
await database.prepare(
|
||||
"SELECT state FROM mfa_totp_factors WHERE state='active' AND user_id=?",
|
||||
)
|
||||
).get('user-1');
|
||||
expect(retained?.state).toBe('active');
|
||||
const replacementCode = generateTotpCode(
|
||||
Secret.fromBase32(replacement.secretBase32).bytes,
|
||||
);
|
||||
const confirmed = await actions.confirmEnrollment(
|
||||
event(cookieJar, {
|
||||
factorId: replacement.factorId,
|
||||
code: replacementCode,
|
||||
}),
|
||||
);
|
||||
expect(confirmed).toMatchObject({ enrollmentConfirmed: true });
|
||||
expect(cookieJar.values.get('shd_session')).not.toBe(currentToken);
|
||||
const sessions = await (
|
||||
await database.prepare('SELECT id FROM sessions WHERE user_id=?')
|
||||
).all('user-1');
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(
|
||||
auditEvents.some((audit) => audit.action === 'totp.enrollment_confirmed'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('regenerates recovery codes only after recent AAL2', async () => {
|
||||
const started = (await actions.beginEnrollment(event(cookieJar))) as {
|
||||
factorId: string;
|
||||
secretBase32: string;
|
||||
};
|
||||
const code = generateTotpCode(
|
||||
Secret.fromBase32(started.secretBase32).bytes,
|
||||
);
|
||||
const confirmed = (await actions.confirmEnrollment(
|
||||
event(cookieJar, { factorId: started.factorId, code }),
|
||||
)) as { recoveryCodes: string[] };
|
||||
const now = new Date().toISOString();
|
||||
setUser({ aal: 2, authLevel: 2, aal2VerifiedAt: now });
|
||||
const regenerated = await actions.regenerateRecoveryCodes(event(cookieJar));
|
||||
expect(regenerated).toMatchObject({
|
||||
recoveryCodesRegenerated: true,
|
||||
recoveryCodesIssued: 10,
|
||||
mfaResultToken: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
(regenerated as { recoveryCodes: string[] }).recoveryCodes,
|
||||
).not.toEqual(confirmed.recoveryCodes);
|
||||
const revoked = await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE revoked_at IS NOT NULL',
|
||||
)
|
||||
).get();
|
||||
expect(Number(revoked?.count)).toBe(10);
|
||||
});
|
||||
|
||||
it('rejects disable for a full administrator without changing the factor', async () => {
|
||||
const started = (await actions.beginEnrollment(event(cookieJar))) as {
|
||||
factorId: string;
|
||||
secretBase32: string;
|
||||
};
|
||||
const code = generateTotpCode(
|
||||
Secret.fromBase32(started.secretBase32).bytes,
|
||||
);
|
||||
await actions.confirmEnrollment(
|
||||
event(cookieJar, { factorId: started.factorId, code }),
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
setUser({ aal: 2, authLevel: 2, aal2VerifiedAt: now });
|
||||
administratorRef.current = true;
|
||||
const result = await actions.disableTotp(
|
||||
event(cookieJar, { factorId: started.factorId }),
|
||||
);
|
||||
expect(result).toMatchObject({ status: 403 });
|
||||
const factor = await (
|
||||
await database.prepare('SELECT state FROM mfa_totp_factors WHERE id=?')
|
||||
).get(started.factorId);
|
||||
expect(factor?.state).toBe('active');
|
||||
});
|
||||
|
||||
it('redirects stale unauthenticated actions to sign-in', async () => {
|
||||
userRef.current = null;
|
||||
await expect(
|
||||
actions.beginEnrollment(event(cookieJar)),
|
||||
).rejects.toMatchObject({
|
||||
status: 303,
|
||||
location: '/sign-in',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import {
|
||||
createSession,
|
||||
getSessionUser,
|
||||
recordLogin,
|
||||
} from '$lib/server/auth/session';
|
||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||
import { getSessionUser, recordLogin } from '$lib/server/auth/session';
|
||||
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
@@ -190,7 +186,6 @@ export const GET: RequestHandler = async ({
|
||||
created = true;
|
||||
}
|
||||
|
||||
await ensureBootstrapAdmin(database, String(user.id));
|
||||
const status = await (
|
||||
await database.prepare(
|
||||
'SELECT blocked_at,banned_until FROM user_status WHERE user_id=?',
|
||||
@@ -209,12 +204,15 @@ export const GET: RequestHandler = async ({
|
||||
redirect(303, '/sign-in?oauth_error=account_blocked');
|
||||
}
|
||||
await saveDiscordProfile(String(user.id), discordUser);
|
||||
const sessionId = await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'discord', true, {
|
||||
const authentication = await completePrimaryAuthentication({
|
||||
database,
|
||||
cookies,
|
||||
userId: String(user.id),
|
||||
provider: 'discord',
|
||||
request,
|
||||
getClientAddress,
|
||||
credentialId: discordUser.id,
|
||||
sessionId,
|
||||
returnPath: created ? '/account?choose_display_name=1' : '/account',
|
||||
});
|
||||
redirect(303, created ? '/account?choose_display_name=1' : '/account');
|
||||
redirect(303, authentication.redirectTo);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||
import { consumeMagicLinkToken } from '$lib/server/auth/magic-link';
|
||||
import { createSession, recordLogin } from '$lib/server/auth/session';
|
||||
import { recordLogin } from '$lib/server/auth/session';
|
||||
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -43,15 +43,17 @@ export const GET: RequestHandler = async ({
|
||||
ON CONFLICT(user_id) DO UPDATE SET email=excluded.email,verified_at=excluded.verified_at,updated_at=excluded.updated_at`)
|
||||
).run(link.userId, link.email, now, now);
|
||||
}
|
||||
await ensureBootstrapAdmin(database, link.userId);
|
||||
const sessionId = await createSession(cookies, link.userId);
|
||||
await recordLogin(link.userId, 'magic_link', true, {
|
||||
const authentication = await completePrimaryAuthentication({
|
||||
database,
|
||||
cookies,
|
||||
userId: link.userId,
|
||||
provider: 'magic_link',
|
||||
request,
|
||||
getClientAddress,
|
||||
sessionId,
|
||||
returnPath:
|
||||
link.purpose === 'verify_email'
|
||||
? '/account?email_verified=1'
|
||||
: '/account',
|
||||
});
|
||||
redirect(
|
||||
303,
|
||||
link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account',
|
||||
);
|
||||
redirect(303, authentication.redirectTo);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||
import { consumeChallenge } from '$lib/server/auth/passkeys';
|
||||
import { createSession, recordLogin } from '$lib/server/auth/session';
|
||||
import { recordLogin } from '$lib/server/auth/session';
|
||||
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import {
|
||||
verifyAuthenticationResponse,
|
||||
@@ -91,13 +91,14 @@ export const POST: RequestHandler = async ({
|
||||
'UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?',
|
||||
)
|
||||
).run(verification.authenticationInfo.newCounter, now, passkey.id);
|
||||
await ensureBootstrapAdmin(database, String(passkey.user_id));
|
||||
const sessionId = await createSession(cookies, String(passkey.user_id));
|
||||
await recordLogin(String(passkey.user_id), 'passkey', true, {
|
||||
const authentication = await completePrimaryAuthentication({
|
||||
database,
|
||||
cookies,
|
||||
userId: String(passkey.user_id),
|
||||
provider: 'passkey',
|
||||
request,
|
||||
getClientAddress,
|
||||
credentialId: String(passkey.id),
|
||||
sessionId,
|
||||
});
|
||||
return json({ verified: true, redirectTo: '/account' });
|
||||
return json({ verified: true, redirectTo: authentication.redirectTo });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
claimAuthenticationTransactionInTransaction,
|
||||
clearAuthenticationTransactionCookie,
|
||||
consumeAuthenticationTransaction,
|
||||
consumeAuthenticationTransactionInTransaction,
|
||||
getAuthenticationTransaction,
|
||||
recordAuthenticationAttempt,
|
||||
} from '$lib/server/auth/authentication-transactions';
|
||||
import { consumeRecoveryCodeInTransaction } from '$lib/server/auth/mfa-recovery';
|
||||
import { verifyTotpFactorInTransaction } from '$lib/server/auth/mfa-totp';
|
||||
import {
|
||||
createSessionInTransaction,
|
||||
recordLoginInTransaction,
|
||||
setSessionCookie,
|
||||
upgradeSessionToAal2InTransaction,
|
||||
} from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
function noStore(setHeaders: (headers: Record<string, string>) => void): void {
|
||||
setHeaders({
|
||||
'cache-control': 'no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
});
|
||||
}
|
||||
|
||||
async function currentTransaction(
|
||||
cookies: Parameters<typeof getAuthenticationTransaction>[0],
|
||||
) {
|
||||
return getAuthenticationTransaction(cookies);
|
||||
}
|
||||
|
||||
async function failedFactor(
|
||||
cookies: Parameters<typeof getAuthenticationTransaction>[0],
|
||||
setHeaders: (headers: Record<string, string>) => void,
|
||||
message = 'The verification code is invalid or expired.',
|
||||
) {
|
||||
noStore(setHeaders);
|
||||
const database = await getDatabase();
|
||||
const attempt = await recordAuthenticationAttempt(cookies, database);
|
||||
return fail(400, {
|
||||
twoFactorError: attempt.locked
|
||||
? 'This verification attempt has expired. Start sign-in again.'
|
||||
: message,
|
||||
mfaError: attempt.locked
|
||||
? 'This verification attempt has expired. Start sign-in again.'
|
||||
: message,
|
||||
mfaMethod: 'totp',
|
||||
attemptsRemaining: Math.max(0, 5 - attempt.attemptCount),
|
||||
mfaResultToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
function expiredFailure(method: 'totp' | 'recovery' = 'totp') {
|
||||
return fail(400, {
|
||||
twoFactorError:
|
||||
'This verification attempt has expired. Start sign-in again.',
|
||||
mfaError: 'This verification attempt has expired. Start sign-in again.',
|
||||
mfaMethod: method,
|
||||
attemptsRemaining: 0,
|
||||
mfaResultToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function completeSecondFactor(input: {
|
||||
cookies: Parameters<typeof getAuthenticationTransaction>[0];
|
||||
request: Request;
|
||||
getClientAddress?: () => string;
|
||||
setHeaders: (headers: Record<string, string>) => void;
|
||||
source: 'totp' | 'recovery_code';
|
||||
code: string;
|
||||
}) {
|
||||
noStore(input.setHeaders);
|
||||
const database = await getDatabase();
|
||||
class FactorVerificationError extends Error {}
|
||||
const operation = database.transactionAsync(async (transaction) => {
|
||||
const claimed = await claimAuthenticationTransactionInTransaction(
|
||||
transaction,
|
||||
input.cookies,
|
||||
);
|
||||
if (!claimed) return { kind: 'expired' as const };
|
||||
|
||||
let verified = false;
|
||||
if (input.source === 'totp') {
|
||||
const factor = await (
|
||||
await transaction.prepare(
|
||||
"SELECT id FROM mfa_totp_factors WHERE user_id=? AND state='active' LIMIT 1",
|
||||
)
|
||||
).get(claimed.userId);
|
||||
if (!factor) {
|
||||
return { kind: 'factor-failed' as const };
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await verifyTotpFactorInTransaction(transaction, {
|
||||
userId: claimed.userId,
|
||||
factorId: String(factor.id),
|
||||
token: input.code,
|
||||
});
|
||||
} catch {
|
||||
throw new FactorVerificationError();
|
||||
}
|
||||
verified = result.status === 'verified';
|
||||
} else {
|
||||
verified = await consumeRecoveryCodeInTransaction(transaction, {
|
||||
userId: claimed.userId,
|
||||
code: input.code,
|
||||
});
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return { kind: 'factor-failed' as const };
|
||||
}
|
||||
|
||||
const consumed = await consumeAuthenticationTransactionInTransaction(
|
||||
transaction,
|
||||
input.cookies,
|
||||
claimed.id,
|
||||
);
|
||||
if (!consumed) {
|
||||
throw new Error('Unable to consume authentication transaction.');
|
||||
}
|
||||
|
||||
const verifiedAt = new Date();
|
||||
const upgradedSessionId = await upgradeSessionToAal2InTransaction(
|
||||
transaction,
|
||||
input.cookies,
|
||||
claimed.userId,
|
||||
verifiedAt,
|
||||
);
|
||||
const session = upgradedSessionId
|
||||
? { sessionId: upgradedSessionId, token: null }
|
||||
: await createSessionInTransaction(transaction, claimed.userId, {
|
||||
aal: 2,
|
||||
aal2VerifiedAt: verifiedAt,
|
||||
});
|
||||
if (claimed.kind === 'login' && claimed.provider) {
|
||||
await recordLoginInTransaction(
|
||||
transaction,
|
||||
claimed.userId,
|
||||
claimed.provider,
|
||||
true,
|
||||
{
|
||||
request: input.request,
|
||||
getClientAddress: input.getClientAddress,
|
||||
sessionId: session.sessionId,
|
||||
aal: 2,
|
||||
aal2VerifiedAt: verifiedAt.toISOString(),
|
||||
riskFlags: [`mfa_${input.source}_verified`],
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: 'complete' as const,
|
||||
returnPath: claimed.returnPath,
|
||||
session,
|
||||
};
|
||||
});
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await operation.immediate();
|
||||
} catch (error) {
|
||||
if (error instanceof FactorVerificationError)
|
||||
return failedFactor(input.cookies, input.setHeaders);
|
||||
throw error;
|
||||
}
|
||||
if (result.kind === 'expired') {
|
||||
clearAuthenticationTransactionCookie(input.cookies);
|
||||
return expiredFailure(
|
||||
input.source === 'recovery_code' ? 'recovery' : 'totp',
|
||||
);
|
||||
}
|
||||
if (result.kind === 'factor-failed')
|
||||
return failedFactor(input.cookies, input.setHeaders);
|
||||
if (result.session.token)
|
||||
setSessionCookie(input.cookies, result.session.token);
|
||||
clearAuthenticationTransactionCookie(input.cookies);
|
||||
redirect(303, result.returnPath);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, setHeaders }) => {
|
||||
noStore(setHeaders);
|
||||
const transaction = await currentTransaction(cookies);
|
||||
if (!transaction) redirect(303, '/sign-in?mfa_error=expired');
|
||||
return {
|
||||
transaction: {
|
||||
id: transaction.id,
|
||||
kind: transaction.kind,
|
||||
method: 'totp',
|
||||
label: 'SECURE SESSION',
|
||||
attemptCount: transaction.attemptCount,
|
||||
expiresAt: transaction.expiresAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
verifyTotp: async ({ cookies, request, getClientAddress, setHeaders }) => {
|
||||
noStore(setHeaders);
|
||||
const form = await request.formData();
|
||||
const code = String(form.get('code') ?? '').trim();
|
||||
if (!/^\d{6}$/.test(code)) return failedFactor(cookies, setHeaders);
|
||||
return completeSecondFactor({
|
||||
cookies,
|
||||
request,
|
||||
getClientAddress,
|
||||
setHeaders,
|
||||
source: 'totp',
|
||||
code,
|
||||
});
|
||||
},
|
||||
|
||||
useRecoveryCode: async ({
|
||||
cookies,
|
||||
request,
|
||||
getClientAddress,
|
||||
setHeaders,
|
||||
}) => {
|
||||
noStore(setHeaders);
|
||||
const form = await request.formData();
|
||||
const code = String(form.get('recoveryCode') ?? '').trim();
|
||||
return completeSecondFactor({
|
||||
cookies,
|
||||
request,
|
||||
getClientAddress,
|
||||
setHeaders,
|
||||
source: 'recovery_code',
|
||||
code,
|
||||
});
|
||||
},
|
||||
|
||||
cancel: async ({ cookies, setHeaders }) => {
|
||||
noStore(setHeaders);
|
||||
await consumeAuthenticationTransaction(cookies);
|
||||
redirect(303, '/sign-in');
|
||||
},
|
||||
} satisfies Actions;
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
|
||||
let { data: rawData, form } = $props();
|
||||
const data = $derived(rawData as any);
|
||||
let recoveryMode = $state(false);
|
||||
let consumedToken = $state<string | null>(null);
|
||||
|
||||
const transaction = $derived(data?.transaction ?? {});
|
||||
const response = $derived(form?.mfaResultToken && form.mfaResultToken !== consumedToken ? form : null);
|
||||
$effect(() => {
|
||||
if (form?.mfaResultToken && form.mfaResultToken !== consumedToken) consumedToken = form.mfaResultToken;
|
||||
});
|
||||
const error = $derived(form?.mfaError ?? '');
|
||||
const method = $derived(form?.mfaMethod ?? transaction.method ?? 'totp');
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Verification — SHD.build</title></svelte:head>
|
||||
<main class="auth-page"><div class="auth-frame"><section class="auth-intel">
|
||||
<p class="eyebrow"><span></span> SHD COMMUNITY IDENTITY</p><h1>SECOND<br/><em>CHECK.</em></h1>
|
||||
<p>Confirm this sign-in with the authenticator already assigned to your Agent.</p>
|
||||
<div class="auth-status"><span><i></i> VERIFICATION REQUIRED</span><span>{transaction.label ?? 'SECURE SESSION'}</span></div>
|
||||
</section><section class="auth-console">
|
||||
<div class="console-head"><span>MULTI-FACTOR AUTH</span><b>SHD // AUTH</b></div>
|
||||
<p class="auth-mode-note">Enter a six-digit authenticator code. Recovery codes are single-use alternatives.</p>
|
||||
{#if error}<p class="error" id="mfa-error" role="alert">{error}{#if form?.attemptsRemaining !== undefined} <span>{form.attemptsRemaining} attempts remaining.</span>{/if}</p>{/if}
|
||||
{#if response}<p class="success" aria-live="polite">Verification accepted. Continuing your secure session…</p>{/if}
|
||||
{#if !recoveryMode}
|
||||
<PendingForm action="?/verifyTotp" toast={false}>
|
||||
<label class="field" for="totp-code">AUTHENTICATOR CODE<input id="totp-code" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6" required aria-describedby={error ? 'mfa-error' : undefined}/></label>
|
||||
<button class="primary full">VERIFY CODE →</button>
|
||||
</PendingForm>
|
||||
<button type="button" class="text-action" onclick={() => recoveryMode = true}>USE A RECOVERY CODE INSTEAD</button>
|
||||
{:else}
|
||||
<PendingForm action="?/useRecoveryCode" toast={false}>
|
||||
<label class="field" for="recovery-code">RECOVERY CODE<input id="recovery-code" name="recoveryCode" autocomplete="off" spellcheck="false" required aria-describedby={error ? 'mfa-error' : undefined}/></label>
|
||||
<button class="primary full">USE RECOVERY CODE →</button>
|
||||
</PendingForm>
|
||||
<button type="button" class="text-action" onclick={() => recoveryMode = false}>USE AUTHENTICATOR CODE</button>
|
||||
{/if}
|
||||
<PendingForm action="?/cancel" toast={false}><button class="secondary full">CANCEL SIGN-IN</button></PendingForm>
|
||||
<small class="identity-note">Transaction {transaction.id ?? 'pending'}{method ? ` · ${method.toUpperCase()}` : ''}</small>
|
||||
</section></div></main>
|
||||
|
||||
<style>
|
||||
.auth-page{min-height:70vh;display:grid;place-items:center;padding:28px 14px}.auth-frame{width:min(940px,100%);display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--shd-border);background:rgba(10,13,14,.94);box-shadow:0 20px 70px #0008}.auth-intel,.auth-console{padding:clamp(22px,5vw,48px)}.auth-intel{display:grid;align-content:center;gap:18px;background:linear-gradient(145deg,#171d1d,#0b0e0f)}.auth-intel h1{margin:0;font-size:clamp(2.5rem,6vw,5rem);line-height:.9}.auth-intel h1 em{color:var(--shd-orange);font-style:normal}.auth-intel p:not(.eyebrow){max-width:34ch;color:var(--shd-text-muted)}.auth-console{display:grid;gap:14px}.console-head{display:flex;justify-content:space-between;border-bottom:1px solid var(--shd-border);padding-bottom:12px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.console-head b{color:var(--shd-text-dim);font-weight:700}.auth-mode-note{color:var(--shd-text-muted);margin:0}.auth-status{display:flex;flex-wrap:wrap;gap:10px;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.7px}.auth-status i{display:inline-block;width:7px;height:7px;background:var(--shd-orange);border-radius:50%;margin-right:5px}.text-action{border:0;background:transparent;color:var(--shd-orange);text-align:left;text-decoration:underline;text-underline-offset:3px;padding:3px 0}.identity-note{color:var(--shd-text-dim)}button:focus-visible,input:focus-visible{outline:2px solid var(--shd-orange);outline-offset:3px}@media(max-width:700px){.auth-frame{grid-template-columns:1fr}.auth-intel{padding-bottom:28px}.auth-console{padding-top:28px}}
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { connect, type Database } from '@tursodatabase/database';
|
||||
import {
|
||||
createAuthenticationTransaction,
|
||||
type AuthenticationTransaction,
|
||||
} from '$lib/server/auth/authentication-transactions';
|
||||
import { issueRecoveryCodes } from '$lib/server/auth/mfa-recovery';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ database: null as Database | null }));
|
||||
const providers = ['password', 'discord', 'magic_link', 'passkey'] as const;
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
function cookieJar(seed?: Map<string, string>) {
|
||||
const values = new Map(seed);
|
||||
return {
|
||||
values,
|
||||
get: (name: string) => values.get(name),
|
||||
set: vi.fn((name: string, value: string) => values.set(name, value)),
|
||||
delete: vi.fn((name: string) => values.delete(name)),
|
||||
};
|
||||
}
|
||||
|
||||
function formRequest(name: string, value: string): Request {
|
||||
const form = new FormData();
|
||||
form.set(name, value);
|
||||
return new Request('http://localhost/auth/two-factor', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
async function createDatabase(failSessionCreation = false): Promise<Database> {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`
|
||||
CREATE TABLE authentication_transactions (
|
||||
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL, user_id TEXT NOT NULL,
|
||||
provider TEXT, kind TEXT NOT NULL, return_path TEXT NOT NULL, required_aal INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL, expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, consumed_at TEXT
|
||||
);
|
||||
CREATE TABLE mfa_recovery_codes (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, consumed_at TEXT, revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, aal INTEGER NOT NULL,
|
||||
aal_verified_at TEXT NOT NULL, aal2_verified_at TEXT
|
||||
);
|
||||
CREATE TABLE login_events (
|
||||
id TEXT PRIMARY KEY, user_id TEXT, provider TEXT NOT NULL,
|
||||
succeeded INTEGER NOT NULL, ip_address TEXT, user_agent TEXT,
|
||||
client_name TEXT, operating_system TEXT, device_type TEXT,
|
||||
location_country TEXT, location_region TEXT, location_city TEXT,
|
||||
failure_reason TEXT, risk_flags TEXT, credential_id TEXT, session_id TEXT,
|
||||
aal INTEGER, aal2_verified_at TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
if (failSessionCreation) {
|
||||
await database.exec(`
|
||||
CREATE TRIGGER fail_aal2_session
|
||||
BEFORE INSERT ON sessions
|
||||
WHEN NEW.aal=2
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'session insert failed');
|
||||
END;
|
||||
`);
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
async function seedAuthentication(
|
||||
database: Database,
|
||||
cookies: ReturnType<typeof cookieJar>,
|
||||
provider: 'password' | 'discord' | 'magic_link' | 'passkey' = 'password',
|
||||
): Promise<{ transaction: AuthenticationTransaction; code: string }> {
|
||||
const transaction = await createAuthenticationTransaction(
|
||||
cookies as never,
|
||||
database,
|
||||
{
|
||||
userId: 'user-1',
|
||||
kind: 'login',
|
||||
provider,
|
||||
returnPath: '/account',
|
||||
},
|
||||
);
|
||||
const issued = await issueRecoveryCodes(database, { userId: 'user-1' });
|
||||
return { transaction, code: issued.codes[0] };
|
||||
}
|
||||
|
||||
async function counts(database: Database) {
|
||||
return {
|
||||
transaction: await (
|
||||
await database.prepare(
|
||||
'SELECT consumed_at FROM authentication_transactions LIMIT 1',
|
||||
)
|
||||
).get(),
|
||||
recovery: await (
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM mfa_recovery_codes WHERE consumed_at IS NOT NULL',
|
||||
)
|
||||
).get(),
|
||||
sessions: await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM sessions')
|
||||
).get(),
|
||||
logins: await (
|
||||
await database.prepare('SELECT COUNT(*) AS count FROM login_events')
|
||||
).get(),
|
||||
loginEvent: await (
|
||||
await database.prepare(
|
||||
'SELECT provider,succeeded FROM login_events ORDER BY created_at DESC LIMIT 1',
|
||||
)
|
||||
).get(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('two-factor server transition', () => {
|
||||
let database: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
database = await createDatabase();
|
||||
mocks.database = database;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
mocks.database = null;
|
||||
await database.close();
|
||||
});
|
||||
|
||||
it.each(providers)(
|
||||
'records the completed MFA login with the persisted %s provider',
|
||||
async (provider) => {
|
||||
const cookies = cookieJar();
|
||||
const { code } = await seedAuthentication(database, cookies, provider);
|
||||
|
||||
await expect(
|
||||
actions.useRecoveryCode?.({
|
||||
cookies,
|
||||
request: formRequest('recoveryCode', code),
|
||||
getClientAddress: () => '127.0.0.1',
|
||||
setHeaders: vi.fn(),
|
||||
} as never),
|
||||
).rejects.toMatchObject({ status: 303, location: '/account' });
|
||||
|
||||
const state = await counts(database);
|
||||
expect(state.transaction.consumed_at).toBeTruthy();
|
||||
expect(Number(state.recovery.count)).toBe(1);
|
||||
expect(Number(state.sessions.count)).toBe(1);
|
||||
expect(Number(state.logins.count)).toBe(1);
|
||||
expect(state.loginEvent).toMatchObject({ provider, succeeded: 1 });
|
||||
expect(cookies.set).toHaveBeenCalledWith(
|
||||
'shd_session',
|
||||
expect.any(String),
|
||||
expect.objectContaining({ path: '/', httpOnly: true }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not record a successful login for an abandoned MFA challenge', async () => {
|
||||
const cookies = cookieJar();
|
||||
await seedAuthentication(database, cookies, 'discord');
|
||||
|
||||
const state = await counts(database);
|
||||
expect(Number(state.logins.count)).toBe(0);
|
||||
expect(state.loginEvent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never returns an invalid recovery code in failure data', async () => {
|
||||
const cookies = cookieJar();
|
||||
await seedAuthentication(database, cookies);
|
||||
const result = await actions.useRecoveryCode?.({
|
||||
cookies,
|
||||
request: formRequest('recoveryCode', 'NOT-A-REAL-CODE'),
|
||||
setHeaders: vi.fn(),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(JSON.stringify(result)).not.toContain('NOT-A-REAL-CODE');
|
||||
expect(Number((await counts(database)).logins.count)).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a duplicate submission without consuming another factor', async () => {
|
||||
const firstCookies = cookieJar();
|
||||
const { code } = await seedAuthentication(database, firstCookies);
|
||||
const duplicateCookies = cookieJar(new Map(firstCookies.values));
|
||||
|
||||
await expect(
|
||||
actions.useRecoveryCode?.({
|
||||
cookies: firstCookies,
|
||||
request: formRequest('recoveryCode', code),
|
||||
setHeaders: vi.fn(),
|
||||
} as never),
|
||||
).rejects.toMatchObject({ status: 303 });
|
||||
const beforeDuplicate = await counts(database);
|
||||
|
||||
const result = await actions.useRecoveryCode?.({
|
||||
cookies: duplicateCookies,
|
||||
request: formRequest('recoveryCode', code),
|
||||
setHeaders: vi.fn(),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: { attemptsRemaining: 0 },
|
||||
});
|
||||
const afterDuplicate = await counts(database);
|
||||
expect(Number(afterDuplicate.recovery.count)).toBe(
|
||||
Number(beforeDuplicate.recovery.count),
|
||||
);
|
||||
expect(Number(afterDuplicate.sessions.count)).toBe(1);
|
||||
expect(Number(afterDuplicate.logins.count)).toBe(1);
|
||||
});
|
||||
|
||||
it('serializes cancellation with completion without corrupting factor state', async () => {
|
||||
const seedCookies = cookieJar();
|
||||
const { code } = await seedAuthentication(database, seedCookies);
|
||||
const submitCookies = cookieJar(new Map(seedCookies.values));
|
||||
const cancelCookies = cookieJar(new Map(seedCookies.values));
|
||||
|
||||
const outcomes = await Promise.allSettled([
|
||||
actions.useRecoveryCode?.({
|
||||
cookies: submitCookies,
|
||||
request: formRequest('recoveryCode', code),
|
||||
setHeaders: vi.fn(),
|
||||
} as never),
|
||||
actions.cancel?.({
|
||||
cookies: cancelCookies,
|
||||
setHeaders: vi.fn(),
|
||||
} as never),
|
||||
]);
|
||||
expect(outcomes).toHaveLength(2);
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.status === 'rejected')
|
||||
expect(outcome.reason).toMatchObject({ status: 303 });
|
||||
}
|
||||
|
||||
const state = await counts(database);
|
||||
const recoveryConsumed = Number(state.recovery.count);
|
||||
const sessions = Number(state.sessions.count);
|
||||
const logins = Number(state.logins.count);
|
||||
expect(state.transaction.consumed_at).toBeTruthy();
|
||||
expect(recoveryConsumed).toBeLessThanOrEqual(1);
|
||||
expect(sessions).toBeLessThanOrEqual(1);
|
||||
expect(logins).toBe(sessions);
|
||||
if (sessions === 0) expect(recoveryConsumed).toBe(0);
|
||||
else expect(recoveryConsumed).toBe(1);
|
||||
});
|
||||
|
||||
it('rolls back the factor, transaction, session, and login event when session creation fails', async () => {
|
||||
await database.close();
|
||||
database = await createDatabase(true);
|
||||
mocks.database = database;
|
||||
const cookies = cookieJar();
|
||||
const { code } = await seedAuthentication(database, cookies);
|
||||
|
||||
await expect(
|
||||
actions.useRecoveryCode?.({
|
||||
cookies,
|
||||
request: formRequest('recoveryCode', code),
|
||||
setHeaders: vi.fn(),
|
||||
} as never),
|
||||
).rejects.toThrow('session insert failed');
|
||||
|
||||
const state = await counts(database);
|
||||
expect(state.transaction.consumed_at).toBeNull();
|
||||
expect(Number(state.recovery.count)).toBe(0);
|
||||
expect(Number(state.sessions.count)).toBe(0);
|
||||
expect(Number(state.logins.count)).toBe(0);
|
||||
expect(cookies.set).not.toHaveBeenCalledWith(
|
||||
'shd_session',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
AuthorityChangeError,
|
||||
applyAuthorityChange,
|
||||
} from '$lib/server/auth/authority-change';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -228,6 +232,7 @@ export const actions: Actions = {
|
||||
targetId = String(event.entity_id);
|
||||
let current: Record<string, unknown> | null = null;
|
||||
let restored: Record<string, unknown> | null = null;
|
||||
let auditRecordedInTransaction = false;
|
||||
try {
|
||||
if (action.startsWith('attribute.')) {
|
||||
current =
|
||||
@@ -349,88 +354,47 @@ export const actions: Actions = {
|
||||
permissions: permissionNames,
|
||||
};
|
||||
} else if (action === 'access.updated') {
|
||||
const currentRoles = await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(targetId);
|
||||
const currentPermissions = await (
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||||
)
|
||||
).all(targetId);
|
||||
current = {
|
||||
roleIds: currentRoles.map((row) => row.role_id),
|
||||
permissionIds: currentPermissions.map((row) => row.permission_id),
|
||||
};
|
||||
if (comparable(current) !== comparable(after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The user’s access changed after this event. Reversion was blocked.',
|
||||
});
|
||||
const roleIds = Array.isArray(before.roleIds)
|
||||
? before.roleIds.map(String)
|
||||
: [];
|
||||
const permissionIds = Array.isArray(before.permissionIds)
|
||||
? before.permissionIds.map(String)
|
||||
: [];
|
||||
const restoresAdminRole = roleIds.includes('role-admin');
|
||||
const restoresSystemPermission = permissionIds.includes(
|
||||
'permission-system-admin',
|
||||
);
|
||||
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin)
|
||||
return fail(403, {
|
||||
revertError:
|
||||
'Only a full administrator can restore administrator access.',
|
||||
});
|
||||
const roleCount = roleIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...roleIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
const permissionCount = permissionIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...permissionIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
if (
|
||||
roleCount !== roleIds.length ||
|
||||
permissionCount !== permissionIds.length
|
||||
!Array.isArray(after.roleIds) ||
|
||||
!Array.isArray(after.permissionIds)
|
||||
)
|
||||
return fail(409, {
|
||||
revertError: 'A previous role or permission no longer exists.',
|
||||
revertError: 'The stored access snapshot is incomplete.',
|
||||
});
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(targetId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(targetId);
|
||||
for (const roleId of roleIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
||||
)
|
||||
).run(targetId, roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(targetId, permissionId);
|
||||
restored = { roleIds, permissionIds };
|
||||
const expectedCurrent = {
|
||||
roleIds: after.roleIds.map(String),
|
||||
permissionIds: after.permissionIds.map(String),
|
||||
};
|
||||
const result = await applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId,
|
||||
roleIds,
|
||||
permissionIds,
|
||||
expectedCurrent,
|
||||
audit: {
|
||||
actorId: operator.id,
|
||||
category: 'audit_control',
|
||||
action: 'audit.reverted',
|
||||
entityId: eventId,
|
||||
before: current,
|
||||
after: {
|
||||
originalEventId: eventId,
|
||||
originalAction: action,
|
||||
targetId,
|
||||
restored: { roleIds, permissionIds },
|
||||
},
|
||||
},
|
||||
});
|
||||
current = result.before;
|
||||
restored = result.after;
|
||||
auditRecordedInTransaction = true;
|
||||
} else {
|
||||
return fail(400, {
|
||||
revertError:
|
||||
@@ -438,25 +402,28 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorityChangeError)
|
||||
return fail(error.status, { revertError: error.message });
|
||||
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The previous state could not be restored because related data has changed.',
|
||||
});
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'audit_control',
|
||||
action: 'audit.reverted',
|
||||
entityId: eventId,
|
||||
before: current,
|
||||
after: {
|
||||
originalEventId: eventId,
|
||||
originalAction: action,
|
||||
targetId,
|
||||
restored,
|
||||
},
|
||||
});
|
||||
if (!auditRecordedInTransaction)
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'audit_control',
|
||||
action: 'audit.reverted',
|
||||
entityId: eventId,
|
||||
before: current,
|
||||
after: {
|
||||
originalEventId: eventId,
|
||||
originalAction: action,
|
||||
targetId,
|
||||
restored,
|
||||
},
|
||||
});
|
||||
return { reverted: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
database: { prepare: vi.fn() },
|
||||
getDatabase: vi.fn(),
|
||||
getPermissions: vi.fn(),
|
||||
requirePermission: vi.fn(),
|
||||
getSessionUser: vi.fn(),
|
||||
applyAuthorityChange: vi.fn(),
|
||||
recordIsacAudit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: mocks.getSessionUser,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
getPermissions: mocks.getPermissions,
|
||||
requirePermission: mocks.requirePermission,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authority-change', () => ({
|
||||
applyAuthorityChange: mocks.applyAuthorityChange,
|
||||
AuthorityChangeError: class extends Error {
|
||||
status = 409 as const;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: mocks.getDatabase,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/pagination', () => ({
|
||||
getPagination: vi.fn(),
|
||||
getRegistrySort: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
describe('ISAC access audit reversion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSessionUser.mockResolvedValue({ id: 'operator-1' });
|
||||
mocks.getPermissions.mockResolvedValue(['*']);
|
||||
mocks.requirePermission.mockImplementation(async (user) => user);
|
||||
mocks.getDatabase.mockResolvedValue(mocks.database);
|
||||
mocks.applyAuthorityChange.mockResolvedValue({
|
||||
before: { roleIds: ['role-admin'], permissionIds: [] },
|
||||
after: { roleIds: [], permissionIds: [] },
|
||||
sessionsRevoked: 1,
|
||||
});
|
||||
mocks.database.prepare.mockImplementation((sql: string) => ({
|
||||
get: vi.fn(async () => {
|
||||
if (sql.includes('SELECT * FROM audit_log'))
|
||||
return {
|
||||
id: 'event-1',
|
||||
action: 'access.updated',
|
||||
entity_id: 'target-1',
|
||||
before_json: JSON.stringify({
|
||||
roleIds: ['role-admin'],
|
||||
permissionIds: [],
|
||||
}),
|
||||
after_json: JSON.stringify({ roleIds: [], permissionIds: [] }),
|
||||
};
|
||||
return null;
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('delegates access reversion to the shared authority operation', async () => {
|
||||
const form = new FormData();
|
||||
form.set('eventId', 'event-1');
|
||||
const result = await actions.revert?.({
|
||||
cookies: {},
|
||||
request: new Request('http://localhost/isac/audit?/revert', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ reverted: true });
|
||||
expect(mocks.applyAuthorityChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetId: 'target-1',
|
||||
roleIds: ['role-admin'],
|
||||
permissionIds: [],
|
||||
expectedCurrent: { roleIds: [], permissionIds: [] },
|
||||
audit: expect.objectContaining({
|
||||
action: 'audit.reverted',
|
||||
entityId: 'event-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
getPermissions,
|
||||
hasRecentAal2,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
@@ -13,6 +14,10 @@ import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
countActiveFullAdministrators,
|
||||
isMfaReady,
|
||||
} from '$lib/server/auth/bootstrap';
|
||||
|
||||
const filters = ['isac', 'data', 'users', 'audit', 'system'];
|
||||
const seededRoleIds = new Set([
|
||||
@@ -158,8 +163,6 @@ export const actions: Actions = {
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '')
|
||||
@@ -178,106 +181,180 @@ export const actions: Actions = {
|
||||
roleError: 'Descriptions must be 240 characters or fewer.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const existing = id
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT id,name,description FROM roles WHERE id=?',
|
||||
)
|
||||
).get(id)
|
||||
: null;
|
||||
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
|
||||
if (id === 'role-admin' && !canManageSystemAdmin) {
|
||||
return fail(403, {
|
||||
roleError: 'Only a full administrator can modify the core Admin role.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, { roleError: 'A role with that name already exists.' });
|
||||
}
|
||||
const validPermissions = permissionIds.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...permissionIds)
|
||||
: [];
|
||||
if (validPermissions.length !== permissionIds.length)
|
||||
return fail(400, { roleError: 'One or more permissions are invalid.' });
|
||||
if (
|
||||
!canManageSystemAdmin &&
|
||||
validPermissions.some((permission) => permission.name === 'system.admin')
|
||||
) {
|
||||
return fail(403, {
|
||||
roleError:
|
||||
'Only a full administrator can grant unrestricted system access.',
|
||||
});
|
||||
}
|
||||
const roleId = id || randomUUID();
|
||||
const beforeLinks = id
|
||||
? await (
|
||||
await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)
|
||||
).all(id)
|
||||
: [];
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE roles SET name=?,description=? WHERE id=?',
|
||||
)
|
||||
).run(name, description || null, id);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO roles(id,name,description) VALUES(?,?,?)',
|
||||
)
|
||||
).run(roleId, name, description || null);
|
||||
await (
|
||||
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
|
||||
).run(roleId);
|
||||
for (const permissionId of permissionIds) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(roleId, permissionId);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: id ? 'role.updated' : 'role.created',
|
||||
entityId: roleId,
|
||||
before: id
|
||||
? {
|
||||
name: existing?.name,
|
||||
description: existing?.description,
|
||||
permissions: beforeLinks.map((row) => row.name),
|
||||
}
|
||||
: null,
|
||||
after: {
|
||||
name,
|
||||
description: description || null,
|
||||
permissions: validPermissions.map((row) => row.name),
|
||||
},
|
||||
});
|
||||
return {
|
||||
roleSaved: true,
|
||||
roleMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
let transactionOpen = true;
|
||||
const rollbackAndFail = async (
|
||||
status: number,
|
||||
data: { roleError: string },
|
||||
) => {
|
||||
await database.exec('ROLLBACK');
|
||||
transactionOpen = false;
|
||||
return fail(status, data);
|
||||
};
|
||||
try {
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const existing = id
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT id,name,description FROM roles WHERE id=?',
|
||||
)
|
||||
).get(id)
|
||||
: null;
|
||||
if (id && !existing)
|
||||
return rollbackAndFail(404, { roleError: 'Role not found.' });
|
||||
if (id === 'role-admin' && !canManageSystemAdmin)
|
||||
return rollbackAndFail(403, {
|
||||
roleError:
|
||||
'Only a full administrator can modify the core Admin role.',
|
||||
});
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
)
|
||||
return rollbackAndFail(409, {
|
||||
roleError: 'A role with that name already exists.',
|
||||
});
|
||||
const validPermissions = permissionIds.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...permissionIds)
|
||||
: [];
|
||||
if (validPermissions.length !== permissionIds.length)
|
||||
return rollbackAndFail(400, {
|
||||
roleError: 'One or more permissions are invalid.',
|
||||
});
|
||||
if (
|
||||
!canManageSystemAdmin &&
|
||||
validPermissions.some(
|
||||
(permission) => permission.name === 'system.admin',
|
||||
)
|
||||
)
|
||||
return rollbackAndFail(403, {
|
||||
roleError:
|
||||
'Only a full administrator can grant unrestricted system access.',
|
||||
});
|
||||
const hadSystemAdmin = id
|
||||
? Boolean(
|
||||
await (
|
||||
await database.prepare(`
|
||||
SELECT 1 FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE role_permissions.role_id=? AND permissions.name='system.admin'
|
||||
`)
|
||||
).get(id),
|
||||
)
|
||||
: false;
|
||||
const hasSystemAdmin = validPermissions.some(
|
||||
(permission) => permission.name === 'system.admin',
|
||||
);
|
||||
const changesAdministratorAuthority =
|
||||
id === 'role-admin' || hadSystemAdmin !== hasSystemAdmin;
|
||||
if (changesAdministratorAuthority && !hasRecentAal2(operator))
|
||||
return rollbackAndFail(403, {
|
||||
roleError:
|
||||
'Recent AAL2 authentication is required for administrator changes.',
|
||||
});
|
||||
const roleId = id || randomUUID();
|
||||
const beforeLinks = id
|
||||
? await (
|
||||
await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)
|
||||
).all(id)
|
||||
: [];
|
||||
const authorityTargets =
|
||||
id && (id === 'role-admin' || hadSystemAdmin || hasSystemAdmin)
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT user_id FROM user_roles WHERE role_id=? ORDER BY user_id',
|
||||
)
|
||||
).all(id)
|
||||
: [];
|
||||
if (hasSystemAdmin && !hadSystemAdmin) {
|
||||
for (const target of authorityTargets) {
|
||||
if (!(await isMfaReady(database, String(target.user_id))))
|
||||
return rollbackAndFail(400, {
|
||||
roleError:
|
||||
'Every account assigned this administrator permission must have an active TOTP factor and an unused recovery code.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE roles SET name=?,description=? WHERE id=?',
|
||||
)
|
||||
).run(name, description || null, id);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO roles(id,name,description) VALUES(?,?,?)',
|
||||
)
|
||||
).run(roleId, name, description || null);
|
||||
await (
|
||||
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
|
||||
).run(roleId);
|
||||
for (const permissionId of permissionIds) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(roleId, permissionId);
|
||||
}
|
||||
if (hadSystemAdmin !== hasSystemAdmin) {
|
||||
for (const target of authorityTargets)
|
||||
await (
|
||||
await database.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(String(target.user_id));
|
||||
}
|
||||
if ((await countActiveFullAdministrators(database)) < 1)
|
||||
return rollbackAndFail(409, {
|
||||
roleError: 'The final active full administrator cannot be removed.',
|
||||
});
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: id ? 'role.updated' : 'role.created',
|
||||
entityId: roleId,
|
||||
before: id
|
||||
? {
|
||||
name: existing?.name,
|
||||
description: existing?.description,
|
||||
permissions: beforeLinks.map((row) => row.name),
|
||||
}
|
||||
: null,
|
||||
after: {
|
||||
name,
|
||||
description: description || null,
|
||||
permissions: validPermissions.map((row) => row.name),
|
||||
},
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
transactionOpen = false;
|
||||
return {
|
||||
roleSaved: true,
|
||||
roleMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (transactionOpen) {
|
||||
transactionOpen = false;
|
||||
await database.exec('ROLLBACK');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const confirmation = String(form.get('confirmation') ?? '')
|
||||
@@ -289,39 +366,66 @@ export const actions: Actions = {
|
||||
roleDeleteError: 'The core Admin role cannot be deleted.',
|
||||
});
|
||||
}
|
||||
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
|
||||
return fail(403, {
|
||||
roleDeleteError:
|
||||
'Only a full administrator can delete a seeded system role.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const role = await (
|
||||
await database.prepare('SELECT id,name,description FROM roles WHERE id=?')
|
||||
).get(id);
|
||||
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
|
||||
if (confirmation !== String(role.name)) {
|
||||
return fail(400, {
|
||||
roleDeleteError: 'Enter the exact role name to confirm deletion.',
|
||||
});
|
||||
}
|
||||
const [permissions, assignedUsers] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
let transactionOpen = true;
|
||||
const rollbackAndFail = async (
|
||||
status: number,
|
||||
data: { roleDeleteError: string },
|
||||
) => {
|
||||
await database.exec('ROLLBACK');
|
||||
transactionOpen = false;
|
||||
return fail(status, data);
|
||||
};
|
||||
try {
|
||||
const canManageSystemAdmin = (await getPermissions(operator)).includes(
|
||||
'*',
|
||||
);
|
||||
if (seededRoleIds.has(id) && !canManageSystemAdmin)
|
||||
return rollbackAndFail(403, {
|
||||
roleDeleteError:
|
||||
'Only a full administrator can delete a seeded system role.',
|
||||
});
|
||||
const role = await (
|
||||
await database.prepare(
|
||||
'SELECT id,name,description FROM roles WHERE id=?',
|
||||
)
|
||||
).get(id);
|
||||
if (!role)
|
||||
return rollbackAndFail(404, { roleDeleteError: 'Role not found.' });
|
||||
if (confirmation !== String(role.name))
|
||||
return rollbackAndFail(400, {
|
||||
roleDeleteError: 'Enter the exact role name to confirm deletion.',
|
||||
});
|
||||
const grantsSystemAdmin = Boolean(
|
||||
await (
|
||||
await database.prepare(`
|
||||
SELECT 1 FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE role_permissions.role_id=? AND permissions.name='system.admin'
|
||||
`)
|
||||
).get(id),
|
||||
);
|
||||
if (grantsSystemAdmin && !hasRecentAal2(operator))
|
||||
return rollbackAndFail(403, {
|
||||
roleDeleteError:
|
||||
'Recent AAL2 authentication is required for administrator changes.',
|
||||
});
|
||||
const [permissions, assignedUsers] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)
|
||||
).all(id),
|
||||
(
|
||||
await database.prepare(`SELECT users.id,
|
||||
).all(id),
|
||||
(
|
||||
await database.prepare(`SELECT users.id,
|
||||
COALESCE(user_profiles.display_name,users.username) AS display_name
|
||||
FROM user_roles JOIN users ON users.id=user_roles.user_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
WHERE user_roles.role_id=?
|
||||
ORDER BY display_name COLLATE NOCASE,users.id`)
|
||||
).all(id),
|
||||
]);
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
).all(id),
|
||||
]);
|
||||
if (seededRoleIds.has(id)) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
|
||||
@@ -342,6 +446,15 @@ export const actions: Actions = {
|
||||
).run(id);
|
||||
if (!deleted.changes)
|
||||
throw new Error('The role changed before it could be deleted.');
|
||||
for (const target of assignedUsers)
|
||||
await (
|
||||
await database.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(String(target.id));
|
||||
if ((await countActiveFullAdministrators(database)) < 1)
|
||||
return rollbackAndFail(409, {
|
||||
roleDeleteError:
|
||||
'The final active full administrator cannot be removed.',
|
||||
});
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
@@ -361,10 +474,15 @@ export const actions: Actions = {
|
||||
systemRole: seededRoleIds.has(id),
|
||||
removedAssignmentCount: assignedUsers.length,
|
||||
},
|
||||
database,
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
transactionOpen = false;
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
if (transactionOpen) {
|
||||
transactionOpen = false;
|
||||
await database.exec('ROLLBACK');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { roleDeleted: true, resultToken: randomUUID() };
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
database: {
|
||||
prepare: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
},
|
||||
getDatabase: vi.fn(),
|
||||
getPermissions: vi.fn(),
|
||||
hasRecentAal2: vi.fn(),
|
||||
requirePermission: vi.fn(),
|
||||
getSessionUser: vi.fn(),
|
||||
recordIsacAudit: vi.fn(),
|
||||
countActiveFullAdministrators: vi.fn(),
|
||||
runs: [] as unknown[][],
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: mocks.getSessionUser,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
getPermissions: mocks.getPermissions,
|
||||
hasRecentAal2: mocks.hasRecentAal2,
|
||||
requirePermission: mocks.requirePermission,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/bootstrap', () => ({
|
||||
countActiveFullAdministrators: mocks.countActiveFullAdministrators,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: mocks.getDatabase,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const postRole = (id = '', permissions: string[] = []) => {
|
||||
const form = new FormData();
|
||||
form.set('id', id);
|
||||
form.set('name', 'Field Operators');
|
||||
form.set('description', 'Ordinary access role');
|
||||
for (const permission of permissions)
|
||||
form.append('permissionIds', permission);
|
||||
return new Request('http://localhost/isac/roles?/save', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const postDelete = () => {
|
||||
const form = new FormData();
|
||||
form.set('id', 'role-user-operator');
|
||||
form.set('confirmation', 'Existing');
|
||||
return new Request('http://localhost/isac/roles?/delete', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ISAC role authority actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.runs.length = 0;
|
||||
mocks.getSessionUser.mockResolvedValue({ id: 'operator-1' });
|
||||
mocks.getPermissions.mockResolvedValue(['users.roles']);
|
||||
mocks.hasRecentAal2.mockReturnValue(true);
|
||||
mocks.requirePermission.mockImplementation(async (user) => user);
|
||||
mocks.getDatabase.mockResolvedValue(mocks.database);
|
||||
mocks.countActiveFullAdministrators.mockResolvedValue(1);
|
||||
mocks.database.prepare.mockImplementation((sql: string) => ({
|
||||
get: vi.fn(async (...args: unknown[]) => {
|
||||
if (sql.includes('SELECT id,name,description FROM roles WHERE id=?'))
|
||||
return args[0]
|
||||
? { id: args[0], name: 'Existing', description: null }
|
||||
: null;
|
||||
if (sql.includes('SELECT id FROM roles WHERE name=')) return null;
|
||||
if (sql.includes('COUNT(DISTINCT users.id)')) return { count: 1 };
|
||||
return null;
|
||||
}),
|
||||
all: vi.fn(async (...args: unknown[]) => {
|
||||
if (sql.includes('SELECT id,name FROM permissions'))
|
||||
return args.map((id) => ({ id, name: 'users.view' }));
|
||||
if (sql.includes('SELECT permissions.name FROM role_permissions'))
|
||||
return [];
|
||||
return [];
|
||||
}),
|
||||
run: vi.fn(async (...args: unknown[]) => {
|
||||
mocks.runs.push(args);
|
||||
return { changes: 1 };
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('rolls back role writes when audit recording fails', async () => {
|
||||
mocks.recordIsacAudit.mockRejectedValueOnce(new Error('audit failed'));
|
||||
|
||||
await expect(
|
||||
actions.save?.({
|
||||
cookies: {},
|
||||
request: postRole('', ['permission-users-view']),
|
||||
} as never),
|
||||
).rejects.toThrow('audit failed');
|
||||
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
expect(mocks.database.exec).not.toHaveBeenCalledWith('COMMIT');
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ database: mocks.database }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a role mutation that would leave no active full administrator', async () => {
|
||||
mocks.countActiveFullAdministrators.mockResolvedValue(0);
|
||||
|
||||
const result = await actions.save?.({
|
||||
cookies: {},
|
||||
request: postRole('', ['permission-users-view']),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 409 });
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['ROLLBACK'],
|
||||
]);
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps role deletion and its audit evidence in one transaction', async () => {
|
||||
mocks.getPermissions.mockResolvedValue(['*']);
|
||||
|
||||
const result = await actions.delete?.({
|
||||
cookies: {},
|
||||
request: postDelete(),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
roleDeleted: true,
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.database.exec.mock.calls).toEqual([
|
||||
['BEGIN IMMEDIATE'],
|
||||
['COMMIT'],
|
||||
]);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'role.deleted',
|
||||
database: mocks.database,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,17 @@ import {
|
||||
getPermissions,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { isFullAdministrator } from '$lib/server/auth/bootstrap';
|
||||
import {
|
||||
AuthorityChangeError,
|
||||
applyAuthorityChange,
|
||||
} from '$lib/server/auth/authority-change';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
|
||||
import {
|
||||
LastAdministratorDeletionError,
|
||||
softDeleteAccount,
|
||||
} from '$lib/server/auth/account-deletion';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -286,105 +294,20 @@ export const actions: Actions = {
|
||||
...new Set(form.getAll('permissionIds').map(String)),
|
||||
];
|
||||
const database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { accessError: 'User not found.' });
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
const changesAdminRole =
|
||||
roleIds.includes('role-admin') !== Boolean(targetAdminRole);
|
||||
if (
|
||||
(target.role === 'admin' || changesAdminRole) &&
|
||||
!operatorPermissions.includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
accessError:
|
||||
'Only an administrator can grant, remove, or modify administrator access.',
|
||||
try {
|
||||
await applyAuthorityChange({
|
||||
database,
|
||||
operator,
|
||||
targetId: params.id,
|
||||
roleIds,
|
||||
permissionIds,
|
||||
});
|
||||
return { accessSaved: true };
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorityChangeError)
|
||||
return fail(error.status, { accessError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const validRoles = roleIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...roleIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
const validPermissions = permissionIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...permissionIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
if (
|
||||
validRoles !== roleIds.length ||
|
||||
validPermissions !== permissionIds.length
|
||||
)
|
||||
return fail(400, {
|
||||
accessError: 'One or more access selections are invalid.',
|
||||
});
|
||||
const before = {
|
||||
roleIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(params.id)
|
||||
).map((row) => row.role_id),
|
||||
permissionIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||||
)
|
||||
).all(params.id)
|
||||
).map((row) => row.permission_id),
|
||||
};
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(params.id);
|
||||
for (const roleId of roleIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
||||
)
|
||||
).run(params.id, roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(params.id, permissionId);
|
||||
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(),
|
||||
operator.id,
|
||||
'user_access',
|
||||
params.id,
|
||||
'access.updated',
|
||||
JSON.stringify(before),
|
||||
JSON.stringify({ roleIds, permissionIds }),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return { accessSaved: true };
|
||||
},
|
||||
moderate: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
@@ -414,16 +337,11 @@ export const actions: Actions = {
|
||||
const now = new Date().toISOString(),
|
||||
database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { moderationError: 'User not found.' });
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
if (
|
||||
(target.role === 'admin' || targetAdminRole) &&
|
||||
(await isFullAdministrator(database, params.id)) &&
|
||||
!(await getPermissions(operator)).includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
@@ -484,16 +402,11 @@ export const actions: Actions = {
|
||||
return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
||||
const database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
if (
|
||||
(target.role === 'admin' || targetAdminRole) &&
|
||||
(await isFullAdministrator(database, params.id)) &&
|
||||
!(await getPermissions(operator)).includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
@@ -501,10 +414,18 @@ export const actions: Actions = {
|
||||
'Only an administrator can delete another administrator.',
|
||||
});
|
||||
}
|
||||
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
|
||||
return fail(409, {
|
||||
adminDeleteError: 'This account has already been deleted.',
|
||||
});
|
||||
try {
|
||||
if (!(await softDeleteAccount(database, params.id, operator.id, reason)))
|
||||
return fail(409, {
|
||||
adminDeleteError: 'This account has already been deleted.',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LastAdministratorDeletionError)
|
||||
return fail(409, {
|
||||
adminDeleteError:
|
||||
'The final active full administrator cannot be deleted.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return { accountDeleted: true };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class TestAuthorityChangeError extends Error {
|
||||
readonly status: 400 | 403 | 404 | 409;
|
||||
|
||||
constructor(status: 400 | 403 | 404 | 409, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
return {
|
||||
getDatabase: vi.fn(),
|
||||
getPermissions: vi.fn(),
|
||||
requirePermission: vi.fn(),
|
||||
getSessionUser: vi.fn(),
|
||||
applyAuthorityChange: vi.fn(),
|
||||
AuthorityChangeError: TestAuthorityChangeError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: mocks.getSessionUser,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
getPermissions: mocks.getPermissions,
|
||||
requirePermission: mocks.requirePermission,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/bootstrap', () => ({
|
||||
isFullAdministrator: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authority-change', () => ({
|
||||
applyAuthorityChange: mocks.applyAuthorityChange,
|
||||
AuthorityChangeError: mocks.AuthorityChangeError,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: mocks.getDatabase,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/account-deletion', () => ({
|
||||
LastAdministratorDeletionError: class extends Error {},
|
||||
softDeleteAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/avatars', () => ({
|
||||
deleteUploadedAvatar: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/methods', () => ({
|
||||
canRemoveAuthenticationMethod: vi.fn(),
|
||||
getAuthenticationMethods: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/pagination', () => ({
|
||||
getPagination: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/notifications', () => ({
|
||||
createNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const postAccess = (roleIds: string[] = [], permissionIds: string[] = []) => {
|
||||
const form = new FormData();
|
||||
for (const roleId of roleIds) form.append('roleIds', roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
form.append('permissionIds', permissionId);
|
||||
return new Request('http://localhost/isac/users/operator-1?/setAccess', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ISAC user access action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSessionUser.mockResolvedValue({ id: 'operator-1' });
|
||||
mocks.requirePermission.mockImplementation(async (user) => user);
|
||||
mocks.getDatabase.mockResolvedValue({});
|
||||
mocks.getPermissions.mockResolvedValue(['users.roles']);
|
||||
mocks.applyAuthorityChange.mockResolvedValue({
|
||||
before: { roleIds: [], permissionIds: [] },
|
||||
after: { roleIds: ['role-user-operator'], permissionIds: [] },
|
||||
sessionsRevoked: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('posts self-escalation to the shared authority operation and returns its denial', async () => {
|
||||
mocks.applyAuthorityChange.mockRejectedValueOnce(
|
||||
new mocks.AuthorityChangeError(
|
||||
403,
|
||||
'Only a full administrator can grant administrator access.',
|
||||
),
|
||||
);
|
||||
|
||||
const result = await actions.setAccess?.({
|
||||
cookies: {},
|
||||
params: { id: 'operator-1' },
|
||||
request: postAccess([], ['permission-system-admin']),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 403 });
|
||||
expect(mocks.applyAuthorityChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetId: 'operator-1',
|
||||
roleIds: [],
|
||||
permissionIds: ['permission-system-admin'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('delegates ordinary role changes without bypassing the shared operation', async () => {
|
||||
const result = await actions.setAccess?.({
|
||||
cookies: {},
|
||||
params: { id: 'operator-1' },
|
||||
request: postAccess(['role-user-operator']),
|
||||
} as never);
|
||||
|
||||
expect(result).toEqual({ accessSaved: true });
|
||||
expect(mocks.applyAuthorityChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetId: 'operator-1',
|
||||
roleIds: ['role-user-operator'],
|
||||
permissionIds: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
createSession,
|
||||
destroySession,
|
||||
recordLogin,
|
||||
} from '$lib/server/auth/session';
|
||||
import { destroySession, recordLogin } from '$lib/server/auth/session';
|
||||
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
|
||||
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
||||
import {
|
||||
emailDeliveryConfigured,
|
||||
@@ -215,7 +211,6 @@ export const actions: Actions = {
|
||||
now,
|
||||
);
|
||||
await insertUserProfile(database, id, displayName, now);
|
||||
await ensureBootstrapAdmin(database, id);
|
||||
await triggerAutomatedSystemMessages({
|
||||
eventKey: 'account.created',
|
||||
userId: id,
|
||||
@@ -223,13 +218,15 @@ export const actions: Actions = {
|
||||
}).catch((error) =>
|
||||
console.error('Unable to deliver new-agent welcome message', error),
|
||||
);
|
||||
const sessionId = await createSession(cookies, id);
|
||||
await recordLogin(id, 'password', true, {
|
||||
const authentication = await completePrimaryAuthentication({
|
||||
database,
|
||||
cookies,
|
||||
userId: id,
|
||||
provider: 'password',
|
||||
request,
|
||||
getClientAddress,
|
||||
sessionId,
|
||||
});
|
||||
redirect(303, '/account');
|
||||
redirect(303, authentication.redirectTo);
|
||||
},
|
||||
login: async ({ request, cookies, getClientAddress }) => {
|
||||
const form = await request.formData();
|
||||
@@ -273,14 +270,15 @@ export const actions: Actions = {
|
||||
username,
|
||||
});
|
||||
}
|
||||
await ensureBootstrapAdmin(database, String(user.id));
|
||||
const sessionId = await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'password', true, {
|
||||
const authentication = await completePrimaryAuthentication({
|
||||
database,
|
||||
cookies,
|
||||
userId: String(user.id),
|
||||
provider: 'password',
|
||||
request,
|
||||
getClientAddress,
|
||||
sessionId,
|
||||
});
|
||||
redirect(303, '/account');
|
||||
redirect(303, authentication.redirectTo);
|
||||
},
|
||||
logout: async ({ cookies }) => {
|
||||
await destroySession(cookies);
|
||||
|
||||
Reference in New Issue
Block a user