feat: user account page: auth provider linking, TOTP 2FA, user profile management - #983
Open
rohan-chaturvedi wants to merge 37 commits into
Open
feat: user account page: auth provider linking, TOTP 2FA, user profile management#983rohan-chaturvedi wants to merge 37 commits into
rohan-chaturvedi wants to merge 37 commits into
Conversation
…l change, and account deletion
…implify setup copy
…earer copy on account page
…of session auth method
…freshness TOTP-aware
…ess — a sole handler is only possible as sole owner, which already blocks
…ocus 2FA code inputs
… during the email-change ceremony, reject blank display names and refresh cached org data after account changes
…el linked identities
…ign-in method cards
…ing only pre-login TOTP verify on REST
…ce the raw reauth code
rohan-chaturvedi
marked this pull request as ready for review
August 24, 2026 09:22
…ard, deletion races, SCIM/TOTP, shared helpers)
…auth error handling
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔍 Overview
Users currently have no way to manage their own account and authentication in Phase. This PR adds a user-level Account page (
/account, outside any org context) that allows:💡 Proposed Changes
New
/accountpage (OnboardingNavbar layout, no keyring unlock required) with aUserMenu → Accountentry. Org settings' "Account" tab is renamed to "Recovery & Devices" (its actual contents) with a cross-link, and?tab=deep links now use stable ids.1. Auth provider linking / unlinking
?intent=linkthrough the existing SSO flows, completed by a dedicated_complete_link()— deliberately separate from the login path's takeover guards. Identities are bound by(provider, uid); emails do not need to match (industry standard — GitHub/Auth0/WorkOS model).state(single round trip), the callback verifies the initiating user, and conflicts (identity already linked to another account) are hard errors — never merges.ENABLE_PASSWORD_AUTHis on), never an identity required by arequire_ssoorg or managed via SCIM.(provider, uid)matches even when their IdP email ≠ Phase email.2. TOTP 2FA
login()is deferred until the code verifies:password_loginreturns{mfaRequired: true}, the SSO callback redirects to/login/mfa; pending state lives in the signed-cookie session (TTL 10 min).ProviderCredentialsprecedent); replay guard via race-free conditional update on the last-used timestep; Redis-backed fail counters + IP throttle (signed cookies are replayable, so no in-cookie counters); 10× Argon2id-hashed single-use recovery codes, masked by default with reveal/copy/download (Done is gated on saving them).UserTOTP+UserRecoveryCode(migration0138, new tables only). New deps:pyotp==2.9.0,qrcode.react(zero transitive deps).3. Change account email
user.emailsalts the device-key KDF, confirm runs an all-org keyring re-wrap ceremony client-side (one new device key covers every org — the salt is account-global). A server-side completeness gate refuses the change unless every keyring-bearing membership was re-wrapped, so a stale client can never brick an org's keyring. Password users also rotate their authHash to the new-email salt.SKIP_EMAIL_VERIFICATION), verification is skipped rather than dead-ending. Security alert goes to the old address on completion.4. Permanent account deletion
user.delete()cascade. Stripe seats update + farewell email post-commit.SecretEvent.useris alreadySET_NULL; a newactorDeletedfield onSecretEventTypedistinguishes deleted accounts from engine events (permission-based nulling made client-side inference impossible), rendered as "Deleted account" in log views.0137fixes fouron_delete=CASCADE→SET_NULLon member-attribution FKs (NetworkAccessPolicy.created_by/updated_by,ServiceToken.created_by,ServiceAccountToken.created_by) — without it, deleting a user destroyed org IP allowlists and live CI tokens. Emits no SQL (Django enforceson_deletein Python); in-transaction FK-nulling kept as defense in depth.Shared foundation
auth_timestamped into the session at everylogin()site; sensitive actions require re-auth within 15 min (AUTH_FRESHNESS_MAX_AGE_SECONDS, default 900). The login page now renders for stale-but-valid sessions arriving with?reauth=1./account.providerMeta.tsextracted fromSignInButtonsas the shared provider icon/name registry.🖼️ Screenshots or Demo
📝 Release Notes
/account) — manage sign-in methods, display name, email address, two-factor authentication, and account deletion.0137is a no-op at the SQL level;0138adds two tables). New optional env varAUTH_FRESHNESS_MAX_AGE_SECONDS(default900). Without SMTP configured, email-change verification is skipped (same convention as password signup). Backend gains thepyotpdependency — rebuild images.❓ Open Questions
image=otpauth param (some apps render it); Authy uses a curated catalog — do we want to apply to Twilio's Authy logo directory?user.password) — a recovery-phrase-based password reset is the natural fast-follow.🧪 Testing
test_account_identities.py(link/unlink/enumeration/freshness),test_mfa.py(crypto round-trip, ±1-window + replay, deferral flows, verify endpoint),test_email_change.py(guards, completeness gate, no-SMTP skip),test_delete_account.py(blockers, lease revocation ordering, cascade semantics),test_update_account_profile.py,test_secret_event_actor.py(tombstone truth table). Existing SSO/auth tests updated for the deferred-login refactor.test_file_read_permission_errorartifact). Frontend: 365 jest tests pass,tscclean (3 known baseline errors only), eslint clean on changed files.require_ssobypass via stale MFA pending state, an AWS-credential leak on deletion via soft-deleted memberships, and a partial-re-wrap keyring brick in the email ceremony. Final security pass: zero findings.🎯 Reviewer Focus
backend/api/views/sso.py— the highest-risk change:login()moved out of_complete_login_bypassing_allauth, the callback now branches login/link/TOTP-deferral, and the org-gate gained the linked-uid fast-pass. The non-enrolled path is intended to be behavior-identical.backend/api/views/auth_mfa.py—mfa_verify(anonymous partial-auth completion) and the pending-session key lifecycle.backend/backend/graphene/mutations/account.py—ConfirmEmailChangeMutation's re-wrap ceremony and completeness gate (the "can this brick a keyring" question), andDeleteAccountMutation's teardown ordering.backend/api/views/identity.py— unlink guard matrix.0137— confirm the no-op-SQL claim (sqlmigrate) and the four FK choices.➕ Additional Context
_complete_login_bypassing_allauth; linking is the opt-in path those comments promised.auth_timefreshness stamp.✨ How to Test the Changes Locally
docker compose -f dev-docker-compose.yml up -dand apply migrations:docker compose -f dev-docker-compose.yml exec backend python manage.py migrate.auth_timeby re-logging), then try any of the above — you'll be bounced to sign in again.💚 Did You...