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 phoneUserEmail— secondary emails for one user; SSO uses these for matching.is_verifiedis 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 withis_verified=Trueis 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 andAdminMemberEmailView.post— go throughaccounts/email_attachment.attach_unverified_email, which creates the row unverified and mails a confirmation code. Redemption runs throughUserEmailViewSet.verify_email, which resolves the token asUserEmail.objects.get(user=request.user, …), so the confirmation cannot be completed by whoever added the address even if they receive the code.emailis 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 holdUserConsent— 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 promptLoginEvent— one row per real sign-in (password, Google OAuth, SSO, or passkey — never token refresh or impersonation). NoorganizationFK: a user can hold Memberships in multiple orgs, so attribution to a chapter/org happens at read time via a join toMembership(seeapps.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 thanLoginEvent, 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
| URL | Purpose |
|---|---|
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 endpointsAllowAny— register, login, password reset request (rate-throttled)IsNationalAdmin— admin password reset
Background tasks
send_email_verification(user_email_id)— sends verification emailsend_temporary_password_notification(user_id, temp_password)— admin reset flowexport_user_data(data_export_id)— bundles all user data, gzips, uploads to S3, generates presigned URL valid for 7 dayssend_impersonation_notice(user_id, actor_id, org_id, reason)— tells a member an org admin started a read-only view-as session, gated onOrganization.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 asPageViewEventrows; 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. DeletesPageViewEventrows older than 14 days.
External integrations
- AWS S3 (data export storage via boto3)
- Email backend (configured at
apps.platform.PlatformEmailConfig)
Signals
post_saveonUser— auto-createsUserEmailrow from primary email
Notable patterns
Account deletion grace period
POST /api/auth/me/delete/ doesn't immediately delete. It:
- Sets
User.deletion_requested_at = now() - Sends confirmation email with a "cancel" link
- After 30 days,
prune_pending_deletionstask 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
useras an argument rather than readingrequest.user. On a DRFRequest, touching.userruns 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, includingAllowAnyones, 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
| Endpoint | Throttle |
|---|---|
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