Skip to content

feat: user account page: auth provider linking, TOTP 2FA, user profile management - #983

Open
rohan-chaturvedi wants to merge 37 commits into
mainfrom
feat--user-auth-management
Open

feat: user account page: auth provider linking, TOTP 2FA, user profile management#983
rohan-chaturvedi wants to merge 37 commits into
mainfrom
feat--user-auth-management

Conversation

@rohan-chaturvedi

@rohan-chaturvedi rohan-chaturvedi commented Aug 20, 2026

Copy link
Copy Markdown
Member

🔍 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:

  • linking of additional auth providers
  • enabling TOTP 2FA
  • permanently deleting an account
  • updating account name and email

💡 Proposed Changes

New /account page (OnboardingNavbar layout, no keyring unlock required) with a UserMenu → Account entry. 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

  • Link additional OAuth/OIDC identities (instance-level and org-level SSO providers) from an authenticated session via ?intent=link through 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).
  • The link marker is bound to its OAuth state (single round trip), the callback verifies the initiating user, and conflicts (identity already linked to another account) are hard errors — never merges.
  • Unlink guards: never the last sign-in method (password counts only when ENABLE_PASSWORD_AUTH is on), never an identity required by a require_sso org or managed via SCIM.
  • The org-SSO login gate now passes members whose linked (provider, uid) matches even when their IdP email ≠ Phase email.
  • Org-scoped audit events on link/unlink let admins track SSO-migration progress; notification emails on both.

2. TOTP 2FA

  • Full enroll → verify → recovery-codes flow; challenges all login methods (password and SSO, including org SSO) once enrolled.
  • login() is deferred until the code verifies: password_login returns {mfaRequired: true}, the SSO callback redirects to /login/mfa; pending state lives in the signed-cookie session (TTL 10 min).
  • Seeds encrypted at rest with the server keypair (ProviderCredentials precedent); 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).
  • New models UserTOTP + UserRecoveryCode (migration 0138, new tables only). New deps: pyotp==2.9.0, qrcode.react (zero transitive deps).

3. Change account email

  • Two-step: verification code to the new address → confirm with current password (or sudo password for SSO-only users). Any address is allowed, ownership-verified — same freedom for OAuth-only and password accounts.
  • Because user.email salts 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.
  • Follows the password-signup no-SMTP convention: without an email gateway (or with SKIP_EMAIL_VERIFICATION), verification is skipped rather than dead-ending. Security alert goes to the old address on completion.
  • Blocked for SCIM-managed users. Stripe billing email is deliberately untouched.

4. Permanent account deletion

  • Readiness gate: blocked while sole Owner of any org (GitHub precedent; remediation = existing ownership transfer) or SCIM-managed; warns when sole handler of client-side-key service accounts.
  • Ordered teardown: synchronous AWS dynamic-lease revocation before the cascade (the scheduled jobs re-fetch rows by id and would otherwise leak IAM users), audit tombstones per org, then hard user.delete() cascade. Stripe seats update + farewell email post-commit.
  • Audit anonymization, not destruction: SecretEvent.user is already SET_NULL; a new actorDeleted field on SecretEventType distinguishes deleted accounts from engine events (permission-based nulling made client-side inference impossible), rendered as "Deleted account" in log views.
  • Migration 0137 fixes four on_delete=CASCADESET_NULL on 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 enforces on_delete in Python); in-transaction FK-nulling kept as defense in depth.

Shared foundation

  • Fresh-session re-auth gate: auth_time stamped into the session at every login() 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.
  • Editable display name (takes precedence over provider-reported names, survives unlinking); account-security emails greet by name and link to /account.
  • providerMeta.ts extracted from SignInButtons as the shared provider icon/name registry.

🖼️ Screenshots or Demo

Screenshot From 2026-08-25 19-15-23

📝 Release Notes

  • New: Account settings page (/account) — manage sign-in methods, display name, email address, two-factor authentication, and account deletion.
  • New: Link additional sign-in providers (e.g. add Microsoft Entra ID SSO to a Google-created account) — unblocks migrating an org to enforced SSO without losing existing accounts.
  • New: TOTP two-factor authentication for all sign-in methods, with single-use recovery codes.
  • New: Change your account email (any address, ownership-verified) and permanently delete your account.
  • Self-hosters: run migrations (0137 is a no-op at the SQL level; 0138 adds two tables). New optional env var AUTH_FRESHNESS_MAX_AGE_SECONDS (default 900). Without SMTP configured, email-change verification is skipped (same convention as password signup). Backend gains the pyotp dependency — rebuild images.
  • Breaking: none. Non-enrolled login flows are byte-identical; existing sessions are treated as "stale" for the new sensitive actions until the next sign-in (by design).

❓ Open Questions

  • Authenticator-app logos: we emit the non-standard image= otpauth param (some apps render it); Authy uses a curated catalog — do we want to apply to Twilio's Authy logo directory?
  • Password-only accounts have no self-serve login recovery if the password is forgotten (recovery phrase covers crypto, not user.password) — a recovery-phrase-based password reset is the natural fast-follow.

🧪 Testing

  • ~115 new backend tests across 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.
  • Full suite: 1384 passed (the one failure is the pre-existing root-container test_file_read_permission_error artifact). Frontend: 365 jest tests pass, tsc clean (3 known baseline errors only), eslint clean on changed files.
  • Three adversarial multi-agent review rounds over the branch (correctness, security, login-regression, crypto-brick dimensions): 22 confirmed findings all fixed — including a require_sso bypass 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.
  • Manually tested on a local dev stack: enrollment with a real authenticator app, link/unlink across providers, email change (skip path), account deletion. Gaps: org-level SSO linking against a live Entra/Okta tenant, and cloud Stripe paths (seat updates) — recommend a staging pass.

🎯 Reviewer Focus

  1. 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.
  2. backend/api/views/auth_mfa.pymfa_verify (anonymous partial-auth completion) and the pending-session key lifecycle.
  3. backend/backend/graphene/mutations/account.pyConfirmEmailChangeMutation's re-wrap ceremony and completeness gate (the "can this brick a keyring" question), and DeleteAccountMutation's teardown ordering.
  4. backend/api/views/identity.py — unlink guard matrix.
  5. Migration 0137 — confirm the no-op-SQL claim (sqlmigrate) and the four FK choices.

➕ Additional Context

  • The takeover guard this builds on (and its rationale) lives in the comments of _complete_login_bypassing_allauth; linking is the opt-in path those comments promised.
  • Session model constraint driving several designs: signed-cookie sessions are stateless (no server-side revocation), hence Redis fail counters, session-embedded pending state, and the auth_time freshness stamp.

✨ How to Test the Changes Locally

  1. docker compose -f dev-docker-compose.yml up -d and apply migrations: docker compose -f dev-docker-compose.yml exec backend python manage.py migrate.
  2. Sign in, open User menu → Account.
  3. Linking: click a provider under "Link a sign-in method", complete OAuth; sign out and back in with the linked provider — same account. Try unlinking your only method (blocked).
  4. 2FA: Enable 2FA, scan with any authenticator app, verify; sign out → password/SSO login now challenges for a code; test a recovery code and the lockout (10 wrong codes).
  5. Email change: without SMTP configured the code step is skipped (you'll be told why); enter your password/sudo password — keyrings re-wrap across all your orgs; confirm secrets still decrypt after re-login.
  6. Deletion: as a sole org owner it's blocked (transfer ownership first); after transfer, type-your-email-to-confirm deletes the account; check org secret logs show "Deleted account".
  7. Re-auth gate: wait >15 min (or clear auth_time by re-logging), then try any of the above — you'll be bounced to sign in again.

💚 Did You...

  • Ensure linting passes (code style checks)?
  • Update dependencies and lockfiles (if required)
  • Update migrations (if required)
  • Regenerate graphql schema and types (if required)
  • Verify the app builds locally?
  • Manually test the changes on different browsers/devices?

Comment thread backend/api/views/sso.py Dismissed
@rohan-chaturvedi
rohan-chaturvedi marked this pull request as ready for review August 24, 2026 09:22
Comment thread backend/api/views/sso.py Dismissed
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