Compare commits
1
Commits
main
...
feature/totp-mfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67c3d4cdb |
@@ -41,6 +41,10 @@ ORIGIN=https://dev.shd.build
|
|||||||
# accessing development directly at http://localhost:5173.
|
# accessing development directly at http://localhost:5173.
|
||||||
PASSKEY_RP_ID=shd.build
|
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.
|
# SMTP delivery for email verification and magic-link authentication.
|
||||||
# Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS.
|
# Port 465 normally uses SMTP_SECURE=true. Port 587 normally uses false and STARTTLS.
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
|
|||||||
@@ -32,8 +32,23 @@ npm run db:seed-users
|
|||||||
|
|
||||||
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
|
The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
|
||||||
generates and displays strong temporary passwords once, then stores only salted
|
generates and displays strong temporary passwords once, then stores only salted
|
||||||
Argon2id hashes. The separate bootstrap policy promotes the first authenticated
|
Argon2id hashes. `DEFAULT_ADMIN_PASSWORD` is the password variable name for
|
||||||
account when no administrator exists.
|
`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
|
## 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_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 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 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 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 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);
|
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_ended_at TEXT,
|
||||||
session_end_reason TEXT,
|
session_end_reason TEXT,
|
||||||
sensitive_purged_at 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
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS user_emails (
|
CREATE TABLE IF NOT EXISTS user_emails (
|
||||||
@@ -643,6 +645,60 @@ CREATE TABLE IF NOT EXISTS deleted_accounts (
|
|||||||
reason TEXT
|
reason TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC);
|
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-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-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');
|
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 }),
|
writeFile(join(temporaryRoot, 'chown'), ownershipStub, { mode: 0o755 }),
|
||||||
writeExport(appExport, [
|
writeExport(appExport, [
|
||||||
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
{ 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_ID', value: 'exported-discord-client' },
|
||||||
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||||
]),
|
]),
|
||||||
@@ -218,6 +222,8 @@ describe('container runtime startup', () => {
|
|||||||
);
|
);
|
||||||
expect(childEnvironment).toMatchObject({
|
expect(childEnvironment).toMatchObject({
|
||||||
APP_ORIGIN: 'https://stage.shd.build',
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
|
MFA_TOTP_ENCRYPTION_KEYS:
|
||||||
|
'primary:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
DISCORD_CLIENT_ID: 'exported-discord-client',
|
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||||
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||||
ORIGIN: 'https://stage.shd.build',
|
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
|
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 current Git tree does not revoke values stored in Git history.
|
||||||
|
|
||||||
The staging container disables default-user seeding. On a brand-new staging
|
## Authentication bootstrap and MFA secrets
|
||||||
database, the existing bootstrap policy grants Admin to the first real account
|
|
||||||
that signs in instead of writing generated credentials into deployment logs.
|
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
|
## 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,
|
backup, and refuses to replace a production database containing users, builds,
|
||||||
audit events, or proposals. Remove the remote host copy after verification.
|
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
|
### Promote one News article
|
||||||
|
|
||||||
Export a locally authored News article by ID or slug while the development
|
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
|
data
|
||||||
- Auth.js-compatible server authentication with Discord OAuth and credentials
|
- Auth.js-compatible server authentication with Discord OAuth and credentials
|
||||||
providers
|
providers
|
||||||
- Argon2id password hashing, server-side sessions, CSRF protection, and role
|
- Argon2id password hashing, TOTP MFA with encrypted secrets, server-side
|
||||||
checks
|
sessions, CSRF protection, and role checks
|
||||||
- Browser `localStorage` only for anonymous build drafts
|
- Browser `localStorage` only for anonymous build drafts
|
||||||
|
|
||||||
## Product boundaries
|
## 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
|
password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
|
||||||
minimum profile. Successful login rehashes credentials with outdated parameters.
|
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
|
## Database runtime
|
||||||
|
|
||||||
The application uses Turso Database in-process through its native Node package.
|
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
|
# Administrator bootstrap
|
||||||
|
|
||||||
The authentication service promotes the first authenticated user to `admin` if
|
The first full administrator is created only through the offline flow documented
|
||||||
the database does not contain an administrator. Promotion uses one conditional
|
in [Authentication and MFA](#authentication-and-mfa). Sign-in never grants
|
||||||
database update so concurrent first sign-ins cannot both become administrators.
|
administrator authority or changes an account's roles.
|
||||||
Once an administrator exists, later sign-ins never alter roles.
|
|
||||||
|
|
||||||
# Database backup and restore
|
# Database backup and restore
|
||||||
|
|
||||||
|
|||||||
Generated
+25
@@ -21,6 +21,7 @@
|
|||||||
"argon2": "^0.45.1",
|
"argon2": "^0.45.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"openai": "^6.49.0",
|
"openai": "^6.49.0",
|
||||||
|
"otpauth": "9.5.1",
|
||||||
"sharp": "^0.35.3"
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -706,6 +707,18 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@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": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.139.0",
|
"version": "0.139.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||||
@@ -3486,6 +3499,18 @@
|
|||||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"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: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": "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",
|
"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": "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: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",
|
"db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"argon2": "^0.45.1",
|
"argon2": "^0.45.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"openai": "^6.49.0",
|
"openai": "^6.49.0",
|
||||||
|
"otpauth": "9.5.1",
|
||||||
"sharp": "^0.35.3"
|
"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);
|
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
|
await database.exec(`CREATE TRIGGER IF NOT EXISTS specialization_ability_skill_chip_links_validate_insert
|
||||||
BEFORE INSERT ON specialization_ability_skill_chip_links
|
BEFORE INSERT ON specialization_ability_skill_chip_links
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -2142,10 +2274,12 @@ if (
|
|||||||
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
|
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
|
||||||
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
|
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
|
created_at TEXT NOT NULL
|
||||||
)`);
|
)`);
|
||||||
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
|
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,aal,aal2_verified_at,created_at)
|
||||||
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
|
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('DROP TABLE login_events_legacy');
|
||||||
await database.exec(
|
await database.exec(
|
||||||
'CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)',
|
'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_ended_at', 'TEXT'],
|
||||||
['session_end_reason', 'TEXT'],
|
['session_end_reason', 'TEXT'],
|
||||||
['sensitive_purged_at', '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)) {
|
if (!loginEventColumns.some((column) => column.name === name)) {
|
||||||
await database.exec(
|
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 (
|
const proposalColumns = await (
|
||||||
await database.prepare("PRAGMA table_info('data_change_proposals')")
|
await database.prepare("PRAGMA table_info('data_change_proposals')")
|
||||||
).all();
|
).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',
|
'APP_ORIGIN',
|
||||||
'ORIGIN',
|
'ORIGIN',
|
||||||
'PASSKEY_RP_ID',
|
'PASSKEY_RP_ID',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
'SCHEDULER_TOKEN',
|
'SCHEDULER_TOKEN',
|
||||||
'TDR_DATABASE_PATH',
|
'TDR_DATABASE_PATH',
|
||||||
'TDR_AVATAR_STORAGE_PATH',
|
'TDR_AVATAR_STORAGE_PATH',
|
||||||
@@ -48,6 +49,7 @@ export function validateRuntimeEnvironment(mode, environment) {
|
|||||||
|
|
||||||
requireValues(APP_REQUIRED);
|
requireValues(APP_REQUIRED);
|
||||||
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
|
||||||
|
validateMfaTotpEncryptionKeys(get('MFA_TOTP_ENCRYPTION_KEYS'), errors);
|
||||||
|
|
||||||
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
|
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
|
||||||
const adapterOrigin = validateOrigin('ORIGIN', get('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',
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
ORIGIN: 'https://stage.shd.build',
|
ORIGIN: 'https://stage.shd.build',
|
||||||
PASSKEY_RP_ID: 'shd.build',
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
|
MFA_TOTP_ENCRYPTION_KEYS:
|
||||||
|
'primary-2026:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
|
||||||
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
||||||
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
||||||
@@ -21,6 +23,7 @@ describe('validateRuntimeEnvironment', () => {
|
|||||||
'APP_ORIGIN is required.',
|
'APP_ORIGIN is required.',
|
||||||
'ORIGIN is required.',
|
'ORIGIN is required.',
|
||||||
'PASSKEY_RP_ID is required.',
|
'PASSKEY_RP_ID is required.',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS is required.',
|
||||||
'SCHEDULER_TOKEN is required.',
|
'SCHEDULER_TOKEN is required.',
|
||||||
'TDR_DATABASE_PATH is required.',
|
'TDR_DATABASE_PATH is required.',
|
||||||
'TDR_AVATAR_STORAGE_PATH is required.',
|
'TDR_AVATAR_STORAGE_PATH is required.',
|
||||||
@@ -158,4 +161,24 @@ describe('validateRuntimeEnvironment', () => {
|
|||||||
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
|
||||||
).toThrow('Unsupported runtime mode: worker');
|
).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([
|
const APP_SECRET_NAMES = new Set([
|
||||||
'APP_ORIGIN',
|
'APP_ORIGIN',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
'DISCORD_CLIENT_ID',
|
'DISCORD_CLIENT_ID',
|
||||||
'DISCORD_CLIENT_SECRET',
|
'DISCORD_CLIENT_SECRET',
|
||||||
'DISCORD_REDIRECT_URI',
|
'DISCORD_REDIRECT_URI',
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { json, type Handle, type HandleServerError } from '@sveltejs/kit';
|
|||||||
import { stringify } from 'devalue';
|
import { stringify } from 'devalue';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
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 {
|
import {
|
||||||
getSiteAccessBlock,
|
getSiteAccessBlock,
|
||||||
getSiteAccessSettings,
|
getSiteAccessSettings,
|
||||||
@@ -49,6 +58,62 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
{ status: 404 },
|
{ 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') {
|
if (event.request.method === 'POST') {
|
||||||
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
const match = event.url.pathname.match(/^\/isac\/([^/]+)$/);
|
||||||
const module = match?.[1] as DataModule | undefined;
|
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>
|
<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}
|
||||||
{/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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="account-panel" role="tabpanel">
|
<div class="account-panel" role="tabpanel">
|
||||||
@@ -183,6 +187,6 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<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}
|
.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}}
|
@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>
|
</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 type { Database } from '@tursodatabase/database';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
import { unlink } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { avatarStorageDirectory } from '$lib/server/avatars';
|
||||||
import { displayNameKey } from '$lib/agents';
|
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(
|
export async function softDeleteAccount(
|
||||||
database: Database,
|
database: Database,
|
||||||
@@ -10,113 +27,189 @@ export async function softDeleteAccount(
|
|||||||
reason: string | null,
|
reason: string | null,
|
||||||
purgeSecurityData = false,
|
purgeSecurityData = false,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const user = await (
|
const deletion = database.transactionAsync(async (transaction) => {
|
||||||
await database.prepare(`
|
const user = await (
|
||||||
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
|
await transaction.prepare(`
|
||||||
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
SELECT users.username,users.login_username,user_profiles.display_name,
|
||||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
|
||||||
WHERE users.id=?
|
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||||
`)
|
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||||
).get(userId);
|
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||||
if (!user || user.deleted_at) return false;
|
WHERE users.id=?
|
||||||
const now = new Date().toISOString();
|
`)
|
||||||
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
|
).get(userId);
|
||||||
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
|
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 (
|
||||||
await database.prepare('DELETE FROM login_events WHERE user_id=?')
|
await transaction.prepare(
|
||||||
).run(userId);
|
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)',
|
||||||
}
|
)
|
||||||
await (
|
).run(
|
||||||
await database.prepare(
|
userId,
|
||||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
purgeSecurityData
|
||||||
)
|
? deletedUsername
|
||||||
).run(
|
: String(user.login_username ?? user.username),
|
||||||
randomUUID(),
|
now,
|
||||||
deletedBy,
|
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',
|
|
||||||
reason,
|
reason,
|
||||||
securityDataPurged: purgeSecurityData,
|
);
|
||||||
}),
|
await (
|
||||||
now,
|
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;
|
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 { getDatabase } from '$lib/server/database';
|
||||||
import type { AuthUser } from './session';
|
import type { AuthUser } from './session';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
hasActiveTotpFactor,
|
||||||
|
isFullAdministrator,
|
||||||
|
isMfaReady,
|
||||||
|
} from './bootstrap';
|
||||||
|
|
||||||
|
export const RECENT_AAL2_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
const REGISTERED_AGENT_PERMISSIONS = [
|
const REGISTERED_AGENT_PERMISSIONS = [
|
||||||
'isac.access',
|
'isac.access',
|
||||||
@@ -10,8 +17,14 @@ const REGISTERED_AGENT_PERMISSIONS = [
|
|||||||
|
|
||||||
export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
if (user.role === 'admin') return ['*'];
|
|
||||||
const database = await getDatabase();
|
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 (
|
const rows = await (
|
||||||
await database.prepare(`
|
await database.prepare(`
|
||||||
SELECT permissions.name FROM permissions
|
SELECT permissions.name FROM permissions
|
||||||
@@ -27,10 +40,12 @@ export async function getPermissions(user: AuthUser | null): Promise<string[]> {
|
|||||||
const permissions = [
|
const permissions = [
|
||||||
...new Set([
|
...new Set([
|
||||||
...REGISTERED_AGENT_PERMISSIONS,
|
...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(
|
export async function hasPermission(
|
||||||
@@ -67,7 +82,60 @@ export async function requireDataView(
|
|||||||
return user;
|
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) 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';
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically promotes the authenticated user when no administrator exists.
|
* Returns whether the account has the canonical unrestricted administrator
|
||||||
* The conditional UPDATE ensures concurrent first sign-ins cannot both win.
|
* 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,
|
database: Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
const promote = await database.prepare(`
|
const row = await (
|
||||||
UPDATE users
|
await database.prepare(`
|
||||||
SET role = 'admin'
|
SELECT 1
|
||||||
WHERE id = ?
|
FROM users
|
||||||
AND NOT EXISTS (
|
WHERE users.id=?
|
||||||
SELECT 1 FROM users WHERE role = 'admin'
|
AND EXISTS (
|
||||||
)
|
SELECT 1
|
||||||
`);
|
FROM user_roles
|
||||||
await promote.run(userId);
|
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 { dev } from '$app/environment';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import type { Database } from '@tursodatabase/database';
|
||||||
import type { Cookies } from '@sveltejs/kit';
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||||
import { avatarUrl } from '$lib/avatar';
|
import { avatarUrl } from '$lib/avatar';
|
||||||
@@ -16,6 +17,27 @@ export type AuthUser = {
|
|||||||
role: string;
|
role: string;
|
||||||
discordLinked: boolean;
|
discordLinked: boolean;
|
||||||
avatarUrl: string;
|
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 = {
|
type LoginRequestContext = {
|
||||||
@@ -25,23 +47,43 @@ type LoginRequestContext = {
|
|||||||
credentialId?: string | null;
|
credentialId?: string | null;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
riskFlags?: string[] | null;
|
riskFlags?: string[] | null;
|
||||||
|
aal?: SessionAuthLevel;
|
||||||
|
aal2VerifiedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hashToken(token: string): string {
|
function hashToken(token: string): string {
|
||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(
|
function sessionCookieOptions() {
|
||||||
cookies: Cookies,
|
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,
|
userId: string,
|
||||||
): Promise<string> {
|
options: CreateSessionOptions = {},
|
||||||
const database = await getDatabase();
|
): Promise<PendingSession> {
|
||||||
const token = randomBytes(32).toString('base64url');
|
const token = randomBytes(32).toString('base64url');
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
|
||||||
const insert = await database.prepare(
|
const aal = options.authLevel ?? options.aal ?? 1;
|
||||||
'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)',
|
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(
|
await insert.run(
|
||||||
@@ -50,16 +92,67 @@ export async function createSession(
|
|||||||
hashToken(token),
|
hashToken(token),
|
||||||
expiresAt.toISOString(),
|
expiresAt.toISOString(),
|
||||||
now.toISOString(),
|
now.toISOString(),
|
||||||
|
aal,
|
||||||
|
aalVerifiedAt,
|
||||||
|
aal2VerifiedAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
cookies.set(COOKIE_NAME, token, {
|
return { sessionId, token };
|
||||||
path: '/',
|
}
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
export async function createSession(
|
||||||
secure: !dev,
|
cookies: Cookies,
|
||||||
maxAge: SESSION_SECONDS,
|
userId: string,
|
||||||
});
|
options: CreateSessionOptions = {},
|
||||||
return sessionId;
|
): 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) {
|
function describeClient(userAgent: string) {
|
||||||
@@ -110,6 +203,23 @@ export async function recordLogin(
|
|||||||
context: LoginRequestContext = {},
|
context: LoginRequestContext = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const database = await getDatabase();
|
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 =
|
const userAgent =
|
||||||
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
context.request?.headers.get('user-agent')?.slice(0, 1000) ?? null;
|
||||||
const headers = context.request?.headers;
|
const headers = context.request?.headers;
|
||||||
@@ -134,10 +244,10 @@ export async function recordLogin(
|
|||||||
/* unavailable in some development adapters */
|
/* unavailable in some development adapters */
|
||||||
}
|
}
|
||||||
await (
|
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,
|
client_name,operating_system,device_type,location_country,location_region,location_city,
|
||||||
failure_reason,risk_flags,credential_id,session_id,created_at)
|
failure_reason,risk_flags,credential_id,session_id,aal,aal2_verified_at,created_at)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
).run(
|
).run(
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
@@ -155,9 +265,10 @@ export async function recordLogin(
|
|||||||
riskFlags.length ? riskFlags.join(',') : null,
|
riskFlags.length ? riskFlags.join(',') : null,
|
||||||
context.credentialId ?? null,
|
context.credentialId ?? null,
|
||||||
context.sessionId ?? null,
|
context.sessionId ?? null,
|
||||||
|
context.aal ?? (succeeded ? 1 : null),
|
||||||
|
context.aal2VerifiedAt ?? null,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
);
|
);
|
||||||
await purgeExpiredLoginMetadata();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSessionUser(
|
export async function getSessionUser(
|
||||||
@@ -173,7 +284,9 @@ export async function getSessionUser(
|
|||||||
users.role, users.discord_id, user_avatars.source AS avatar_source,
|
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_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,
|
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
|
FROM sessions
|
||||||
JOIN users ON users.id = sessions.user_id
|
JOIN users ON users.id = sessions.user_id
|
||||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||||
@@ -224,6 +337,12 @@ export async function getSessionUser(
|
|||||||
String(user.id),
|
String(user.id),
|
||||||
user.avatar_updated_at,
|
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 { 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 { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
@@ -472,18 +475,26 @@ export const actions: Actions = {
|
|||||||
) {
|
) {
|
||||||
return fail(400, { deleteError: 'Your password is incorrect.' });
|
return fail(400, { deleteError: 'Your password is incorrect.' });
|
||||||
}
|
}
|
||||||
if (
|
try {
|
||||||
!(await softDeleteAccount(
|
if (
|
||||||
database,
|
!(await softDeleteAccount(
|
||||||
user.id,
|
database,
|
||||||
user.id,
|
user.id,
|
||||||
'Deleted by account owner',
|
user.id,
|
||||||
purgeSecurityData,
|
'Deleted by account owner',
|
||||||
))
|
purgeSecurityData,
|
||||||
) {
|
))
|
||||||
return fail(409, {
|
)
|
||||||
deleteError: 'This account has already been deleted.',
|
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: '/' });
|
cookies.delete('shd_session', { path: '/' });
|
||||||
redirect(303, '/?account_deleted=1');
|
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 { dev } from '$app/environment';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import {
|
import { getSessionUser, recordLogin } from '$lib/server/auth/session';
|
||||||
createSession,
|
import { completePrimaryAuthentication } from '$lib/server/auth/primary-authentication';
|
||||||
getSessionUser,
|
|
||||||
recordLogin,
|
|
||||||
} from '$lib/server/auth/session';
|
|
||||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
|
||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
@@ -190,7 +186,6 @@ export const GET: RequestHandler = async ({
|
|||||||
created = true;
|
created = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureBootstrapAdmin(database, String(user.id));
|
|
||||||
const status = await (
|
const status = await (
|
||||||
await database.prepare(
|
await database.prepare(
|
||||||
'SELECT blocked_at,banned_until FROM user_status WHERE user_id=?',
|
'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');
|
redirect(303, '/sign-in?oauth_error=account_blocked');
|
||||||
}
|
}
|
||||||
await saveDiscordProfile(String(user.id), discordUser);
|
await saveDiscordProfile(String(user.id), discordUser);
|
||||||
const sessionId = await createSession(cookies, String(user.id));
|
const authentication = await completePrimaryAuthentication({
|
||||||
await recordLogin(String(user.id), 'discord', true, {
|
database,
|
||||||
|
cookies,
|
||||||
|
userId: String(user.id),
|
||||||
|
provider: 'discord',
|
||||||
request,
|
request,
|
||||||
getClientAddress,
|
getClientAddress,
|
||||||
credentialId: discordUser.id,
|
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 { 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 { getDatabase } from '$lib/server/database';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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`)
|
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);
|
).run(link.userId, link.email, now, now);
|
||||||
}
|
}
|
||||||
await ensureBootstrapAdmin(database, link.userId);
|
const authentication = await completePrimaryAuthentication({
|
||||||
const sessionId = await createSession(cookies, link.userId);
|
database,
|
||||||
await recordLogin(link.userId, 'magic_link', true, {
|
cookies,
|
||||||
|
userId: link.userId,
|
||||||
|
provider: 'magic_link',
|
||||||
request,
|
request,
|
||||||
getClientAddress,
|
getClientAddress,
|
||||||
sessionId,
|
returnPath:
|
||||||
|
link.purpose === 'verify_email'
|
||||||
|
? '/account?email_verified=1'
|
||||||
|
: '/account',
|
||||||
});
|
});
|
||||||
redirect(
|
redirect(303, authentication.redirectTo);
|
||||||
303,
|
|
||||||
link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account',
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
|
||||||
import { consumeChallenge } from '$lib/server/auth/passkeys';
|
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 { getDatabase } from '$lib/server/database';
|
||||||
import {
|
import {
|
||||||
verifyAuthenticationResponse,
|
verifyAuthenticationResponse,
|
||||||
@@ -91,13 +91,14 @@ export const POST: RequestHandler = async ({
|
|||||||
'UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?',
|
'UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?',
|
||||||
)
|
)
|
||||||
).run(verification.authenticationInfo.newCounter, now, passkey.id);
|
).run(verification.authenticationInfo.newCounter, now, passkey.id);
|
||||||
await ensureBootstrapAdmin(database, String(passkey.user_id));
|
const authentication = await completePrimaryAuthentication({
|
||||||
const sessionId = await createSession(cookies, String(passkey.user_id));
|
database,
|
||||||
await recordLogin(String(passkey.user_id), 'passkey', true, {
|
cookies,
|
||||||
|
userId: String(passkey.user_id),
|
||||||
|
provider: 'passkey',
|
||||||
request,
|
request,
|
||||||
getClientAddress,
|
getClientAddress,
|
||||||
credentialId: String(passkey.id),
|
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 { getDatabase } from '$lib/server/database';
|
||||||
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
||||||
import { recordIsacAudit } from '$lib/server/audit';
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
|
import {
|
||||||
|
AuthorityChangeError,
|
||||||
|
applyAuthorityChange,
|
||||||
|
} from '$lib/server/auth/authority-change';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -228,6 +232,7 @@ export const actions: Actions = {
|
|||||||
targetId = String(event.entity_id);
|
targetId = String(event.entity_id);
|
||||||
let current: Record<string, unknown> | null = null;
|
let current: Record<string, unknown> | null = null;
|
||||||
let restored: Record<string, unknown> | null = null;
|
let restored: Record<string, unknown> | null = null;
|
||||||
|
let auditRecordedInTransaction = false;
|
||||||
try {
|
try {
|
||||||
if (action.startsWith('attribute.')) {
|
if (action.startsWith('attribute.')) {
|
||||||
current =
|
current =
|
||||||
@@ -349,88 +354,47 @@ export const actions: Actions = {
|
|||||||
permissions: permissionNames,
|
permissions: permissionNames,
|
||||||
};
|
};
|
||||||
} else if (action === 'access.updated') {
|
} 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)
|
const roleIds = Array.isArray(before.roleIds)
|
||||||
? before.roleIds.map(String)
|
? before.roleIds.map(String)
|
||||||
: [];
|
: [];
|
||||||
const permissionIds = Array.isArray(before.permissionIds)
|
const permissionIds = Array.isArray(before.permissionIds)
|
||||||
? before.permissionIds.map(String)
|
? 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 (
|
if (
|
||||||
roleCount !== roleIds.length ||
|
!Array.isArray(after.roleIds) ||
|
||||||
permissionCount !== permissionIds.length
|
!Array.isArray(after.permissionIds)
|
||||||
)
|
)
|
||||||
return fail(409, {
|
return fail(409, {
|
||||||
revertError: 'A previous role or permission no longer exists.',
|
revertError: 'The stored access snapshot is incomplete.',
|
||||||
});
|
});
|
||||||
await (
|
const expectedCurrent = {
|
||||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
roleIds: after.roleIds.map(String),
|
||||||
).run(targetId);
|
permissionIds: after.permissionIds.map(String),
|
||||||
await (
|
};
|
||||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
const result = await applyAuthorityChange({
|
||||||
).run(targetId);
|
database,
|
||||||
for (const roleId of roleIds)
|
operator,
|
||||||
await (
|
targetId,
|
||||||
await database.prepare(
|
roleIds,
|
||||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
permissionIds,
|
||||||
)
|
expectedCurrent,
|
||||||
).run(targetId, roleId);
|
audit: {
|
||||||
for (const permissionId of permissionIds)
|
actorId: operator.id,
|
||||||
await (
|
category: 'audit_control',
|
||||||
await database.prepare(
|
action: 'audit.reverted',
|
||||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
entityId: eventId,
|
||||||
)
|
before: current,
|
||||||
).run(targetId, permissionId);
|
after: {
|
||||||
restored = { roleIds, permissionIds };
|
originalEventId: eventId,
|
||||||
|
originalAction: action,
|
||||||
|
targetId,
|
||||||
|
restored: { roleIds, permissionIds },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
current = result.before;
|
||||||
|
restored = result.after;
|
||||||
|
auditRecordedInTransaction = true;
|
||||||
} else {
|
} else {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
revertError:
|
revertError:
|
||||||
@@ -438,25 +402,28 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof AuthorityChangeError)
|
||||||
|
return fail(error.status, { revertError: error.message });
|
||||||
if (error && typeof error === 'object' && 'status' in error) throw error;
|
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||||
return fail(409, {
|
return fail(409, {
|
||||||
revertError:
|
revertError:
|
||||||
'The previous state could not be restored because related data has changed.',
|
'The previous state could not be restored because related data has changed.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await recordIsacAudit({
|
if (!auditRecordedInTransaction)
|
||||||
actorId: operator.id,
|
await recordIsacAudit({
|
||||||
category: 'audit_control',
|
actorId: operator.id,
|
||||||
action: 'audit.reverted',
|
category: 'audit_control',
|
||||||
entityId: eventId,
|
action: 'audit.reverted',
|
||||||
before: current,
|
entityId: eventId,
|
||||||
after: {
|
before: current,
|
||||||
originalEventId: eventId,
|
after: {
|
||||||
originalAction: action,
|
originalEventId: eventId,
|
||||||
targetId,
|
originalAction: action,
|
||||||
restored,
|
targetId,
|
||||||
},
|
restored,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
return { reverted: true, resultToken: randomUUID() };
|
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 {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
|
hasRecentAal2,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
} from '$lib/server/auth/authorization';
|
} from '$lib/server/auth/authorization';
|
||||||
import { getSessionUser } from '$lib/server/auth/session';
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
@@ -13,6 +14,10 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { recordIsacAudit } from '$lib/server/audit';
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
|
import {
|
||||||
|
countActiveFullAdministrators,
|
||||||
|
isMfaReady,
|
||||||
|
} from '$lib/server/auth/bootstrap';
|
||||||
|
|
||||||
const filters = ['isac', 'data', 'users', 'audit', 'system'];
|
const filters = ['isac', 'data', 'users', 'audit', 'system'];
|
||||||
const seededRoleIds = new Set([
|
const seededRoleIds = new Set([
|
||||||
@@ -158,8 +163,6 @@ export const actions: Actions = {
|
|||||||
await getSessionUser(cookies),
|
await getSessionUser(cookies),
|
||||||
'users.roles',
|
'users.roles',
|
||||||
);
|
);
|
||||||
const operatorPermissions = await getPermissions(operator);
|
|
||||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const id = String(form.get('id') ?? '');
|
const id = String(form.get('id') ?? '');
|
||||||
const name = String(form.get('name') ?? '')
|
const name = String(form.get('name') ?? '')
|
||||||
@@ -178,106 +181,180 @@ export const actions: Actions = {
|
|||||||
roleError: 'Descriptions must be 240 characters or fewer.',
|
roleError: 'Descriptions must be 240 characters or fewer.',
|
||||||
});
|
});
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const existing = id
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
? await (
|
let transactionOpen = true;
|
||||||
await database.prepare(
|
const rollbackAndFail = async (
|
||||||
'SELECT id,name,description FROM roles WHERE id=?',
|
status: number,
|
||||||
)
|
data: { roleError: string },
|
||||||
).get(id)
|
) => {
|
||||||
: null;
|
await database.exec('ROLLBACK');
|
||||||
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
|
transactionOpen = false;
|
||||||
if (id === 'role-admin' && !canManageSystemAdmin) {
|
return fail(status, data);
|
||||||
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(),
|
|
||||||
};
|
};
|
||||||
|
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 }) => {
|
delete: async ({ cookies, request }) => {
|
||||||
const operator = await requirePermission(
|
const operator = await requirePermission(
|
||||||
await getSessionUser(cookies),
|
await getSessionUser(cookies),
|
||||||
'users.roles',
|
'users.roles',
|
||||||
);
|
);
|
||||||
const operatorPermissions = await getPermissions(operator);
|
|
||||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const id = String(form.get('id') ?? '').trim();
|
const id = String(form.get('id') ?? '').trim();
|
||||||
const confirmation = String(form.get('confirmation') ?? '')
|
const confirmation = String(form.get('confirmation') ?? '')
|
||||||
@@ -289,39 +366,66 @@ export const actions: Actions = {
|
|||||||
roleDeleteError: 'The core Admin role cannot be deleted.',
|
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 database = await getDatabase();
|
||||||
const role = await (
|
await database.exec('BEGIN IMMEDIATE');
|
||||||
await database.prepare('SELECT id,name,description FROM roles WHERE id=?')
|
let transactionOpen = true;
|
||||||
).get(id);
|
const rollbackAndFail = async (
|
||||||
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
|
status: number,
|
||||||
if (confirmation !== String(role.name)) {
|
data: { roleDeleteError: string },
|
||||||
return fail(400, {
|
) => {
|
||||||
roleDeleteError: 'Enter the exact role name to confirm deletion.',
|
await database.exec('ROLLBACK');
|
||||||
});
|
transactionOpen = false;
|
||||||
}
|
return fail(status, data);
|
||||||
const [permissions, assignedUsers] = await Promise.all([
|
};
|
||||||
(
|
try {
|
||||||
await database.prepare(`SELECT permissions.name FROM role_permissions
|
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
|
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||||
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)
|
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)
|
||||||
).all(id),
|
).all(id),
|
||||||
(
|
(
|
||||||
await database.prepare(`SELECT users.id,
|
await database.prepare(`SELECT users.id,
|
||||||
COALESCE(user_profiles.display_name,users.username) AS display_name
|
COALESCE(user_profiles.display_name,users.username) AS display_name
|
||||||
FROM user_roles JOIN users ON users.id=user_roles.user_id
|
FROM user_roles JOIN users ON users.id=user_roles.user_id
|
||||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||||
WHERE user_roles.role_id=?
|
WHERE user_roles.role_id=?
|
||||||
ORDER BY display_name COLLATE NOCASE,users.id`)
|
ORDER BY display_name COLLATE NOCASE,users.id`)
|
||||||
).all(id),
|
).all(id),
|
||||||
]);
|
]);
|
||||||
await database.exec('BEGIN IMMEDIATE');
|
|
||||||
try {
|
|
||||||
if (seededRoleIds.has(id)) {
|
if (seededRoleIds.has(id)) {
|
||||||
await (
|
await (
|
||||||
await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
|
await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
|
||||||
@@ -342,6 +446,15 @@ export const actions: Actions = {
|
|||||||
).run(id);
|
).run(id);
|
||||||
if (!deleted.changes)
|
if (!deleted.changes)
|
||||||
throw new Error('The role changed before it could be deleted.');
|
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({
|
await recordIsacAudit({
|
||||||
actorId: operator.id,
|
actorId: operator.id,
|
||||||
category: 'role_management',
|
category: 'role_management',
|
||||||
@@ -361,10 +474,15 @@ export const actions: Actions = {
|
|||||||
systemRole: seededRoleIds.has(id),
|
systemRole: seededRoleIds.has(id),
|
||||||
removedAssignmentCount: assignedUsers.length,
|
removedAssignmentCount: assignedUsers.length,
|
||||||
},
|
},
|
||||||
|
database,
|
||||||
});
|
});
|
||||||
await database.exec('COMMIT');
|
await database.exec('COMMIT');
|
||||||
|
transactionOpen = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.exec('ROLLBACK');
|
if (transactionOpen) {
|
||||||
|
transactionOpen = false;
|
||||||
|
await database.exec('ROLLBACK');
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { roleDeleted: true, resultToken: randomUUID() };
|
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,
|
getPermissions,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
} from '$lib/server/auth/authorization';
|
} 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 { getSessionUser } from '$lib/server/auth/session';
|
||||||
import { getDatabase } from '$lib/server/database';
|
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 { error, fail } from '@sveltejs/kit';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -286,105 +294,20 @@ export const actions: Actions = {
|
|||||||
...new Set(form.getAll('permissionIds').map(String)),
|
...new Set(form.getAll('permissionIds').map(String)),
|
||||||
];
|
];
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const target = await (
|
try {
|
||||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
await applyAuthorityChange({
|
||||||
).get(params.id);
|
database,
|
||||||
if (!target) return fail(404, { accessError: 'User not found.' });
|
operator,
|
||||||
const operatorPermissions = await getPermissions(operator);
|
targetId: params.id,
|
||||||
const targetAdminRole = await (
|
roleIds,
|
||||||
await database.prepare(
|
permissionIds,
|
||||||
`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.',
|
|
||||||
});
|
});
|
||||||
|
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 }) => {
|
moderate: async ({ cookies, request, params }) => {
|
||||||
const operator = await requirePermission(
|
const operator = await requirePermission(
|
||||||
@@ -414,16 +337,11 @@ export const actions: Actions = {
|
|||||||
const now = new Date().toISOString(),
|
const now = new Date().toISOString(),
|
||||||
database = await getDatabase();
|
database = await getDatabase();
|
||||||
const target = await (
|
const target = await (
|
||||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||||
).get(params.id);
|
).get(params.id);
|
||||||
if (!target) return fail(404, { moderationError: 'User not found.' });
|
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 (
|
if (
|
||||||
(target.role === 'admin' || targetAdminRole) &&
|
(await isFullAdministrator(database, params.id)) &&
|
||||||
!(await getPermissions(operator)).includes('*')
|
!(await getPermissions(operator)).includes('*')
|
||||||
) {
|
) {
|
||||||
return fail(403, {
|
return fail(403, {
|
||||||
@@ -484,16 +402,11 @@ export const actions: Actions = {
|
|||||||
return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
||||||
const database = await getDatabase();
|
const database = await getDatabase();
|
||||||
const target = await (
|
const target = await (
|
||||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
await database.prepare('SELECT id FROM users WHERE id=?')
|
||||||
).get(params.id);
|
).get(params.id);
|
||||||
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
|
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 (
|
if (
|
||||||
(target.role === 'admin' || targetAdminRole) &&
|
(await isFullAdministrator(database, params.id)) &&
|
||||||
!(await getPermissions(operator)).includes('*')
|
!(await getPermissions(operator)).includes('*')
|
||||||
) {
|
) {
|
||||||
return fail(403, {
|
return fail(403, {
|
||||||
@@ -501,10 +414,18 @@ export const actions: Actions = {
|
|||||||
'Only an administrator can delete another administrator.',
|
'Only an administrator can delete another administrator.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
|
try {
|
||||||
return fail(409, {
|
if (!(await softDeleteAccount(database, params.id, operator.id, reason)))
|
||||||
adminDeleteError: 'This account has already been deleted.',
|
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 };
|
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 {
|
import { destroySession, recordLogin } from '$lib/server/auth/session';
|
||||||
createSession,
|
|
||||||
destroySession,
|
|
||||||
recordLogin,
|
|
||||||
} from '$lib/server/auth/session';
|
|
||||||
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||||
import { getDatabase } from '$lib/server/database';
|
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 { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
||||||
import {
|
import {
|
||||||
emailDeliveryConfigured,
|
emailDeliveryConfigured,
|
||||||
@@ -215,7 +211,6 @@ export const actions: Actions = {
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
await insertUserProfile(database, id, displayName, now);
|
await insertUserProfile(database, id, displayName, now);
|
||||||
await ensureBootstrapAdmin(database, id);
|
|
||||||
await triggerAutomatedSystemMessages({
|
await triggerAutomatedSystemMessages({
|
||||||
eventKey: 'account.created',
|
eventKey: 'account.created',
|
||||||
userId: id,
|
userId: id,
|
||||||
@@ -223,13 +218,15 @@ export const actions: Actions = {
|
|||||||
}).catch((error) =>
|
}).catch((error) =>
|
||||||
console.error('Unable to deliver new-agent welcome message', error),
|
console.error('Unable to deliver new-agent welcome message', error),
|
||||||
);
|
);
|
||||||
const sessionId = await createSession(cookies, id);
|
const authentication = await completePrimaryAuthentication({
|
||||||
await recordLogin(id, 'password', true, {
|
database,
|
||||||
|
cookies,
|
||||||
|
userId: id,
|
||||||
|
provider: 'password',
|
||||||
request,
|
request,
|
||||||
getClientAddress,
|
getClientAddress,
|
||||||
sessionId,
|
|
||||||
});
|
});
|
||||||
redirect(303, '/account');
|
redirect(303, authentication.redirectTo);
|
||||||
},
|
},
|
||||||
login: async ({ request, cookies, getClientAddress }) => {
|
login: async ({ request, cookies, getClientAddress }) => {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
@@ -273,14 +270,15 @@ export const actions: Actions = {
|
|||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await ensureBootstrapAdmin(database, String(user.id));
|
const authentication = await completePrimaryAuthentication({
|
||||||
const sessionId = await createSession(cookies, String(user.id));
|
database,
|
||||||
await recordLogin(String(user.id), 'password', true, {
|
cookies,
|
||||||
|
userId: String(user.id),
|
||||||
|
provider: 'password',
|
||||||
request,
|
request,
|
||||||
getClientAddress,
|
getClientAddress,
|
||||||
sessionId,
|
|
||||||
});
|
});
|
||||||
redirect(303, '/account');
|
redirect(303, authentication.redirectTo);
|
||||||
},
|
},
|
||||||
logout: async ({ cookies }) => {
|
logout: async ({ cookies }) => {
|
||||||
await destroySession(cookies);
|
await destroySession(cookies);
|
||||||
|
|||||||
Reference in New Issue
Block a user