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
+42 -6
View File
@@ -9,8 +9,8 @@ The team builds the project in vertical slices.
data
- Auth.js-compatible server authentication with Discord OAuth and credentials
providers
- Argon2id password hashing, server-side sessions, CSRF protection, and role
checks
- Argon2id password hashing, TOTP MFA with encrypted secrets, server-side
sessions, CSRF protection, and role checks
- Browser `localStorage` only for anonymous build drafts
## Product boundaries
@@ -41,6 +41,43 @@ Local credentials use Argon2id PHC strings with a unique random salt per
password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
minimum profile. Successful login rehashes credentials with outdated parameters.
## Authentication and MFA
Sign-in never grants administrator authority. The first full administrator is
created through an offline database operation after the target account has
enrolled TOTP. The local command is:
```sh
npm run admin:bootstrap -- <verified-email>
```
Staging and production use the stopped-container Compose workflow documented in
[`DEPLOYMENT.md`](DEPLOYMENT.md), not a host-side npm command. The bootstrap
operation accepts exactly one active, unblocked account with a verified email,
requires an active TOTP factor and an unused recovery code, requires that no
active full administrator exists, assigns the canonical `role-admin`
relationship, revokes the target's sessions, and records
`role_management / administrator.bootstrapped`. Administrators must enroll TOTP
before offline elevation.
`MFA_TOTP_ENCRYPTION_KEYS` is a required app-only secret. Its value contains one
to three comma-separated, unique `key-id:canonical-base64url-32-byte-key`
entries. The first entry is the active encryption key for new factors; later
entries retain decryption access to older factors during rotation. There is no
generated-key fallback. Rotate by prepending a newly generated 32-byte key with
a new ID, deploying it with the old entries retained, and verifying existing
TOTP authentication. A successful verification rewraps that factor under the
first key. Do not remove an old entry until every factor encrypted with it has
been rewrapped and verified. Keep key material in the secret manager and never
place it in source, logs, audit snapshots, or documentation.
TOTP enrollment issues ten one-time recovery codes. The interface displays them
only once; the database stores only domain-separated hashes. Save the codes in
an offline password manager or another protected offline record. Each code is
consumed once. Regenerating codes immediately revokes all unused previous codes
and displays a new set once. Disabling TOTP revokes its recovery codes, and a
full administrator cannot disable TOTP.
## Database runtime
The application uses Turso Database in-process through its native Node package.
@@ -244,10 +281,9 @@ does not become part of the canonical action replay.
# Administrator bootstrap
The authentication service promotes the first authenticated user to `admin` if
the database does not contain an administrator. Promotion uses one conditional
database update so concurrent first sign-ins cannot both become administrators.
Once an administrator exists, later sign-ins never alter roles.
The first full administrator is created only through the offline flow documented
in [Authentication and MFA](#authentication-and-mfa). Sign-in never grants
administrator authority or changes an account's roles.
# Database backup and restore