Skip to content

feat(account): profile, security and TOTP two-factor authentication - #208

Open
sineld wants to merge 4 commits into
rustfs:mainfrom
sineld:feat/admin-account-2fa
Open

feat(account): profile, security and TOTP two-factor authentication#208
sineld wants to merge 4 commits into
rustfs:mainfrom
sineld:feat/admin-account-2fa

Conversation

@sineld

@sineld sineld commented Aug 25, 2026

Copy link
Copy Markdown

Pull Request

Description

Makes the account area meaningful and adds TOTP two-factor authentication to the Console.

Before this, the account menu opened on an avatar with no name, and password management was hidden from administrators — because the backend rejected the call it made (add-user refuses the root access key). Both are fixed.

  • The menu now names the signed-in identity and its authority, and links to Profile and Security.
  • New /account (profile) and /account/security (password + two-factor) pages.
  • Dialogs for password change, two-factor setup, disable, and recovery-code regeneration.
  • A second-factor step in the login flow.

Merge order

This PR must merge after rustfs/rustfs#6596. The endpoints it calls do not exist
until then, so merging this first ships a console whose Profile and Security pages
fail against every released server.

1.  rustfs/rustfs#6596     backend: endpoints + login gate     ← must land first
2a. rustfs/console#208     this PR                             ← after 1
2b. rustfs/cli#358         CLI                                 ← after 1

2a and 2b are independent of each other and can land in either order, or together.
Kept as a draft until 1 lands.

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Security fix
  • Bug fix (non-breaking change which fixes an issue)

The bug fix: components/user/change-password.tsx changed a password by re-POSTing the whole user through add-user, which rewrote status and dropped the policy field — a password reset that silently re-enabled a disabled account. It is replaced by POST /account/password, which touches only the secret.

Design notes for reviewers

Followed skills/rustfs-console-design-guide/SKILL.md; the decisions worth checking:

  • Setup is one dialog with internal steps, not a chain of dialogs. Nesting is ruled out by the guide, and it is one decision ("protect this account") even across three screens. It also means a parent cannot close underneath the recovery codes and orphan the only copy that will ever exist.
  • Recovery codes cannot be dismissed until copied or downloaded. The server keeps only hashes and cannot show them again.
  • Turning the factor off asks for the code and the password. A code alone would let a session someone walked away from strip the protection with one shoulder-surfed number.
  • Controls are gated on what the server reports as possible, and an unavailable feature explains itself rather than showing a dead button. A root identity says its credentials come from the server environment and cannot be changed here.
  • A failed read never renders as an empty profile; loading, error and empty are distinct, and retry sits next to the failure.
  • Passive metadata is a definition list, not a grid of bordered cards; peer sections use one divider system, no nested frames.
  • Zero recovery codes is a louder state than "running low."

Security-relevant:

  • The long-term credentials needed for the second login call stay in component state. Only the resulting STS session is persisted, and no TOTP secret ever reaches browser storage.
  • The QR arrives as a server-rendered SVG and is exposed as a data: URI via <img>, never injected as markup.
  • A demand for a second factor is modelled as a login outcome, not an error. Reporting it as "login failed" would send the user to reset a password that is working.
  • The pre-login challenge probe is signed with the key the user just typed, which is what keeps it from being an enumeration oracle. A server without the endpoint, or any transport failure, is treated as "not required" — AssumeRole fails closed on its own, so this cannot become a bypass.

Testing

pnpm install --frozen-lockfile   # lockfile unchanged (no new dependencies)
pnpm type-check                  # clean
pnpm lint                        # clean
pnpm test:run                    # 430 passed, 0 failed
  • Unit tests added/updated
  • Manual testing completed — full flow driven end to end against a live server; see Screenshots

New suites: tests/lib/mfa.test.ts (16 tests over code-shape detection, the MFA-required signal including cyclic error objects, and QR/secret formatting) and tests/lib/account-surface.test.js (12 tests pinning the state handling, the no-nested-dialog rule, the no-secret-in-storage rule, and that the account pages stay reachable for every authenticated identity).

Two existing tests were updated rather than deleted, because this PR deliberately reverses what they asserted:

  • iam-remaining-safety.test.js asserted the password dialog does not ask for the current secret — correct for add-user, wrong for /account/password, where it is the proof-of-knowledge step. Retargeted at the new dialog and now asserts the stronger contract, plus that add-user/createUser cannot come back.
  • ui-layout-source.test.js pinned the exact avatar markup. Same intent (artwork smaller than its trigger, visible inset), updated for the added shrink-0.

Checklist

  • Code follows the project's style guidelines
  • Self-review completed
  • TypeScript types are properly defined
  • All existing tests pass
  • No new dependencies added — input-otp was already in package.json and unused; the QR needs no library because the server renders it
  • All commit messages are in English (Conventional Commits)

Related Issues

N/A — no tracking issue exists yet.

Screenshots

Captured against a real stack: a debug build of rustfs/rustfs#6596 with
RUSTFS_IAM_MASTER_KEY set, plus pnpm dev on this branch, signed in as the root
identity sinan. Every state below is a genuine server response, not a fixture.

Account menu — before / after

Before, the menu opened on an avatar with no name, and password management was
hidden from administrators because the backend rejected the call it made. After,
the trigger and the menu both name the identity and its authority, and link to
Profile and Security.

Account menu trigger after

/account/security — two-factor off → on

Root reports its password as immutable (it comes from RUSTFS_ACCESS_KEY) while
enrollment stays available. That split is the whole point: the default deployment
signs into the console as root, so refusing enrollment there would leave the one
login this feature exists for unprotected.

Security, 2FA off

Security, 2FA on

Setup dialog

The QR is rendered by the server and embedded as a data: URI — no QR library in
the console. Captured at a narrow viewport (628 CSS px), which is why the dialog
fills the width.

Setup dialog with QR

Recovery codes — the dismissal guard

Regenerating asks for a second factor first. The new set arrives with Done
disabled
until the codes are copied or downloaded, because the server keeps only
hashes and cannot show them again.

Regenerate prompt

Recovery codes, Done disabled

Recovery codes, saved and Done enabled

/account profile

Profile

Login second-factor step

The password was accepted and the server asked for a code — the UI shows the
second-factor step, not "Login Failed". This is the branch the whole
LoginOutcome type exists for.

Login second factor

Completing it with a real TOTP code signs in and advances the server-side
Last used stamp, visible here after the login above:

Security after a 2FA login

Two gaps I am not papering over:

  1. No true mobile capture. The browser tooling available to me could not set a
    narrow CSS viewport — resize_window reported success while window.innerWidth
    stayed put. The setup-dialog capture happens to be at 628 CSS px, which is the
    only sub-md evidence here. The responsive rules are covered by source tests,
    not by a capture.
  2. No true "before" capture of the old menu. That would need a second dev
    server on main; the after-state above is the honest half.

Images live on an orphan branch of the fork
(ui-evidence/account-2fa)
so no binaries enter this PR's diff or the upstream history.

Additional Notes

Two things a reviewer should know:

  1. pnpm format:check was already failing on main. components/object/tiff-viewer.tsx was not Prettier-clean in the base branch, so the gate failed for every branch regardless of contents. Fixed in its own whitespace-only commit (style: format tiff-viewer with Prettier) so it can be cherry-picked or dropped independently of the feature.
  2. i18n: 68 new keys across all 14 locales, translated rather than left as English fallbacks. The interpolation uses single braces ({account}) to match this repo's i18next configuration (prefix: "{"), not the i18next default — worth a glance, since double braces would have rendered literally.

sineld added 4 commits August 25, 2026 21:13
Adds the API layer for the account and MFA endpoints, plus the pure
helpers the UI branches on.

Every security decision stays on the server. What lives here is
presentation logic and the detection of the one signal the login flow has
to branch on: a demand for a second factor is a successful password check,
not a failure, and reporting it as "login failed" would send the user to
reset a password that is working.

`fetchMfaChallenge` signs with the long-term key the user just typed,
because at login time there is no session to sign with. That signature
requirement is also what keeps the endpoint from being an enumeration
oracle. A server without the endpoint, or any transport failure, is
reported as "not required" rather than as an error: `AssumeRole` fails
closed on its own, so this cannot become a bypass.

The QR arrives as a server-rendered SVG and is exposed as a data URI, not
injected as markup — no QR library is added, and a server-generated SVG
never becomes a same-origin script sink.
The account menu previously opened on an avatar with no name and hid
password management from administrators, because the backend rejected the
call it made. Both are fixed: the menu now names the signed-in identity
and its authority, and links to Profile and Security.

Adds `/account` and `/account/security`, dialogs for password change,
two-factor setup, disable and recovery-code regeneration, and the
second-factor step in the login flow.

Design notes:

* Setup is one dialog with internal steps, not a chain of dialogs: it is
  one decision, and a parent closing underneath the recovery codes would
  orphan the only copy that will ever exist.
* Recovery codes cannot be dismissed until copied or downloaded. The
  server keeps only hashes and cannot show them again.
* Turning the factor off asks for the code *and* the password. A code
  alone would let a session someone walked away from strip the protection.
* Controls are gated on what the server reports as possible, and an
  unavailable feature explains itself instead of showing a dead button —
  a root identity says its credentials come from the server environment.
* A failed read never renders as an empty profile; passive metadata is a
  definition list rather than a grid of cards.

The long-term credentials needed for the second call stay in component
state; only the resulting STS session is persisted, and no TOTP secret
reaches browser storage.

Replaces `components/user/change-password.tsx`, which called `add-user`
and so rewrote the target's status and dropped its policy field.
`components/object/tiff-viewer.tsx` was not Prettier-clean on `main`, so
`pnpm format:check` — a required gate in AGENTS.md — failed for every
branch regardless of its contents.

Whitespace only; no behaviour change. Unrelated to the account and
two-factor work in this branch, and separated into its own commit so it
can be cherry-picked or dropped independently.
UI-027 and UI-028, per the audit register's own convention: the account
menu named neither the identity nor its authority and hid password
management from administrators, and a recovery-code set is the only copy
that will ever exist, so its dialog must not be freely dismissable.

Evidence key `account-2fa-29` is live runtime captures rather than static
fixtures. The note states what it does not cover — a mobile viewport and
a `main`-branch before-capture — so the source tests stay authoritative
for those two rather than being implied by the key.
@sineld
sineld marked this pull request as ready for review August 25, 2026 18:54
@mertefecerit

Copy link
Copy Markdown

+1

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.

2 participants