feat: add TOTP multifactor authentication

This commit is contained in:
jb
2026-08-07 13:10:05 -04:00
parent 4777a3abd5
commit e67c3d4cdb
50 changed files with 6646 additions and 539 deletions
+81 -3
View File
@@ -98,9 +98,81 @@ remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
and delete local and hosted dotenv key material. Removing encrypted files from
the current Git tree does not revoke values stored in Git history.
The staging container disables default-user seeding. On a brand-new staging
database, the existing bootstrap policy grants Admin to the first real account
that signs in instead of writing generated credentials into deployment logs.
## Authentication bootstrap and MFA secrets
Set the required app-only `MFA_TOTP_ENCRYPTION_KEYS` secret under `/app` in the
`dev`, `staging`, and `prod` Infisical environments before starting each app.
The value contains one to three comma-separated, unique
`key-id:canonical-base64url-32-byte-key` entries. Each key is a random 32-byte
value encoded as canonical Base64URL. The first entry is the active write key;
following entries are retained decryption keys for older TOTP factors. Do not
put this secret under `/shared/scheduler`, in a Gitea variable, in the image, or
in a deployment log. A missing, malformed, duplicate, or overlong keyring fails
app runtime validation.
Rotate the keyring without losing access to existing factors:
1. Generate a new random 32-byte key in the approved secret-management process
and assign it a new key ID. Do not copy the value into a document or shell
history.
2. Prepend the new `key-id` entry and retain the old entries in Infisical.
3. Deploy or restart the app, then verify an existing TOTP sign-in and a new
enrollment. A successful verification rewraps that factor under the first
key.
4. Retain each old key until every factor encrypted with it has been rewrapped
and verified. Never remove old entries merely because new enrollments use the
new key; losing a required old key prevents decryption of those factors.
TOTP enrollment issues ten recovery codes and displays them once. The database
stores only their domain-separated hashes. The account owner must save the codes
in an offline password manager or another protected offline record; do not put
their values in Infisical, logs, tickets, backups, audit snapshots, or this
documentation. Each code works once. Regenerating codes immediately revokes all
unused previous codes and displays a replacement set once. Disabling TOTP also
revokes its recovery codes.
Sign-in never grants administrator authority automatically. Before offline
elevation, the target administrator must have a verified email, an active TOTP
factor, and at least one unused recovery code. The target enrolls TOTP at
`/account/security` while the app is running. Then stop every app and scheduler
service that can access the database and run the environment-specific command.
The command requires that no active full administrator already exists, assigns
the canonical administrator role, revokes the target's sessions, and records
`role_management / administrator.bootstrapped`.
For local development:
```sh
npm run admin:bootstrap -- <verified-email>
```
For staging, from `/opt/shd-build-stage`:
```sh
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
app node scripts/admin-bootstrap.mjs <verified-email>
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
```
For production, from `/opt/shd-build-production`:
```sh
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
app node scripts/admin-bootstrap.mjs <verified-email>
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
```
The script requires exactly one active, unblocked account with a verified email,
an active TOTP factor, and an unused recovery code. It exits without changing
roles when an active full administrator already exists. The Compose commands
restart their services; restart the local development server after its command.
The target must sign in again because its old sessions were revoked.
The staging container disables default-user seeding. A first sign-in never
receives Admin; use the offline flow above after a new database or an initial
data bootstrap.
## Gitea Actions runner
@@ -388,6 +460,12 @@ import. The importer verifies the bundle target, creates a production safety
backup, and refuses to replace a production database containing users, builds,
audit events, or proposals. Remove the remote host copy after verification.
If the imported database has no active full administrator, complete the offline
administrator flow with the production Compose command in
[Authentication bootstrap and MFA secrets](#authentication-bootstrap-and-mfa-secrets)
before reopening the site. Do not rely on the first production sign-in for
elevation.
### Promote one News article
Export a locally authored News article by ID or slug while the development