Skip to main content

accounts app

User accounts, password management, email verification, passkey (WebAuthn) sign-in, and GDPR-compliant data export / account deletion.

Models (8)

  • User — extended Django user with UUID PK, email-as-username, encrypted phone
  • UserEmail — secondary emails for one user; SSO uses these for matching. is_verified is a privilege boundary, not a data-quality flag: a verified row is simultaneously a login identity (MultiEmailBackend.authenticate), a password-reset target (password_reset.initiate_password_reset, reachable from the public unauthenticated endpoint), and a provisioned-account credential destination (organizations/services/credential_delivery.py). Any code path that creates a row with is_verified=True is granting all three at once — see why the admin credential-reset dialog saves unverified. As of v0.73.19 (#912) there is no such path left that an administrator can reach: both surfaces that attach an address to someone else's account — the credential-reset dialog and AdminMemberEmailView.post — go through accounts/email_attachment.attach_unverified_email, which creates the row unverified and mails a confirmation code. Redemption runs through UserEmailViewSet.verify_email, which resolves the token as UserEmail.objects.get(user=request.user, …), so the confirmation cannot be completed by whoever added the address even if they receive the code. email is globally unique, so an unverified row also reserves that address against every other account; UNVERIFIED_EMAIL_LIMIT (accounts/views.py) caps how many one user may hold
  • UserConsent — captures consent template acceptances (privacy / terms / marketing / analytics)
  • DataExport — async data-export job state (status, S3 URL, expires_at)
  • WebAuthnCredential — stored passkey credential (credential_id, public_key, sign_count, friendly name, last_used_at)
  • PasskeyEnrollmentDismissal — tracks "remind me later" for the post-login passkey enrollment prompt
  • LoginEvent — one row per real sign-in (password, Google OAuth, SSO, or passkey — never token refresh or impersonation). No organization FK: a user can hold Memberships in multiple orgs, so attribution to a chapter/org happens at read time via a join to Membership (see apps.analytics.services.adoption_kpi), not at write time. Feeds the org Analytics page's Adoption tab.
  • PageViewEvent — one row per route-change ping (user, module, created_at). Never written directly from the request path — POST /api/auth/activity/page-view/ buffers it to a Redis list (apps.common.activity_buffer), and a Celery beat task flushes the buffer to Postgres every 2 minutes. Pruned after 14 days (weekly Celery beat task) — much shorter retention than LoginEvent, since it's a far higher-volume signal and the Adoption tab's Module Usage / Avg Session Length cards only ever need a recent window.

Key endpoints

URLPurpose
POST /api/auth/register/Self-signup with email + password
POST /api/auth/login/Email + password → sets JWT cookies
POST /api/auth/logout/Clears cookies, blacklists refresh token
GET /api/auth/me/Current user profile
PATCH /api/auth/me/Update first/last name, phone
POST /api/auth/me/password/Change password
POST /api/auth/me/emails/add/Add a secondary email
POST /api/auth/me/emails/verify/Verify a secondary email via token
POST /api/auth/password-reset/request/Request reset email
POST /api/auth/password-reset/confirm/Submit new password with token
POST /api/auth/me/export/Trigger GDPR data export (Celery → S3)
POST /api/auth/me/delete/Request account deletion (30-day grace period)
POST /api/auth/admin/password-reset/Admin-initiated password reset (org admin only)
POST /api/auth/impersonate/Start a read-only "view as member" session (org admin only, same org)
POST /api/auth/impersonate/stop/End a session and restore the acting admin's own tokens
POST /api/auth/passkey/register/begin/Build WebAuthn registration options (signed-in user)
POST /api/auth/passkey/register/complete/Verify attestation, persist WebAuthnCredential
POST /api/auth/passkey/authenticate/begin/Build discoverable WebAuthn assertion options (no auth required)
POST /api/auth/passkey/authenticate/complete/Verify assertion, look up credential, issue JWT
GET /api/auth/passkey/credentials/List the current user's passkeys
PATCH /api/auth/passkey/credentials/<id>/Rename a passkey
DELETE /api/auth/passkey/credentials/<id>/Revoke a passkey
GET /api/auth/passkey/prompt-status/Should the post-login enrollment prompt show?
POST /api/auth/passkey/dismiss-prompt/"Remind me later" — sets 30-day cooldown
POST /api/auth/activity/page-view/Fire-and-forget route-change ping (buffered to Redis, not written synchronously) — feeds the Adoption tab's Module Usage / Avg Session Length cards

Permissions

  • IsAuthenticated — most endpoints
  • AllowAny — register, login, password reset request (rate-throttled)
  • IsNationalAdmin — admin password reset

Background tasks

  • send_email_verification(user_email_id) — sends verification email
  • send_temporary_password_notification(user_id, temp_password) — admin reset flow
  • export_user_data(data_export_id) — bundles all user data, gzips, uploads to S3, generates presigned URL valid for 7 days
  • send_impersonation_notice(user_id, actor_id, org_id, reason) — tells a member an org admin started a read-only view-as session, gated on Organization.notify_member_on_impersonation. Names the admin outright rather than using the non-identifying role label the password-reset notice uses: a reset is an administrative act on an account, this is a person looking through someone else's eyes.
  • flush_page_view_buffer() — Celery beat, every 2 minutes. Drains the Redis-buffered page-view pings and bulk-inserts them as PageViewEvent rows; drops (and logs) events for an invalid module or a user deleted since the ping fired, rather than failing the whole batch.
  • prune_page_view_events() — Celery beat, weekly. Deletes PageViewEvent rows older than 14 days.

External integrations

  • AWS S3 (data export storage via boto3)
  • Email backend (configured at apps.platform.PlatformEmailConfig)

Signals

  • post_save on User — auto-creates UserEmail row from primary email

Notable patterns

Account deletion grace period

POST /api/auth/me/delete/ doesn't immediately delete. It:

  1. Sets User.deletion_requested_at = now()
  2. Sends confirmation email with a "cancel" link
  3. After 30 days, prune_pending_deletions task scrubs the account

Cancelling: signing in within 30 days clears deletion_requested_at.

View-as sessions (#853)

impersonation.py holds the authorization rules and token mechanics; impersonation_views.py is the HTTP surface. The session is a real JWT for the target carrying imp_by / imp_org / imp_exp, resolved and re-validated on every request in CookieJWTAuthentication._resolve_impersonation.

Two things about that method are load-bearing:

  • It takes the resolved user as an argument rather than reading request.user. On a DRF Request, touching .user runs authentication — and this is authentication, so reading it recurses until the stack blows.
  • It exempts /api/auth/token/refresh/. DRF authenticates every view before dispatch, including AllowAny ones, so rejecting an expired session there would 401 the one request able to recover it.

See Auth & permissions → Impersonation for the full model, including why read-only has to be enforced separately at the WebSocket layer.

Password hashing

Production uses Django's default PBKDF2 with 870K iterations. Tests override to MD5 via PASSWORD_HASHERS to keep tests fast.

Throttling

EndpointThrottle
register/account_request (30/hour prod) + account_request_email (2/hour per email)
login/auth (60/min prod)
token/refresh/token_refresh (120/min prod)
password-reset/request/password_reset (20/hour prod) + password_reset_email (3/hour per email)
password-reset/verify/, password-reset/confirm/password_reset only — these carry a signed token, not an email
admin/password-reset/admin_password_reset_target (10/hour prod)
passkey/*/begin/passkey_begin (60/min prod)
passkey/*/complete/passkey_complete (30/min prod)

Rates rose in v0.71.13 because the per-IP keying was corrected: a bucket is now one shared network rather than one caller, so the previous numbers would have locked out a whole chapter house. token/refresh/ also moved out of the auth scope, since a machine-driven retry loop was spending the budget interactive sign-in needs.

Code paths

  • Models: backend/apps/accounts/models.py
  • Views: backend/apps/accounts/views.py, backend/apps/accounts/webauthn_views.py
  • Serializers: backend/apps/accounts/serializers.py
  • URLs: backend/apps/accounts/urls.py
  • Tasks: backend/apps/accounts/tasks.py
  • Tests: backend/apps/accounts/tests/ + backend/apps/accounts/tests_webauthn.py