feat: add TOTP multifactor authentication
This commit is contained in:
+81
-3
@@ -98,9 +98,81 @@ remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
|
||||
and delete local and hosted dotenv key material. Removing encrypted files from
|
||||
the current Git tree does not revoke values stored in Git history.
|
||||
|
||||
The staging container disables default-user seeding. On a brand-new staging
|
||||
database, the existing bootstrap policy grants Admin to the first real account
|
||||
that signs in instead of writing generated credentials into deployment logs.
|
||||
## Authentication bootstrap and MFA secrets
|
||||
|
||||
Set the required app-only `MFA_TOTP_ENCRYPTION_KEYS` secret under `/app` in the
|
||||
`dev`, `staging`, and `prod` Infisical environments before starting each app.
|
||||
The value contains one to three comma-separated, unique
|
||||
`key-id:canonical-base64url-32-byte-key` entries. Each key is a random 32-byte
|
||||
value encoded as canonical Base64URL. The first entry is the active write key;
|
||||
following entries are retained decryption keys for older TOTP factors. Do not
|
||||
put this secret under `/shared/scheduler`, in a Gitea variable, in the image, or
|
||||
in a deployment log. A missing, malformed, duplicate, or overlong keyring fails
|
||||
app runtime validation.
|
||||
|
||||
Rotate the keyring without losing access to existing factors:
|
||||
|
||||
1. Generate a new random 32-byte key in the approved secret-management process
|
||||
and assign it a new key ID. Do not copy the value into a document or shell
|
||||
history.
|
||||
2. Prepend the new `key-id` entry and retain the old entries in Infisical.
|
||||
3. Deploy or restart the app, then verify an existing TOTP sign-in and a new
|
||||
enrollment. A successful verification rewraps that factor under the first
|
||||
key.
|
||||
4. Retain each old key until every factor encrypted with it has been rewrapped
|
||||
and verified. Never remove old entries merely because new enrollments use the
|
||||
new key; losing a required old key prevents decryption of those factors.
|
||||
|
||||
TOTP enrollment issues ten recovery codes and displays them once. The database
|
||||
stores only their domain-separated hashes. The account owner must save the codes
|
||||
in an offline password manager or another protected offline record; do not put
|
||||
their values in Infisical, logs, tickets, backups, audit snapshots, or this
|
||||
documentation. Each code works once. Regenerating codes immediately revokes all
|
||||
unused previous codes and displays a replacement set once. Disabling TOTP also
|
||||
revokes its recovery codes.
|
||||
|
||||
Sign-in never grants administrator authority automatically. Before offline
|
||||
elevation, the target administrator must have a verified email, an active TOTP
|
||||
factor, and at least one unused recovery code. The target enrolls TOTP at
|
||||
`/account/security` while the app is running. Then stop every app and scheduler
|
||||
service that can access the database and run the environment-specific command.
|
||||
The command requires that no active full administrator already exists, assigns
|
||||
the canonical administrator role, revokes the target's sessions, and records
|
||||
`role_management / administrator.bootstrapped`.
|
||||
|
||||
For local development:
|
||||
|
||||
```sh
|
||||
npm run admin:bootstrap -- <verified-email>
|
||||
```
|
||||
|
||||
For staging, from `/opt/shd-build-stage`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||
app node scripts/admin-bootstrap.mjs <verified-email>
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
|
||||
```
|
||||
|
||||
For production, from `/opt/shd-build-production`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
|
||||
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||
app node scripts/admin-bootstrap.mjs <verified-email>
|
||||
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
|
||||
```
|
||||
|
||||
The script requires exactly one active, unblocked account with a verified email,
|
||||
an active TOTP factor, and an unused recovery code. It exits without changing
|
||||
roles when an active full administrator already exists. The Compose commands
|
||||
restart their services; restart the local development server after its command.
|
||||
The target must sign in again because its old sessions were revoked.
|
||||
|
||||
The staging container disables default-user seeding. A first sign-in never
|
||||
receives Admin; use the offline flow above after a new database or an initial
|
||||
data bootstrap.
|
||||
|
||||
## Gitea Actions runner
|
||||
|
||||
@@ -388,6 +460,12 @@ import. The importer verifies the bundle target, creates a production safety
|
||||
backup, and refuses to replace a production database containing users, builds,
|
||||
audit events, or proposals. Remove the remote host copy after verification.
|
||||
|
||||
If the imported database has no active full administrator, complete the offline
|
||||
administrator flow with the production Compose command in
|
||||
[Authentication bootstrap and MFA secrets](#authentication-bootstrap-and-mfa-secrets)
|
||||
before reopening the site. Do not rely on the first production sign-in for
|
||||
elevation.
|
||||
|
||||
### Promote one News article
|
||||
|
||||
Export a locally authored News article by ID or slug while the development
|
||||
|
||||
+42
-6
@@ -9,8 +9,8 @@ The team builds the project in vertical slices.
|
||||
data
|
||||
- Auth.js-compatible server authentication with Discord OAuth and credentials
|
||||
providers
|
||||
- Argon2id password hashing, server-side sessions, CSRF protection, and role
|
||||
checks
|
||||
- Argon2id password hashing, TOTP MFA with encrypted secrets, server-side
|
||||
sessions, CSRF protection, and role checks
|
||||
- Browser `localStorage` only for anonymous build drafts
|
||||
|
||||
## Product boundaries
|
||||
@@ -41,6 +41,43 @@ Local credentials use Argon2id PHC strings with a unique random salt per
|
||||
password. Current work factors meet OWASP's 19 MiB / 2 iteration / single-lane
|
||||
minimum profile. Successful login rehashes credentials with outdated parameters.
|
||||
|
||||
## Authentication and MFA
|
||||
|
||||
Sign-in never grants administrator authority. The first full administrator is
|
||||
created through an offline database operation after the target account has
|
||||
enrolled TOTP. The local command is:
|
||||
|
||||
```sh
|
||||
npm run admin:bootstrap -- <verified-email>
|
||||
```
|
||||
|
||||
Staging and production use the stopped-container Compose workflow documented in
|
||||
[`DEPLOYMENT.md`](DEPLOYMENT.md), not a host-side npm command. The bootstrap
|
||||
operation accepts exactly one active, unblocked account with a verified email,
|
||||
requires an active TOTP factor and an unused recovery code, requires that no
|
||||
active full administrator exists, assigns the canonical `role-admin`
|
||||
relationship, revokes the target's sessions, and records
|
||||
`role_management / administrator.bootstrapped`. Administrators must enroll TOTP
|
||||
before offline elevation.
|
||||
|
||||
`MFA_TOTP_ENCRYPTION_KEYS` is a required app-only secret. Its value contains one
|
||||
to three comma-separated, unique `key-id:canonical-base64url-32-byte-key`
|
||||
entries. The first entry is the active encryption key for new factors; later
|
||||
entries retain decryption access to older factors during rotation. There is no
|
||||
generated-key fallback. Rotate by prepending a newly generated 32-byte key with
|
||||
a new ID, deploying it with the old entries retained, and verifying existing
|
||||
TOTP authentication. A successful verification rewraps that factor under the
|
||||
first key. Do not remove an old entry until every factor encrypted with it has
|
||||
been rewrapped and verified. Keep key material in the secret manager and never
|
||||
place it in source, logs, audit snapshots, or documentation.
|
||||
|
||||
TOTP enrollment issues ten one-time recovery codes. The interface displays them
|
||||
only once; the database stores only domain-separated hashes. Save the codes in
|
||||
an offline password manager or another protected offline record. Each code is
|
||||
consumed once. Regenerating codes immediately revokes all unused previous codes
|
||||
and displays a new set once. Disabling TOTP revokes its recovery codes, and a
|
||||
full administrator cannot disable TOTP.
|
||||
|
||||
## Database runtime
|
||||
|
||||
The application uses Turso Database in-process through its native Node package.
|
||||
@@ -244,10 +281,9 @@ does not become part of the canonical action replay.
|
||||
|
||||
# Administrator bootstrap
|
||||
|
||||
The authentication service promotes the first authenticated user to `admin` if
|
||||
the database does not contain an administrator. Promotion uses one conditional
|
||||
database update so concurrent first sign-ins cannot both become administrators.
|
||||
Once an administrator exists, later sign-ins never alter roles.
|
||||
The first full administrator is created only through the offline flow documented
|
||||
in [Authentication and MFA](#authentication-and-mfa). Sign-in never grants
|
||||
administrator authority or changes an account's roles.
|
||||
|
||||
# Database backup and restore
|
||||
|
||||
|
||||
Reference in New Issue
Block a user