Skip to content

feat(2fa): implement two-factor authentication - #3207

Open
FrankApiyo wants to merge 20 commits into
mainfrom
feat/django-two-factor-auth
Open

feat(2fa): implement two-factor authentication#3207
FrankApiyo wants to merge 20 commits into
mainfrom
feat/django-two-factor-auth

Conversation

@FrankApiyo

@FrankApiyo FrankApiyo commented Aug 10, 2026

Copy link
Copy Markdown
Member

Changes / Features implemented

Two-factor authentication moves into onadata. Login runs through the wizard at /account/login/ for everyone, and /accounts/login/ redirects to it rather than rendering a password-only form beside it. Enrolment stays off until a deployment sets ENABLE_TWO_FACTOR.

  • /api/v1/totp/: status, enroll/start, enroll/confirm, disable, verify, recovery/generate, recovery. Anything that weakens or replaces a factor needs a current code, or a grant verify minted for that one operation. Responses carry Cache-Control: no-store.
  • First enrolment has no factor to challenge, so it asks for the account password instead — behind TWO_FACTOR_ENROLMENT_REQUIRES_PASSWORD, off by default because a deployment whose users authenticate elsewhere may hold hashes nobody knows.
  • The failed-login lockout now covers both wizard steps. Only the credentials step counted before, leaving whoever held the password an unbounded run at a six-digit code.
  • Only login is served from the two-factor URLConf. Why the library's own pages are not is in two_factor_urls.py.

Also: is_owner resolves LOGIN_URL, which is a URL name now; django-two-factor-auth, django-otp and qrcode become declared dependencies rather than deployment-only ones; pyproject.toml stops ruff and isort reordering imports against each other; and two existing tests stop hard-coding /accounts/login.

Steps taken to verify this change does what is intended

Tests are driven through the wizard's own POSTs rather than against the forms, with codes from a real HOTP implementation — patching verify_token would pass against a device no authenticator app agrees with. Code verification is covered by TransactionTestCase concurrency tests, since the race they guard needs committed data on a second connection.

Exercised by hand against a running stack: enrol, sign in with a code, and sign in as a user with no factor.

Side effects of implementing this change

  • An account with no second factor sees a one-step password form, so nothing changes for a user until they enrol.
  • One-time codes are spent under a row lock. Without it, workers being separate processes meant a single-use recovery code was honoured once per racing caller.
  • Enabling django.contrib.admin is safe: the library redirects /admin/login/ to LOGIN_URL. Reaching /admin/ still needs only staff permission, so a staff user who has not enrolled gets in on a password alone.
  • feat(2fa): implement step-up authentication #3215 is stacked on this branch.

Before submitting this PR for review, please make sure you have:

  • Included tests
  • Updated documentation

Closes #

@FrankApiyo
FrankApiyo force-pushed the feat/django-two-factor-auth branch from 820f5f4 to 47ec239 Compare August 11, 2026 07:34
@FrankApiyo
FrankApiyo force-pushed the feat/django-two-factor-auth branch from 6fa06dc to 47ec239 Compare August 11, 2026 09:15
@FrankApiyo
FrankApiyo force-pushed the feat/django-two-factor-auth branch 3 times, most recently from 808a0ff to 53f57b8 Compare August 14, 2026 14:48
@FrankApiyo
FrankApiyo marked this pull request as ready for review August 17, 2026 12:04
@FrankApiyo
FrankApiyo force-pushed the feat/django-two-factor-auth branch 3 times, most recently from 7532dd9 to d29ef14 Compare August 19, 2026 11:10
@FrankApiyo FrankApiyo changed the title feat(2fa): groundwork for django-two-factor-auth (gate off) feat(2fa): move two-factor into onadata behind ENABLE_TWO_FACTOR Aug 19, 2026
@FrankApiyo
FrankApiyo force-pushed the feat/django-two-factor-auth branch 2 times, most recently from 5a1adfb to 4042f52 Compare August 19, 2026 13:11
@FrankApiyo FrankApiyo changed the title feat(2fa): move two-factor into onadata behind ENABLE_TWO_FACTOR feat(2fa): move login onto the two-factor wizard, enrolment gated off Aug 19, 2026
@FrankApiyo FrankApiyo changed the title feat(2fa): move login onto the two-factor wizard, enrolment gated off feat(2fa): implement two-factor authentication Aug 19, 2026
@FrankApiyo
FrankApiyo force-pushed the feat/django-two-factor-auth branch 2 times, most recently from 9cd079d to 7a0b620 Compare August 20, 2026 10:00
@FrankApiyo
FrankApiyo force-pushed the feat/django-two-factor-auth branch from 7a0b620 to 46c4cba Compare August 20, 2026 12:22
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.

1 participant