Skip to content

Fix dashboard login on installs with 2FA enabled - #343

Merged
kh0pper merged 1 commit into
mainfrom
fix/dashboard-2fa-login
Sep 9, 2026
Merged

Fix dashboard login on installs with 2FA enabled#343
kh0pper merged 1 commit into
mainfrom
fix/dashboard-2fa-login

Conversation

@kh0pper

@kh0pper kh0pper commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Dashboard login is unreachable on any instance with 2FA enabled. Two independent defects, both on error/edge paths, which is why they survived: an instance with 2FA off never reaches either.

1. The pending-2FA token could never be stored

dashboard/totp.js wrote the pending-2FA token into oauth_tokens, whose constraint is CHECK(token_type IN ('access','refresh')). Every 2FA login failed with CHECK constraint failed: token_type IN ('access', 'refresh'). attemptLogin's DB-failure path reports that as a soft error, so a correct password renders "Login temporarily unavailable (server database error)" with no route in from the UI.

Fixed additively with a dedicated dashboard_pending_2fa table rather than widening the CHECK. SQLite cannot ALTER a CHECK constraint, so widening means DROP/recreate/copy of a live token table; the house pattern for that (the bot_sessions.control widen) also needs a SCHEMA_GENERATION bump, a REBUILD_TABLES entry and the dry-run script. A new table gets the same result with nothing dropped. No data migration is needed, because no pending_2fa row can ever have been written.

init-db.js creates the table, and runGatewayMigrations also creates it at boot, so a host that pulls and restarts without running init-db is not left locked out. That is the same deploy-ordering window ensureSyncConflictsOpColumn already closes.

2. A wrong password could crash the gateway

dashboard/index.js calls t() at 17 sites but never imported it. Every one of those paths throws ReferenceError: t is not defined:

  • login lockout help (fires after 5 failed attempts)
  • 2FA session expiry, invalid 2FA code, 2FA recovery, 2FA setup errors
  • password-reset request and invalid-token errors

An unhandled rejection is fatal in that process, so each one takes the gateway down. Observed live: five wrong passwords, then every subsequent attempt crash-looped the service (restart counter climbing, Main process exited, code=exited, status=1/FAILURE). The i18n keys themselves all exist; only the import was missing.

Tests

tests/dashboard-2fa-login.test.js:

  • pending-2FA token round-trip: hashed at rest (plaintext never stored), context readable without consuming, single-use verify, expired rows rejected and swept on create
  • asserts the pre-fix INSERT still fails the CHECK, so the reason the new table exists stays documented
  • static rot-guard: no sql: line in totp.js may name oauth_tokens
  • static rot-guard: index.js must import every i18n helper it calls, and every key it asks for must resolve

Both rot-guards were run against the unfixed files and confirmed to fail (missing import t detected; 5 offending SQL lines detected). migration-guard, migration-registry, auth-network and login-password-toggle all still pass (52 + 9 tests). The init-db change is purely additive, so the destructive-statement rot-guard is unaffected.

Deploy note

Existing installs get the table from either path (init-db or the next gateway boot). An instance currently locked out by defect 1 becomes loggable after a restart on this code.

Two defects made the dashboard unreachable on any instance with 2FA on,
found on a self-hosted instance whose 2FA had been enabled.

1. totp.js stored the pending-2FA token in oauth_tokens, whose
   CHECK(token_type IN ('access','refresh')) rejects token_type
   ='pending_2fa'. attemptLogin's DB-failure path swallowed the error, so
   a CORRECT password rendered "Login temporarily unavailable (server
   database error)" and there was no way in from the UI. Instances with
   2FA off never hit it, because sessions use token_type='access'.

   Fixed additively with a dedicated dashboard_pending_2fa table rather
   than widening the CHECK: SQLite cannot ALTER a CHECK, and the
   alternative was a DROP/recreate of a live token table. init-db creates
   it; runGatewayMigrations also creates it at boot so a host that pulls
   and restarts without running init-db is not left locked out (the same
   deploy-ordering window ensureSyncConflictsOpColumn closes).

2. dashboard/index.js called t() at 17 sites without importing it. Every
   one of those paths - login lockout, 2FA session expiry, invalid 2FA
   code, 2FA recovery, 2FA setup errors, password-reset errors - threw
   ReferenceError: t is not defined, and because an unhandled rejection
   is fatal there, each one took the whole gateway process down. Five
   consecutive wrong passwords were enough to crash-loop the gateway.

Tests: tests/dashboard-2fa-login.test.js covers the token round-trip
(hashed at rest, context read without consuming, single-use verify,
expiry sweep), asserts the pre-fix INSERT still fails the CHECK so the
reason for the new table stays documented, and adds two static
rot-guards - no totp.js SQL may name oauth_tokens, and index.js must
import every i18n helper it calls. Both guards were confirmed to fail
against the unfixed files.
@kh0pper
kh0pper merged commit 897cfdd into main Sep 9, 2026
3 checks passed
kh0pper added a commit that referenced this pull request Sep 10, 2026
…#345)

A POST to /dashboard/login/2fa with the `totp_code` field simply absent
reached verifyTotp(undefined, secret). otpauth reads `.length` off the
token, so the call raised `TypeError: Cannot read properties of
undefined` inside an async route handler — an unhandled rejection, which
is fatal, so one malformed request took the whole gateway process down.
Same class as the t()-not-imported defect in #343: a bad request must
render an error, never end the process.

The secret argument is attacker-shaped too. /dashboard/login/2fa/setup
and the settings enable_2fa action both pass a `secret` straight from the
request body, and Secret.fromBase32(undefined) throws the same way, as
does a string that is not base32 at all.

So verifyTotp is now total on both arguments: a non-string or blank
token, a non-string or blank secret, and a secret that will not parse as
base32 each answer false. Nothing else changes — a number, an object or a
junk string already answered false via otpauth's own null return, and a
real code for the current period still verifies.

Found on the R4 instance while building a client for the perch-interactive
routes; one malformed POST restarted its gateway during #343's own
verification.

Co-authored-by: kh0pper <kevin.hopper@maestro.press>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant