Skip to content

1416 add support for mfa for tom user logins - #1653

Open
phycodurus wants to merge 31 commits into
devfrom
1416-add-support-for-mfa-for-tom-user-logins
Open

1416 add support for mfa for tom user logins#1653
phycodurus wants to merge 31 commits into
devfrom
1416-add-support-for-mfa-for-tom-user-logins

Conversation

@phycodurus

Copy link
Copy Markdown
Member

Suggested reviewing path:

  1. make and read the documentation
  2. try some of the configurations below.
  3. delve deeper as you wish

==========================================

Do this / See that

Each part adds settings on top of the previous part's.

Prerequisites

  • A scratch TOM running this branch (editable install), migrated (python manage.py migrate),
    runserver up (steps below say :8274 — substitute your port).
  • In settings.py: EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
    and MANAGERS = [('You', 'you@example.com')].
  • Your admin superuser (password known). An authenticator app. A second browser or
    incognito window ("window B"). curl.

Part 0 — baseline (no TOM_* settings configured)

  • Anonymous: browse Home, Targets → pages load (READ_ONLY).
  • /accounts/login/ → styled login page; no Register button, no "sign up" line, no "Forgot your password?".
  • /accounts/signup/ → "Sign Up Closed" page, tells you to contact the administrators.
  • /admin/login/ → redirected to the TOM login page.
  • /api-auth/login/ → redirected to the TOM login page.
  • Log in as admin (password only) → navbar shows your name + Logout.
  • Profile → Security card: "Not enabled" badge + Enable two-factor authentication button.
  • Users page → 2FA column (blank rows), no requirement columns, no Pending Users, no AnonymousUser row.

Part 1 — enroll and use 2FA (still no settings)

  • Security card → Enable → scan QR with your app → enter code → recovery codes shown once
    (no Download button under the default show-once policy; clicking the codes copies them).
  • With "I have saved my recovery codes" unchecked, click a navbar link → dialog: "Leave this page
    without confirming that you have saved your recovery codes?…". Cancel stays; checking the box
    then leaving asks nothing. (Closing the tab instead shows the browser's own generic warning.)
  • /accounts/2fa/ → cards: Authenticator App "active" (Deactivate button), Recovery Codes "10 of 10".
  • Log out; log in → password page, then code page → in.
  • Log out; log in using one recovery code instead of an app code → in; /accounts/2fa/ now says "9 of 10".
  • Users page → your 2FA column says yes.
  • Users page → create user tester (with an email address; no 2FA yet).

Part 2 — require 2FA

TOM_MFA_REQUIRED = 'all'
  • ⟳ Window B: log in as tester → any page redirects to the enrolment page, message says 2FA is required.
  • While bounced: Logout still works; log back in.
  • Enrol tester → pages open normally.
  • Tester's Security card → no Disable; text says required + contact the administrators to reset.
  • Tester opens /accounts/2fa/totp/deactivate/ → no Deactivate button; explanation + "Back to two-factor settings" link.
  • Users page (admin) → new 2FA required column, both rows yes.
  • Users page as tester (window B) → no Email column (superuser-only).

Part 3 — terms of service

TOM_TERMS_OF_SERVICE_VERSION = 'v1'
  • ⟳ As admin: next click → redirected to /terms/accept/, message says accept to continue; placeholder text tells the operator where to write terms.
  • Create templates/tom_common/partials/terms_of_service_text.html in the TOM with your terms → accept page shows them.
  • Accept → success message, returned to the page you were heading for; not asked again.
  • /terms/ in a logged-out window → public read-only terms page.
  • Users page → Terms accepted (v1) column (header names the version): admin yes, tester no
    (until tester accepts in window B).
  • Change the setting to 'v2' ⟳ → prompted again; accept; column header now says (v2).
  • /admin/ → Terms of service acceptances: rows for v1 and v2 with timestamp and IP; read-only.

Part 4 — password rules and expiry

TOM_PASSWORD_EXPIRY_DAYS = 60
AUTH_PASSWORD_VALIDATORS = [
    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 12}},
    {'NAME': 'tom_common.accounts.password_validation.CharacterClassValidator'},
    {'NAME': 'tom_common.accounts.password_validation.NotSameAsCurrentPasswordValidator'},
]
  • ⟳ Next click as admin → redirected to change-password, message says the password expired.
  • Enter a wrong current password → rejected: "That is not your current password, so nothing was changed…".
  • Enter your current password as the new one → rejected: "same as your current password".
  • Enter alllowercase → rejected, missing classes listed by name.
  • Enter a valid new password → returned to the page you were heading for; not re-prompted.
  • Users page → Password current column: admin yes, tester no (tester repeats in window B).
  • Admin: Users → Change tester's password → tester's next request (window B) → forced to change it.

Part 5 — required profile fields

TOM_REQUIRED_USER_FIELDS = ['first_name', 'last_name', 'email', 'affiliation', 'phone_number']
  • ⟳ Window B (tester, fields empty): next click → redirected to their edit page, message says complete details.
  • Save with fields still blank → "This field is required" errors; fill all → freed.
  • Users page → Profile complete column: tester yes, admin per your own fields.

Part 6 — API tokens

TOM_API_TOKEN_EXPIRY_DAYS = 60
  • ⟳ Your edit page → API Token shows Created and expires dates.
  • curl -H "Authorization: Token <your-token>" http://localhost:8274/api/targets/ → 200 JSON.
TOM_API_TOKEN_REQUIRES_MFA = True
  • ⟳ Same curl → 401, message: token predates your two-factor enrolment.
  • Edit page → Regenerate → first sent to the confirm-identity page (password or code) → then new token.
  • curl with the new token → 200.
  • curl --request POST http://localhost:8274/api/token-auth/ -H "Content-Type: application/json" -d '{"username":"admin","password":"<pw>"}' → 403, message points at the profile page.
  • Admin opens tester's edit page → Regenerate → refused with message; tester's token unchanged.

Part 7 — open registration

TOM_REGISTRATION_STRATEGY = 'open'
  • ⟳ Window B, logged out: navbar shows Register; login page shows the "sign up" line.
  • Register newuser: form shows username/email/password/name/organization/phone + terms checkbox;
    the five required fields are marked; "terms of service" in the checkbox label links to /terms/;
    submitting without the checkbox → error.
  • Complete it → logged in immediately → bounced straight to 2FA enrolment (Part 2 still active) → enrol → in.
  • No terms prompt and no fields prompt for newuser (satisfied at sign-up).
  • Users page → newuser present; admin /admin/ → newuser is in the Public group.

Part 8 — approval-required registration

TOM_REGISTRATION_STRATEGY = 'approval_required'
  • ⟳ Window B: register applicantnot logged in; runserver console prints the awaiting-approval mail to MANAGERS.
  • Window B: try to log in as applicant → "Account Pending Approval" page.
  • Admin: Users page → Pending Users table lists applicant → Approve → success message; console prints the approved mail with a login link.
  • Pending Users table gone; applicant row in Active Users.
  • Window B: applicant logs in → walked through enrolment → in.
  • Optional (no-email degradation): set EMAIL_HOST = 'localhost' with the SMTP backend ⟳ →
    Pending users table warns email is not configured; approving warns you to tell the user directly;
    manage.py check reports tom_common.W002. Restore the console backend ⟳ afterwards.

Part 9 — LOCKED + password reset

AUTH_STRATEGY = 'LOCKED'
OPEN_URLS = []
TOM_PASSWORD_RESET_ENABLED = True
  • ⟳ Logged-out window: Home → bounced to login. Login, Register, pending page all reachable.
  • Login page now shows "Forgot your password?".
  • Request a reset for tester's email → console prints the mail → follow the link → set a new password (validators apply) → log in with it (plus code).
  • Full password+code login works under LOCKED.

Part 10 — security log (optional)

LOGGING = {'version': 1, 'disable_existing_loggers': False,
           'handlers': {'console': {'class': 'logging.StreamHandler'}},
           'loggers': {'tom_common.security': {'handlers': ['console'], 'level': 'INFO'}}}
  • ⟳ Log out and in (one wrong password first) → console lines: Login failed / Login succeeded / Logout.
  • Regenerate your token, accept a bumped terms version → one line each.

Wrap up

  • python manage.py check → no warnings (tom_common.W001/W002 would name any
    token-authentication or email misconfiguration).
  • Cross-check anything surprising against docs/common/authentication.rst — the docs are the acceptance criteria; a mismatch is a bug in one of them.

These docs describe the result django-allauth integration
which will be TOM Toolkit 3.1.0
django-allauth now answers every /accounts/ page replacing
Django's plain LoginView/LogoutView.
This commit deserves a thorough explanation (see also the docstring
in tom_common.adapters): tom_common.adapters is the "customisation
seam" that the docs refer to: a TOM changes account behavior by
subclassing these adapters and re-pointing the configuration variables
like ACCOUNT_ADAPTER / MFA_ADAPTER from settings.py (for example,
re-pointing them to subclasses in `custom_code`).

- TomAccountAdapter.is_open_for_signup: allauth's default is OPEN
  self-registration, which the URL cutover briefly inherited; signup is
  now closed unless TOM_REGISTRATION_STRATEGY says otherwise (the
  strategies themselves land with the registration work).

- TomMFAAdapter: TOTP secrets and recovery-code seeds are stored
  encrypted with the SECRET_KEY-derived cipher rather than allauth's
  plaintext-in-JSON default, so authenticator data participates in the
  same key-rotation story as every EncryptedModelField; the
  issuer (the name that appears in authenticator apps) is TOM_NAME;
  and can_delete_authenticator enforces the TOM_MFA_REQUIRED policy
  (users under the policy cannot remove their second factor —
  an administrator still can, in the admin).

- rotate_encryption_key also re-encrypts Authenticator JSON data
  ('secret', 'seed', 'migrated_codes'), through the adapter so a TOM
  with a custom MFA_ADAPTER rotates by its own scheme.
all these template overrides are about getting the django-allauth
templates to look like TOM Toolkit.

Some specific details worth mentioning:
- tom_common/base.html: the new `content_container` block nesting the
existing content block distinguished the TOM Toolkit content block
from the allauth block that is also named 'content'. A layout that
wants to wrap page content needs a block of its own and any plugin
layout can use it the same way.
Previously, under AUTH_STRATEGY='LOCKED' the middleware admitted
anonymous users only to the login path and OPEN_URLS. This adds
the necessary django-allauth URL names to the list. These are the
pages that the anonymous users must visit to become not anonymous.
When htmx receives a 302 it follows the redirect and swaps the
destination page into the requesting fragment — so any authentication
redirect fired during an HTMX action (session expiry, the 300 s
reauthentication window, a LOCKED bounce) rendered the login page
inside a table cell (bad). HTMXRedirectMiddleware rewrites such
responses to HX-Redirect, causing HTMX to navigate the whole browser
window (good).
The alternative was having this stuff scattered in modules all
over tom_common. Now it's in one place. (middleware and signals
remain in those modules).
settings introduced: (TOM_MFA_REQUIRED,  TOM_PASSWORD_EXPIRY_DAYS,
and TOM_REQUIRED_USER_FIELDS). AccountRequirementsMiddleware runs
checks for every logged-in request and redirects to the first unmet
check's page, with a message naming what to do there.

Checks implemented in accounts/requirements.py -- see docstring.
Setting a version string is the signal that invokes the check
and makes every user accept the terms before using the TOM.
Allows Administrators to see the requirement status of each user.
Also: guardian's AnonymousUser is excluded from the user list. It is a
permissions sentinel from django-guardian, not a person; filtered
on guardian's ANONYMOUS_USER_NAME setting.
Events and where they are caught:
- login success/failure/logout: Django's auth signals.
- two-factor authenticator added/removed/reset: allauth's mfa signals.
- API-token regeneration: in the view, recording both the token's owner
  and the acting user (they differ when a superuser regenerates).
This includes the DRF API in the configured authentication requirements.
TomTokenAuthentication replaces the REST framework's TokenAuthentication
in REST_FRAMEWORK. With neither TOM_API_TOKEN_REQUIRES_MFA nor
TOM_API_TOKEN_EXPRIRY_DAYS setting configured, it
behaves identically. So, existing TOMs see no change.
With TOM_API_TOKEN_REQUIRES_MFA a token can now only come into
existence through a channel protected by the second factor.

- api/token-auth/ is served by TomObtainAuthToken, which refuses (403,
  pointing at the profile page) under the flag.
- Token regeneration under the flag is owner-only and requires recent
  reauthentication via allauth's page.
- Raise403Middleware no longer converts /api/ 403s into browser login
  redirects so scripts can see the 403 JSON response.
- The token partial shows created/expires dates (token_expiry_date
  helper)
- The REST API docs name TomTokenAuthentication and the
  TOM_API_TOKEN_* settings.
The administrator's half of approval_required registration.
@phycodurus
phycodurus requested review from Fingel and jchate6 August 28, 2026 01:12
@phycodurus phycodurus linked an issue Aug 28, 2026 that may be closed by this pull request
4 tasks
@phycodurus

Copy link
Copy Markdown
Member Author

@rachel3834 The checklist above represents a reasonable tour of the registration/authorization configuration options.

@jchate6 jchate6 moved this to Needs Review in TOM Toolkit Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

Add support for MFA for TOM user logins

2 participants