Skip to content

fix(ui): show the MFA lockout and keep the code step on a 401 - #7308

Merged
otavio merged 7 commits into
masterfrom
fix/mfa-auth-lockout
Oct 2, 2026
Merged

otavio merged 7 commits into
masterfrom
fix/mfa-auth-lockout

Conversation

@geovannewashington

@geovannewashington geovannewashington commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Summary

Companion of shellhub-io/cloud#2610, which adds the lockout and the single-use token to the MFA code step. Merge both together.

Refs shellhub-io/team#264

 <MfaCodeForm>                     components/mfa/MfaCodeForm.tsx
   loginWithMfa()                  stores/authStore.ts
-    any error                     "Invalid verification code"
+    429                           "Too many failed attempts", rethrows the SDK error
+  lockoutEndFrom(headers)         hooks/useLockoutCountdown.ts, shared with SignInForm
+  useLockoutCountdown(epoch)      hooks/useLockoutCountdown.ts, moved out of SignInForm
+  <Callout> lockout + countdown, then "Your timeout has finished"

 response interceptor              api/fetchInterceptors.ts
-  401 outside /api/login          logout, redirect to /login
+  401 outside /api/login and /api/user/mfa/auth

The OpenAPI spec of POST /api/user/mfa/auth gains 429 with X-Account-Lockout, and declares the header on 200 too. Its description says the token logs in once, wrong codes have a lockout of their own that a correct password does not clear, and a 404 means the user behind the token is gone.

The interceptor change is needed because a wrong code now answers 401. Without it, a typo sends the user back to the password step.

Core's cache gains CompareAndDelete(key, value), a Lua script that deletes a key only while it still holds the given value. Cloud's MFA guard stores a UUID per request with SetNX and releases it with this, so a request that outlives the guard's TTL cannot free the guard a later request took.

 type Cache interface             pkg/cache/cache.go
   SetNX(key, value, ttl)
+  CompareAndDelete(key, value)   redis: GET+DEL in one script, null: true

Evidence

  • Before: a lockout on the MFA page read as "Invalid verification code", and a 401 from the code step signed the user out.
    After: MfaLogin.test.tsx runs the real store against MSW:

    401 on the code             -> stays on the page, "Invalid verification code", token kept
    429 + X-Account-Lockout     -> "Too many failed attempts (29 seconds)", token kept
    lockout ends                -> "Your timeout has finished"
    429 without the header      -> message, no countdown, no "finished"
    

    Login.test.tsx gains one case: a 429 without the header shows the message, no countdown and no "finished". It failed before lockoutEndFrom.

    Console suite: 2670 tests pass. The build passes.

    TestRedisCacheCompareAndDeleteKeepsAnotherOwnersKey runs against Valkey: a wrong value leaves the key in place, the owner's value deletes it.

  • Manual, dev stack: three wrong codes showed the lockout with its countdown. After it, the right code logged in without the password.

Merge Danger

Door: two-way

UI, OpenAPI and one new method on core's Cache interface. No migration or wire format change. Any other Cache implementation has to add CompareAndDelete; the repos have only the Redis and null ones, plus the mock.

Blast Radius: MFA login

SignInForm switched to the shared countdown hook and header parse. Its one change: a 429 without the header now shows no countdown, where it used to show "Your timeout has finished" at once. Merged alone, this PR is harmless: the server still answers 403 and 404, which the UI shows as before. The reverse is not true: cloud#2610 calls CompareAndDelete and does not build against a shellhub without it, so merge this one first or together.

POST /api/user/mfa/auth now answers 429 with X-Account-Lockout while wrong
codes keep the source locked out, and 401 for an unknown or already used token.
A 429 from the code step used to read as a wrong code. The form now shows the
lockout with the countdown the password form uses, moved to a shared hook, and
keeps the MFA token so the user can retry once it ends.

Fixes: shellhub-io/team#264
The MFA code step now answers a wrong code with 401, and the response
interceptor treated any 401 outside /api/login as an expired session, so a
typo sent the user back to the password step. A 429 without the lockout header
also read as an already finished lockout, since Number(null) is 0.

The tests run the real auth store through MSW. The countdown ones advance fake
time one second per act() call, because the form stores the deadline after
the error message renders, so its interval exists only after that commit.
@geovannewashington
geovannewashington requested review from a team as code owners October 2, 2026 19:29
@geovannewashington geovannewashington self-assigned this Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Claude finished @geovannewashington's task in 5m 38s —— View job


Code Review Complete

Reviewed 7 files and posted 3 inline issues. Standards: 0 violations, 3 judgement calls. Spec: did not run. Correctness: 0.

Standards

The worst one is in ui/apps/console/src/components/mfa/MfaCodeForm.tsx:43-46. The X-Account-Lockout parse and the lockout callouts are copied from SignInForm.tsx, and the copies already differ: epoch > 0 here, isNaN(epoch) there. So a 429 without the header still shows "Your timeout has finished" right away on the password step. The other two calls ask the lockout tests in MfaLogin.test.tsx to assert the countdown value, and to assert that no countdown shows when the header is missing.

Spec

The Spec axis did not run. The PR references shellhub-io/team#264, which this job can't read, so there was no issue text to review against.

Additional notes

  • No cap dropped anything. Validation discarded one correctness candidate. It claimed that dropping the 401 logout for /api/user/mfa/auth traps a user whose 30-minute MFA token expired. In fact there are ways out: a reload clears the in-memory mfaToken and redirects to /login, and recovery codes don't use the token. One minor UX gap remains: an expired token shows the same "Invalid verification code" message as a wrong code.
  • Two things were left out because this branch didn't introduce them. useLockoutCountdown sits next to an existing hooks/useCountdown.ts that does much the same job, but it was moved here from SignInForm as-is. The lockout strings in the store and in SignInForm are also worded slightly differently.

If you've addressed the feedback and want a new review, tag @shellhub-io/admin and a team member can trigger it.

Comment thread ui/apps/console/src/components/mfa/MfaCodeForm.tsx
Comment thread ui/apps/console/src/pages/__tests__/MfaLogin.test.tsx Outdated
Comment thread ui/apps/console/src/pages/__tests__/MfaLogin.test.tsx
The code step now counts wrong codes apart from wrong passwords, because a
correct password resets the password counter and would otherwise clear the
MFA one too.
The password form parsed X-Account-Lockout with an isNaN check, so a 429
without the header read as a lockout that ended at epoch 0 and showed "Your
timeout has finished" at once. Both forms now use lockoutEndFrom, which
treats a missing, zero or malformed header as no countdown.
… header

Cloud sets X-Account-Lockout on every response, as /api/login does, so the
200 declares it too. A valid token whose user is gone still answers 404, and
a code sent while another one is being checked answers 401.
…ns it

Cloud's MFA login takes a per-user in-flight guard with SetNX and deletes it
when the guess is done. If a request outlives the guard's TTL, another one
takes the guard, and a plain Delete from the first frees it, letting a third
guess run next to the second. CompareAndDelete runs GET and DEL in one Lua
script, so the owner's value decides the delete atomically.

The mock was written to match mockery's output by hand, because mockery
cannot load the root packages inside the dev container.

Refs: shellhub-io/team#264
@otavio
otavio merged commit 383d9ba into master Oct 2, 2026
87 checks passed
@otavio
otavio deleted the fix/mfa-auth-lockout branch October 2, 2026 21:58
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.

3 participants