API Token
+
{% if drf_api_token %}
{{ drf_api_token }}
+
+ Created {{ drf_api_token.created|date }}{% if drf_api_token_expires %} — expires {{ drf_api_token_expires|date }}{% endif %}
+
{% else %}
No token generated.
{% endif %}
+
{% else %}
+{% registration_is_open as show_register_button %}
+{% if show_register_button %}
+
+ Register
+
+{% endif %}
Login
diff --git a/tom_common/templates/tom_common/partials/security_card.html b/tom_common/templates/tom_common/partials/security_card.html
new file mode 100644
index 000000000..63712c049
--- /dev/null
+++ b/tom_common/templates/tom_common/partials/security_card.html
@@ -0,0 +1,26 @@
+
+
+
+
Two-factor authentication
+ {% if mfa_enabled %}
+
+ Enabled
+ Logging in asks for a code from your authenticator app.
+
+
Manage two-factor authentication
+ {% if not can_disable %}
+
+ Two-factor authentication is required for your account, so it cannot be disabled here.
+ If you lose your authenticator app and recovery codes, contact the administrators of this
+ TOM — they can remove the authenticator so that you can enrol again.
+
+ {% endif %}
+ {% else %}
+
+ Not enabled
+ Add a second factor to protect your account.
+
+
Enable two-factor authentication
+ {% endif %}
+
+
diff --git a/tom_common/templates/tom_common/partials/terms_of_service_text.html b/tom_common/templates/tom_common/partials/terms_of_service_text.html
new file mode 100644
index 000000000..9622e9cdb
--- /dev/null
+++ b/tom_common/templates/tom_common/partials/terms_of_service_text.html
@@ -0,0 +1,9 @@
+{% comment %}
+The terms of service for this TOM, as plain HTML. A TOM publishes its terms by copying this
+file to its own templates/tom_common/partials/ directory and writing them here.
+{% endcomment %}
+
+ This TOM has not published its terms of service yet. If you are this TOM's operator: write
+ your terms in templates/tom_common/partials/terms_of_service_text.html in your
+ project — this placeholder is what your users currently see.
+
diff --git a/tom_common/templates/tom_common/terms_accept.html b/tom_common/templates/tom_common/terms_accept.html
new file mode 100644
index 000000000..128c848b7
--- /dev/null
+++ b/tom_common/templates/tom_common/terms_accept.html
@@ -0,0 +1,15 @@
+{% extends 'tom_common/base.html' %}
+{% block title %}Accept the Terms of Service{% endblock %}
+{% block content %}
+
+
+
Terms of Service
+ {% include 'tom_common/partials/terms_of_service_text.html' %}
+
+
+
+{% endblock %}
diff --git a/tom_common/templates/tom_common/terms_of_service.html b/tom_common/templates/tom_common/terms_of_service.html
new file mode 100644
index 000000000..68eb40827
--- /dev/null
+++ b/tom_common/templates/tom_common/terms_of_service.html
@@ -0,0 +1,10 @@
+{% extends 'tom_common/base.html' %}
+{% block title %}Terms of Service{% endblock %}
+{% block content %}
+
+
+
Terms of Service
+ {% include 'tom_common/partials/terms_of_service_text.html' %}
+
+
+{% endblock %}
diff --git a/tom_common/templatetags/user_extras.py b/tom_common/templatetags/user_extras.py
index 7b2ff5f60..d6358f8f3 100644
--- a/tom_common/templatetags/user_extras.py
+++ b/tom_common/templatetags/user_extras.py
@@ -1,6 +1,16 @@
import logging
+
+from allauth.account.adapter import get_adapter as get_account_adapter
+from allauth.mfa.adapter import get_adapter as get_mfa_adapter
+from allauth.mfa.models import Authenticator
+from guardian.conf import settings as guardian_settings
+
from django import template
+from django.conf import settings
from django.contrib.auth.models import Group, User
+
+from tom_common.accounts.email import email_is_configured
+from tom_common.accounts.requirements import AccountRequirement
from django.forms.models import model_to_dict
from django.apps import apps
from django.utils.module_loading import import_string
@@ -24,10 +34,30 @@ def group_list(context):
def user_list(context):
"""
Renders the list of users in the TOM along with edit/delete/change password buttons, as well as an Add User button.
+
+ Each configured account requirement contributes a column (label + the set of pks
+ meeting it) so administrators can see who is not yet compliant.
"""
+ # guardian's anonymous user is a permissions sentinel, not a person: requirements are
+ # inapplicable to it (it never logs in), so it belongs in no list of users
+ users = list(User.objects.select_related('profile')
+ .exclude(username=guardian_settings.ANONYMOUS_USER_NAME))
+ requirement_columns = []
+ for dotted_path in getattr(settings, 'TOM_ACCOUNT_REQUIREMENTS', []):
+ requirement = import_string(dotted_path)
+ if isinstance(requirement, AccountRequirement) and requirement.is_configured():
+ requirement_columns.append({
+ 'label': requirement.column_label(),
+ 'met_pks': requirement.met_user_pks(users),
+ })
return {
'request': context['request'],
- 'users': User.objects.all()
+ 'users': users,
+ # users with an authenticator app enrolled, for the two-factor column
+ 'mfa_user_ids': set(
+ Authenticator.objects.filter(type=Authenticator.Type.TOTP).values_list('user_id', flat=True)
+ ),
+ 'requirement_columns': requirement_columns,
}
@@ -66,6 +96,53 @@ def include_app_user_lists(context):
return context
+@register.simple_tag(takes_context=True)
+def mfa_can_be_disabled(context):
+ """Whether the current user may disable their own authenticator app (the MFA adapter decides)."""
+ user = context['request'].user
+ authenticator = Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).first()
+ return authenticator is None or get_mfa_adapter().can_delete_authenticator(authenticator)
+
+
+@register.simple_tag(takes_context=True)
+def registration_is_open(context):
+ """Whether self-registration is currently open; the account adapter decides.
+
+ Asking the adapter (rather than reading TOM_REGISTRATION_STRATEGY directly) keeps the
+ Register button honest for TOMs that override is_open_for_signup in a custom adapter.
+ """
+ return get_account_adapter().is_open_for_signup(context['request'])
+
+
+@register.inclusion_tag('auth/partials/pending_users.html', takes_context=True)
+def pending_users_list(context):
+ """The registrations awaiting approval, for the Pending users table on the Users page.
+
+ "Pending" is literally ``is_active=False`` (tom_registration's convention), so an
+ account an administrator deactivated by hand appears here too.
+ """
+ request = context['request']
+ if not request.user.is_superuser:
+ return {'request': request, 'pending_users': User.objects.none()}
+ return {
+ 'request': request,
+ 'pending_users': User.objects.filter(is_active=False)
+ .exclude(username=guardian_settings.ANONYMOUS_USER_NAME),
+ 'email_configured': email_is_configured(),
+ }
+
+
+@register.inclusion_tag('tom_common/partials/security_card.html')
+def security_card(user):
+ """Two-factor authentication status and actions for the Security card on the profile page."""
+ totp_authenticator = Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).first()
+ return {
+ 'user': user,
+ 'mfa_enabled': totp_authenticator is not None,
+ 'can_disable': get_mfa_adapter().can_delete_authenticator(totp_authenticator) if totp_authenticator else True,
+ }
+
+
@register.inclusion_tag('tom_common/partials/user_data.html')
def user_data(user):
"""
diff --git a/tom_common/test_templates/tom_common/partials/terms_of_service_text.html b/tom_common/test_templates/tom_common/partials/terms_of_service_text.html
new file mode 100644
index 000000000..e47dc2973
--- /dev/null
+++ b/tom_common/test_templates/tom_common/partials/terms_of_service_text.html
@@ -0,0 +1 @@
+
I solemnly swear that I am up to no good.
diff --git a/tom_common/tests.py b/tom_common/tests.py
index 3a27c2dca..9f2a16b97 100644
--- a/tom_common/tests.py
+++ b/tom_common/tests.py
@@ -1,22 +1,36 @@
+from copy import deepcopy
+from urllib.parse import urlencode
+from datetime import timedelta
from http import HTTPStatus
+from pathlib import Path
from io import StringIO
from types import SimpleNamespace
import tempfile
+import time
import logging
+from allauth.mfa.adapter import get_adapter as get_mfa_adapter
+from allauth.mfa.models import Authenticator
+from allauth.mfa.totp.internal import auth as totp_auth
from cryptography.fernet import InvalidToken
from django import forms
+from django.conf import settings as django_settings
from django.contrib.auth.models import User
+from django.core.cache import cache
from django.contrib.sites.models import Site
from django.core.exceptions import FieldError, ValidationError
+from django.core.mail.backends.base import BaseEmailBackend
from django.core.management import call_command
-from django.urls import reverse
+from django.urls import NoReverseMatch, clear_url_caches, resolve, reverse
from django_comments.models import Comment
+from rest_framework.authtoken.models import Token
from django.core.paginator import Paginator
-from django.test import TestCase, override_settings
+from django.test import Client, TestCase, override_settings
from django.test.runner import DiscoverRunner
+from django.utils import timezone
+from tom_common.middleware import ExternalServiceMiddleware
from tom_common.models import Profile
from tom_common import encryption
from tom_common.encryption import (
@@ -356,6 +370,1100 @@ def test_read_only_unauthenticated_allowed(self):
self.assertEqual(response.status_code, 200)
+class TestAllauthURLConf(TestCase):
+ """The allauth URL cutover: historical URL names keep working and password-only logins are closed."""
+
+ def test_login_and_logout_names_are_aliases(self):
+ """``login``/``logout`` and ``account_login``/``account_logout`` reverse to the same paths."""
+ self.assertEqual(reverse('login'), reverse('account_login'))
+ self.assertEqual(reverse('logout'), reverse('account_logout'))
+
+ def test_login_path_is_served_by_allauth(self):
+ """allauth is mounted before the plugin loop and the aliases, so its view answers the path."""
+ self.assertEqual(resolve(reverse('login')).url_name, 'account_login')
+
+ def test_login_page_renders_allauth_form(self):
+ response = self.client.get(reverse('login'))
+ self.assertEqual(response.status_code, 200)
+ # allauth's login form posts a 'login' field where Django's posted 'username'
+ self.assertContains(response, 'name="login"')
+ self.assertContains(response, 'name="password"')
+
+ def test_browsable_api_login_redirects_to_tom_login(self):
+ """The REST framework's password-only login page must not bypass two-factor authentication."""
+ response = self.client.get(reverse('rest_framework:login') + '?next=/api/')
+ self.assertRedirects(
+ response, reverse('account_login') + '?next=/api/', fetch_redirect_response=False
+ )
+
+ def test_logout_is_a_post(self):
+ user = User.objects.create_user(username='logout_user', password='password')
+ self.client.force_login(user)
+ response = self.client.post(reverse('logout'))
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ # the session is gone: a LOCKED-style protected page now redirects
+ self.assertNotIn('_auth_user_id', self.client.session)
+
+
+class TestSecureAdminLogin(TestCase):
+ """The Django admin's password-only login page must route through the TOM (allauth) login."""
+
+ def test_admin_login_redirects_to_tom_login(self):
+ response = self.client.get('/admin/login/?next=/admin/')
+ self.assertRedirects(
+ response, reverse('account_login') + '?next=%2Fadmin%2F', fetch_redirect_response=False
+ )
+
+ def test_admin_usable_with_an_authenticated_session(self):
+ admin_user = User.objects.create_user(username='admin_user', password='password',
+ is_staff=True, is_superuser=True)
+ self.client.force_login(admin_user)
+ self.assertEqual(self.client.get('/admin/').status_code, HTTPStatus.OK)
+ # an already-authenticated user hitting the admin login page is sent on, not asked again
+ response = self.client.get('/admin/login/?next=/admin/')
+ self.assertRedirects(response, '/admin/', fetch_redirect_response=False)
+
+
+class TestExternalServiceMiddleware(TestCase):
+ def test_unrelated_exceptions_are_left_to_other_middleware(self):
+ """process_exception must return None for exceptions it does not handle.
+
+ Re-raising prevented later exception middleware (allauth's AccountMiddleware) from
+ converting its control-flow exceptions into redirects, producing 500s instead.
+ """
+ middleware = ExternalServiceMiddleware(lambda request: None)
+ self.assertIsNone(middleware.process_exception(None, ValueError('unrelated')))
+
+
+# The test TOM's terms of service live in tom_common/test_templates/, shadowing the shipped
+# placeholder partial exactly the way a real TOM's templates/ directory would — so these
+# tests also prove the documented override mechanism.
+_TEMPLATES_WITH_TEST_TERMS = deepcopy(django_settings.TEMPLATES)
+_TEMPLATES_WITH_TEST_TERMS[0]['DIRS'] = (
+ [str(Path(__file__).parent / 'test_templates')] + list(_TEMPLATES_WITH_TEST_TERMS[0]['DIRS'])
+)
+MARAUDERS_OATH = 'I solemnly swear that I am up to no good.'
+
+
+def with_next(target_url: str, destination_url: str) -> str:
+ """The requirement middleware's redirect: target plus the interrupted destination as ?next=."""
+ return f'{target_url}?{urlencode({"next": destination_url})}'
+
+
+@override_settings(TEMPLATES=_TEMPLATES_WITH_TEST_TERMS)
+class TestTermsOfService(TestCase):
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='tos_user', password='password')
+ self.client.force_login(self.user)
+
+ def test_terms_page_is_public_and_shows_the_toms_terms(self):
+ response = Client().get(reverse('terms-of-service'))
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertContains(response, MARAUDERS_OATH)
+
+ def test_inactive_when_no_version_configured(self):
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.OK)
+
+ @override_settings(TOM_TERMS_OF_SERVICE_VERSION='v1')
+ def test_acceptance_flow(self):
+ from tom_common.models import TermsOfServiceAcceptance
+ # unaccepted: redirected to the accept page
+ response = self.client.get(reverse('user-profile'))
+ self.assertRedirects(response, with_next(reverse('terms-accept'), reverse('user-profile')),
+ fetch_redirect_response=False)
+ # the accept page itself renders (exempt from the check) and shows this TOM's terms
+ accept_page = self.client.get(reverse('terms-accept'))
+ self.assertEqual(accept_page.status_code, HTTPStatus.OK)
+ self.assertContains(accept_page, MARAUDERS_OATH)
+ # accepting records version and IP and unblocks
+ self.client.post(reverse('terms-accept'))
+ acceptance = TermsOfServiceAcceptance.objects.get(user=self.user)
+ self.assertEqual(acceptance.version, 'v1')
+ self.assertIsNotNone(acceptance.ip_address)
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.OK)
+ # bumping the version requires re-acceptance; the old record remains for the audit trail
+ with override_settings(TOM_TERMS_OF_SERVICE_VERSION='v2'):
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.FOUND)
+ self.client.post(reverse('terms-accept'))
+ self.assertEqual(TermsOfServiceAcceptance.objects.filter(user=self.user).count(), 2)
+
+ @override_settings(TOM_TERMS_OF_SERVICE_VERSION='v1', TOM_MFA_REQUIRED='all')
+ def test_terms_check_runs_first(self):
+ response = self.client.get(reverse('user-profile'))
+ self.assertRedirects(response, with_next(reverse('terms-accept'), reverse('user-profile')),
+ fetch_redirect_response=False)
+
+
+class TestRegistrationStrategies(TestCase):
+ """TOM_REGISTRATION_STRATEGY: closed (default), 'open', and 'approval_required'."""
+
+ SIGNUP_DATA = {
+ 'username': 'new_astronomer', 'email': 'new@example.com',
+ 'password1': 'a-strong-password-1!', 'password2': 'a-strong-password-1!',
+ 'first_name': 'Willa', 'affiliation': 'LCO',
+ }
+
+ def setUp(self):
+ cache.clear()
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='open')
+ def test_open_signup_creates_active_logged_in_user_with_fields(self):
+ response = self.client.post(reverse('account_signup'), self.SIGNUP_DATA)
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ user = User.objects.get(username='new_astronomer')
+ self.assertTrue(user.is_active)
+ self.assertEqual(user.first_name, 'Willa')
+ self.assertEqual(user.profile.affiliation, 'LCO')
+ self.assertTrue(user.groups.filter(name='Public').exists())
+ self.assertEqual(int(self.client.session['_auth_user_id']), user.pk) # logged in
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='approval_required',
+ MANAGERS=[('Admin', 'admin@example.com')])
+ def test_approval_required_signup_creates_inactive_user_and_notifies_managers(self):
+ from django.core import mail
+ response = self.client.post(reverse('account_signup'), self.SIGNUP_DATA)
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ user = User.objects.get(username='new_astronomer')
+ self.assertFalse(user.is_active)
+ self.assertTrue(user.groups.filter(name='Public').exists())
+ self.assertNotIn('_auth_user_id', self.client.session) # not logged in
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertIn('awaiting approval', mail.outbox[0].subject)
+ # a login attempt shows the pending-approval page, with the next step named
+ login_response = Client().post(reverse('login'),
+ {'login': 'new_astronomer', 'password': 'a-strong-password-1!'},
+ follow=True)
+ self.assertContains(login_response, 'awaiting approval')
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='open', TOM_REQUIRED_USER_FIELDS=['phone_number'])
+ def test_signup_enforces_required_fields(self):
+ response = self.client.post(reverse('account_signup'), self.SIGNUP_DATA)
+ self.assertEqual(response.status_code, HTTPStatus.OK) # re-rendered with errors
+ self.assertFalse(User.objects.filter(username='new_astronomer').exists())
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='open', TOM_TERMS_OF_SERVICE_VERSION='v1')
+ def test_signup_requires_and_records_terms_acceptance(self):
+ from tom_common.models import TermsOfServiceAcceptance
+ response = self.client.post(reverse('account_signup'), self.SIGNUP_DATA)
+ self.assertEqual(response.status_code, HTTPStatus.OK) # checkbox missing: form error
+ response = self.client.post(reverse('account_signup'),
+ {**self.SIGNUP_DATA, 'accept_terms': 'on'})
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ acceptance = TermsOfServiceAcceptance.objects.get(user__username='new_astronomer')
+ self.assertEqual(acceptance.version, 'v1')
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='open', TOM_TERMS_OF_SERVICE_VERSION='v1')
+ def test_terms_checkbox_label_links_the_terms(self):
+ response = self.client.get(reverse('account_signup'))
+ self.assertContains(response, f'href="{reverse("terms-of-service")}"')
+
+ def test_signup_closed_by_default_creates_nothing(self):
+ response = self.client.post(reverse('account_signup'), self.SIGNUP_DATA)
+ self.assertEqual(response.status_code, HTTPStatus.OK) # the closed page
+ self.assertFalse(User.objects.filter(username='new_astronomer').exists())
+
+
+class ExplodingEmailBackend(BaseEmailBackend):
+ """A test email backend whose sends always fail, like an unreachable relay."""
+ def send_messages(self, email_messages):
+ raise ConnectionRefusedError('no relay here')
+
+
+UNCONFIGURED_EMAIL = {'EMAIL_BACKEND': 'django.core.mail.backends.smtp.EmailBackend',
+ 'EMAIL_HOST': 'localhost', 'EMAIL_HOST_USER': ''}
+
+
+class TestEmailDegradation(TestCase):
+ """approval_required and password reset degrade gracefully without working email."""
+
+ def setUp(self):
+ cache.clear()
+ self.superuser = User.objects.create_user(username='mailless_admin', password='password',
+ is_staff=True, is_superuser=True)
+ self.pending = User.objects.create_user(username='mailless_applicant', password='password',
+ email='applicant@example.com', is_active=False)
+
+ def test_email_is_configured_heuristic(self):
+ from tom_common.accounts.email import email_is_configured
+ with override_settings(**UNCONFIGURED_EMAIL):
+ self.assertFalse(email_is_configured())
+ with override_settings(EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend'):
+ self.assertTrue(email_is_configured())
+ with override_settings(EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend',
+ EMAIL_HOST='smtp.example.com'):
+ self.assertTrue(email_is_configured())
+
+ @override_settings(EMAIL_BACKEND='tom_common.tests.ExplodingEmailBackend',
+ TOM_REGISTRATION_STRATEGY='approval_required')
+ def test_failed_approval_email_warns_the_approver(self):
+ self.client.force_login(self.superuser)
+ response = self.client.post(reverse('user-approve', kwargs={'pk': self.pending.pk}),
+ follow=True)
+ self.pending.refresh_from_db()
+ self.assertTrue(self.pending.is_active) # the approval survived the email failure
+ rendered_messages = [str(m) for m in response.context['messages']]
+ self.assertTrue(any('could not be sent' in m for m in rendered_messages))
+ self.assertTrue(any('applicant@example.com' in m for m in rendered_messages))
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='approval_required', **UNCONFIGURED_EMAIL)
+ def test_pending_table_warns_when_email_unconfigured(self):
+ self.client.force_login(self.superuser)
+ response = self.client.get(reverse('user-list'))
+ self.assertContains(response, 'Email is not configured')
+
+ @override_settings(EMAIL_BACKEND='tom_common.tests.ExplodingEmailBackend')
+ def test_password_reset_with_broken_relay_does_not_500(self):
+ self.addCleanup(TestPasswordResetOptIn._reload_urlconf)
+ with override_settings(TOM_PASSWORD_RESET_ENABLED=True):
+ TestPasswordResetOptIn._reload_urlconf()
+ response = self.client.post('/accounts/password/reset/',
+ {'email': 'applicant@example.com'}, follow=True)
+ self.assertEqual(response.status_code, HTTPStatus.OK) # not a 500
+ rendered_messages = [str(m) for m in response.context['messages']]
+ self.assertTrue(any('could not be sent' in m for m in rendered_messages))
+
+ def test_w002_warns_for_email_dependent_features_without_email(self):
+ from tom_common.checks import email_prerequisite_check
+ with override_settings(TOM_REGISTRATION_STRATEGY='approval_required', **UNCONFIGURED_EMAIL):
+ warnings = email_prerequisite_check(None)
+ self.assertEqual(warnings[0].id, 'tom_common.W002')
+ with override_settings(TOM_REGISTRATION_STRATEGY='approval_required',
+ EMAIL_BACKEND='django.core.mail.backends.console.EmailBackend'):
+ self.assertEqual(email_prerequisite_check(None), [])
+
+
+class TestInterruptedDestinationRestored(TestCase):
+ """A requirement interrupt carries ?next=, and the satisfy-pages send the user onward."""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='next_user', password='current-pass-1!')
+ self.client.force_login(self.user)
+
+ @override_settings(TOM_TERMS_OF_SERVICE_VERSION='v1')
+ def test_terms_interrupt_returns_to_destination(self):
+ destination = reverse('tom_targets:list')
+ response = self.client.get(destination)
+ self.assertEqual(response.headers['Location'], with_next(reverse('terms-accept'), destination))
+ response = self.client.post(reverse('terms-accept'), {'next': destination})
+ self.assertRedirects(response, destination, fetch_redirect_response=False)
+
+ @override_settings(TOM_PASSWORD_EXPIRY_DAYS=60)
+ def test_password_expiry_interrupt_returns_to_destination(self):
+ destination = reverse('tom_targets:list')
+ response = self.client.get(destination)
+ self.assertEqual(response.headers['Location'],
+ with_next(reverse('account_change_password'), destination))
+ response = self.client.post(f'{reverse("account_change_password")}?next={destination}', {
+ 'oldpassword': 'current-pass-1!',
+ 'password1': 'a-brand-new-pass-2@', 'password2': 'a-brand-new-pass-2@',
+ })
+ self.assertRedirects(response, destination, fetch_redirect_response=False)
+
+ @override_settings(TOM_TERMS_OF_SERVICE_VERSION='v1')
+ def test_unsafe_next_is_ignored(self):
+ response = self.client.post(reverse('terms-accept'), {'next': 'https://evil.example.com/'})
+ self.assertRedirects(response, django_settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False)
+
+
+class TestBlockedActionPagesExplainThemselves(TestCase):
+ """Pages for actions policy forbids explain the block instead of offering a dead control."""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='clarity_user', password='current-pass-1!')
+
+ def _enrol_and_log_in(self):
+ """A full password + TOTP-code login (the deactivate page requires recent authentication)."""
+ secret = totp_auth.generate_totp_secret()
+ totp_auth.TOTP.activate(self.user, secret)
+ client = Client()
+ client.post(reverse('login'), {'login': 'clarity_user', 'password': 'current-pass-1!'})
+ code = totp_auth.hotp_value(secret, int(time.time() // 30))
+ client.post(reverse('mfa_authenticate'), {'code': f'{code:06d}'})
+ return client
+
+ @override_settings(TOM_MFA_REQUIRED='all')
+ def test_deactivate_page_offers_no_dead_button_when_disabling_is_forbidden(self):
+ client = self._enrol_and_log_in()
+ response = client.get(reverse('mfa_deactivate_totp'))
+ self.assertContains(response, 'contact the')
+ self.assertContains(response, 'Back to two-factor settings')
+ self.assertNotContains(response, 'Are you sure')
+ self.assertNotContains(response, '>Deactivate', html=False)
+
+ def test_deactivate_page_confirms_normally_when_allowed(self):
+ client = self._enrol_and_log_in()
+ response = client.get(reverse('mfa_deactivate_totp'))
+ self.assertContains(response, 'Are you sure')
+ self.assertContains(response, 'Deactivate')
+
+ def test_wrong_current_password_says_so(self):
+ self.client.force_login(self.user)
+ response = self.client.post(reverse('account_change_password'), {
+ 'oldpassword': 'not-my-password', 'password1': 'a-new-pass-2@x', 'password2': 'a-new-pass-2@x',
+ })
+ self.assertContains(response, 'That is not your current password')
+
+
+class TestRegistrationDiscoverability(TestCase):
+ """The Register button and the login-page invitation appear only while registration is open."""
+
+ def test_hidden_while_registration_is_closed(self):
+ home = self.client.get(reverse('home'))
+ self.assertNotContains(home, '>Register')
+ login_page = self.client.get(reverse('account_login'))
+ self.assertNotContains(login_page, 'sign up')
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='open')
+ def test_shown_while_registration_is_open(self):
+ home = self.client.get(reverse('home'))
+ self.assertContains(home, '>Register')
+ self.assertContains(home, reverse('account_signup'))
+ login_page = self.client.get(reverse('account_login'))
+ self.assertContains(login_page, 'sign up')
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='open')
+ def test_register_button_not_shown_to_authenticated_users(self):
+ user = User.objects.create_user(username='already_in', password='password')
+ self.client.force_login(user)
+ self.assertNotContains(self.client.get(reverse('home')), '>Register')
+
+
+@override_settings(TOM_REGISTRATION_STRATEGY='approval_required')
+class TestApprovalWorkflow(TestCase):
+ """The administrator's side of approval_required: the Pending users table and approval."""
+
+ def setUp(self):
+ cache.clear()
+ self.superuser = User.objects.create_user(username='approver', password='password',
+ is_staff=True, is_superuser=True)
+ self.pending = User.objects.create_user(username='applicant', password='applicant-pass-1!',
+ email='applicant@example.com', is_active=False)
+
+ def test_pending_table_shown_to_superusers_only(self):
+ self.client.force_login(self.superuser)
+ response = self.client.get(reverse('user-list'))
+ self.assertContains(response, 'Pending Users')
+ self.assertContains(response, 'applicant')
+ regular = User.objects.create_user(username='regular', password='password')
+ self.client.force_login(regular)
+ self.assertNotContains(self.client.get(reverse('user-list')), 'Pending Users')
+
+ def test_approval_activates_notifies_and_logs(self):
+ from django.core import mail
+ self.client.force_login(self.superuser)
+ with self.assertLogs('tom_common.security', 'INFO') as logs:
+ response = self.client.post(reverse('user-approve', kwargs={'pk': self.pending.pk}))
+ self.assertRedirects(response, reverse('user-list'), fetch_redirect_response=False)
+ self.pending.refresh_from_db()
+ self.assertTrue(self.pending.is_active)
+ self.assertEqual(mail.outbox[-1].to, ['applicant@example.com'])
+ self.assertIn('approved', mail.outbox[-1].subject)
+ self.assertIn('/accounts/login/', mail.outbox[-1].body)
+ self.assertIn('Registration approved: applicant by approver', '\n'.join(logs.output))
+ # the approved user can log in now
+ login = Client().post(reverse('login'),
+ {'login': 'applicant', 'password': 'applicant-pass-1!'})
+ self.assertEqual(login.status_code, HTTPStatus.FOUND)
+
+ def test_approving_twice_is_a_404(self):
+ self.client.force_login(self.superuser)
+ self.client.post(reverse('user-approve', kwargs={'pk': self.pending.pk}))
+ response = self.client.post(reverse('user-approve', kwargs={'pk': self.pending.pk}))
+ self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
+
+ def test_non_superusers_cannot_approve(self):
+ regular = User.objects.create_user(username='not_admin', password='password')
+ self.client.force_login(regular)
+ self.client.post(reverse('user-approve', kwargs={'pk': self.pending.pk}))
+ self.pending.refresh_from_db()
+ self.assertFalse(self.pending.is_active)
+
+
+class TestTomTokenAuthentication(TestCase):
+ """TomTokenAuthentication: expiry and MFA gating for API tokens."""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='token_user', password='password')
+ self.token = Token.objects.get(user=self.user) # auto-created by signal
+
+ def _api_get(self):
+ return self.client.get('/api/', HTTP_AUTHORIZATION=f'Token {self.token.key}')
+
+ def test_plain_token_works_with_nothing_configured(self):
+ self.assertEqual(self._api_get().status_code, HTTPStatus.OK)
+
+ @override_settings(TOM_API_TOKEN_EXPIRY_DAYS=60)
+ def test_expired_token_is_rejected_with_next_action(self):
+ self.assertEqual(self._api_get().status_code, HTTPStatus.OK) # fresh token passes
+ Token.objects.filter(user=self.user).update(created=timezone.now() - timedelta(days=61))
+ response = self._api_get()
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+ self.assertIn('Regenerate it on your profile edit page', response.json()['detail'])
+
+ @override_settings(TOM_API_TOKEN_REQUIRES_MFA=True)
+ def test_mfa_gating_lifecycle(self):
+ # not enrolled: rejected, message names the fix
+ response = self._api_get()
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+ self.assertIn('two-factor', response.json()['detail'])
+ # enrolled, but the token predates enrolment: rejected
+ totp_auth.TOTP.activate(self.user, totp_auth.generate_totp_secret())
+ response = self._api_get()
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+ self.assertIn('predates', response.json()['detail'])
+ # regenerated after enrolment: accepted
+ Token.objects.filter(user=self.user).delete()
+ self.token = Token.objects.create(user=self.user)
+ self.assertEqual(self._api_get().status_code, HTTPStatus.OK)
+ # turning MFA off invalidates the token immediately (no explicit revocation step)
+ Authenticator.objects.filter(user=self.user).delete()
+ self.assertEqual(self._api_get().status_code, HTTPStatus.UNAUTHORIZED)
+
+ @override_settings(TOM_API_TOKEN_REQUIRES_MFA=True, TOM_TERMS_OF_SERVICE_VERSION='v1')
+ def test_outstanding_requirement_rejects_the_token(self):
+ from tom_common.models import TermsOfServiceAcceptance
+ totp_auth.TOTP.activate(self.user, totp_auth.generate_totp_secret())
+ Token.objects.filter(user=self.user).delete()
+ self.token = Token.objects.create(user=self.user)
+ response = self._api_get()
+ self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)
+ self.assertIn('Terms accepted', response.json()['detail'])
+ TermsOfServiceAcceptance.objects.create(user=self.user, version='v1')
+ self.assertEqual(self._api_get().status_code, HTTPStatus.OK)
+
+ @override_settings(TOM_API_TOKEN_REQUIRES_MFA=True)
+ def test_system_check_warns_without_tom_token_authentication(self):
+ from tom_common.checks import api_token_settings_check
+ self.assertEqual(api_token_settings_check(None), []) # settings.py lists our class
+ with override_settings(REST_FRAMEWORK={'DEFAULT_AUTHENTICATION_CLASSES': [
+ 'rest_framework.authentication.TokenAuthentication']}):
+ warnings = api_token_settings_check(None)
+ self.assertEqual(len(warnings), 1)
+ self.assertEqual(warnings[0].id, 'tom_common.W001')
+
+
+class TestTokenEndpointAndRegeneration(TestCase):
+ """api/token-auth/ and token regeneration under TOM_API_TOKEN_REQUIRES_MFA."""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='endpoint_user', password='password')
+
+ def test_token_auth_endpoint_works_by_default(self):
+ response = self.client.post('/api/token-auth/',
+ {'username': 'endpoint_user', 'password': 'password'})
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertIn('token', response.json())
+
+ @override_settings(TOM_API_TOKEN_REQUIRES_MFA=True)
+ def test_token_auth_endpoint_refuses_under_the_mfa_flag(self):
+ response = self.client.post('/api/token-auth/',
+ {'username': 'endpoint_user', 'password': 'password'})
+ self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN)
+ self.assertIn('copy your token from your profile page', response.json()['detail'])
+
+ @override_settings(TOM_API_TOKEN_REQUIRES_MFA=True)
+ def test_superuser_cannot_regenerate_anothers_token_under_the_flag(self):
+ superuser = User.objects.create_user(username='endpoint_admin', password='password',
+ is_staff=True, is_superuser=True)
+ original_key = Token.objects.get(user=self.user).key
+ self.client.force_login(superuser)
+ response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ self.assertEqual(Token.objects.get(user=self.user).key, original_key) # unchanged
+
+ @override_settings(TOM_API_TOKEN_REQUIRES_MFA=True)
+ def test_owner_regeneration_requires_recent_reauthentication(self):
+ original_key = Token.objects.get(user=self.user).key
+ # force_login leaves no allauth authentication record: sent to the reauthenticate page
+ self.client.force_login(self.user)
+ response = self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ self.assertIn(reverse('account_reauthenticate'), response.headers['Location'])
+ self.assertEqual(Token.objects.get(user=self.user).key, original_key)
+ # a real login is a recent authentication: regeneration proceeds
+ client = Client()
+ client.post(reverse('login'), {'login': 'endpoint_user', 'password': 'password'})
+ response = client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ self.assertNotEqual(Token.objects.get(user=self.user).key, original_key)
+
+ @override_settings(TOM_API_TOKEN_EXPIRY_DAYS=60)
+ def test_edit_page_shows_token_dates(self):
+ self.client.force_login(self.user)
+ response = self.client.get(reverse('user-update', kwargs={'pk': self.user.pk}))
+ self.assertContains(response, 'expires')
+
+
+class TestSecurityLog(TestCase):
+ """Authentication events emit one INFO line each on the tom_common.security logger."""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='audit_user', password='password')
+
+ def test_login_success_failure_and_logout(self):
+ with self.assertLogs('tom_common.security', 'INFO') as logs:
+ self.client.post(reverse('login'), {'login': 'audit_user', 'password': 'wrong'})
+ self.client.post(reverse('login'), {'login': 'audit_user', 'password': 'password'})
+ self.client.post(reverse('logout'))
+ joined = '\n'.join(logs.output)
+ self.assertIn('Login failed: username=audit_user', joined)
+ self.assertIn('Login succeeded: audit_user', joined)
+ self.assertIn('Logout: audit_user', joined)
+ self.assertNotIn('wrong', joined) # passwords are never logged
+
+ def test_password_change_and_authenticator_events(self):
+ # a real login and enrolment: allauth emits authenticator_added from its view flow,
+ # not from the low-level TOTP.activate() used elsewhere in these tests
+ client = Client()
+ client.post(reverse('login'), {'login': 'audit_user', 'password': 'password'})
+ with self.assertLogs('tom_common.security', 'INFO') as logs:
+ response = client.get(reverse('mfa_activate_totp'))
+ secret = response.context['form'].secret
+ code = totp_auth.hotp_value(secret, int(time.time() // 30))
+ client.post(reverse('mfa_activate_totp'), {'code': f'{code:06d}'})
+ # after the enrolment: changing the password would invalidate the session above
+ self.user.set_password('a-new-password-1!')
+ self.user.save()
+ joined = '\n'.join(logs.output)
+ self.assertIn('Two-factor authenticator added: audit_user (totp)', joined)
+ self.assertIn('Password changed: audit_user', joined)
+
+ @override_settings(TOM_TERMS_OF_SERVICE_VERSION='v1')
+ def test_terms_acceptance_and_token_regeneration(self):
+ self.client.force_login(self.user)
+ with self.assertLogs('tom_common.security', 'INFO') as logs:
+ self.client.post(reverse('terms-accept'))
+ self.client.post(reverse('regenerate-api-token', kwargs={'pk': self.user.pk}))
+ joined = '\n'.join(logs.output)
+ self.assertIn('Terms of service accepted: audit_user (version v1', joined)
+ self.assertIn('API token regenerated for audit_user by audit_user', joined)
+
+
+class TestRequiredFieldsOnUserForm(TestCase):
+ """TOM_REQUIRED_USER_FIELDS marks the listed User/Profile fields required on the edit form."""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='form_user', password='password')
+ self.client.force_login(self.user)
+
+ def _post_update(self, **extra):
+ data = {
+ 'profile-TOTAL_FORMS': '1', 'profile-INITIAL_FORMS': '1',
+ 'profile-0-id': str(self.user.profile.pk), 'profile-0-user': str(self.user.pk),
+ 'username': 'form_user', 'email': 'form@example.com',
+ }
+ data.update(extra)
+ return self.client.post(reverse('user-update', kwargs={'pk': self.user.pk}), data)
+
+ def test_fields_optional_by_default(self):
+ response = self._post_update()
+ self.assertEqual(response.status_code, HTTPStatus.FOUND) # saved without the optional fields
+
+ @override_settings(TOM_REQUIRED_USER_FIELDS=['first_name', 'affiliation'])
+ def test_missing_required_fields_are_form_errors(self):
+ response = self._post_update()
+ self.assertEqual(response.status_code, HTTPStatus.OK) # re-rendered with errors, not saved
+ self.assertContains(response, 'This field is required')
+
+ @override_settings(TOM_REQUIRED_USER_FIELDS=['first_name', 'affiliation'])
+ def test_filled_required_fields_save(self):
+ response = self._post_update(**{'first_name': 'Willa', 'profile-0-affiliation': 'LCO'})
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ self.user.refresh_from_db()
+ self.assertEqual(self.user.first_name, 'Willa')
+ self.assertEqual(self.user.profile.affiliation, 'LCO')
+
+
+class TestRequirementColumnsOnUserList(TestCase):
+ """Configured requirements appear as columns on the Users page; unconfigured ones do not."""
+
+ def setUp(self):
+ cache.clear()
+ self.superuser = User.objects.create_user(username='col_admin', password='password',
+ is_staff=True, is_superuser=True)
+ self.user = User.objects.create_user(username='col_user', password='password')
+
+ def _user_list(self):
+ # requirements would redirect the (non-compliant) superuser away from the Users
+ # page; what is under test here is the columns, so satisfy or bypass as needed
+ self.client.force_login(self.superuser)
+ return self.client.get(reverse('user-list'))
+
+ def test_no_requirement_columns_by_default(self):
+ response = self._user_list()
+ self.assertNotContains(response, 'Terms accepted')
+ self.assertNotContains(response, 'Password current')
+
+ def test_guardian_anonymous_user_is_not_listed(self):
+ # guardian's permissions sentinel is not a person; requirements are inapplicable to it
+ response = self._user_list()
+ self.assertNotContains(response, 'AnonymousUser')
+
+ @override_settings(TOM_TERMS_OF_SERVICE_VERSION='v1')
+ def test_terms_column_reflects_acceptance_and_version_bumps(self):
+ from tom_common.models import TermsOfServiceAcceptance
+ TermsOfServiceAcceptance.objects.create(user=self.superuser, version='v1')
+ TermsOfServiceAcceptance.objects.create(user=self.user, version='v1')
+ response = self._user_list()
+ self.assertContains(response, 'Terms accepted')
+ self.assertNotContains(response, '
no', html=True)
+ # bump the version: col_user has not accepted it and flips to no
+ with override_settings(TOM_TERMS_OF_SERVICE_VERSION='v2'):
+ TermsOfServiceAcceptance.objects.create(user=self.superuser, version='v2') # readmit the admin
+ response = self._user_list()
+ self.assertContains(response, '
no', count=1, html=True)
+
+ @override_settings(TOM_MFA_REQUIRED='superusers')
+ def test_mfa_requirement_column_respects_scoping(self):
+ totp_auth.TOTP.activate(self.superuser, totp_auth.generate_totp_secret())
+ response = self._user_list()
+ self.assertContains(response, '2FA required')
+ # the regular user is outside the policy: vacuously met, no 'no' rows
+ self.assertNotContains(response, '
no', html=True)
+
+ @override_settings(TOM_PASSWORD_EXPIRY_DAYS=60)
+ def test_password_and_fields_columns_render(self):
+ Profile.objects.filter(user=self.superuser).update(password_changed_at=timezone.now())
+ response = self._user_list()
+ self.assertContains(response, 'Password current')
+ self.assertContains(response, '
no', html=True) # col_user's stamp is None
+
+
+class TestPasswordValidators(TestCase):
+ def test_character_class_validator(self):
+ from tom_common.accounts.password_validation import CharacterClassValidator
+ validator = CharacterClassValidator()
+ validator.validate('Abcdef1!') # all four classes: no exception
+ for bad, missing in (('abcdef1!', 'upper-case'), ('ABCDEF1!', 'lower-case'),
+ ('Abcdefg!', 'digit'), ('Abcdefg1', 'special')):
+ with self.subTest(bad=bad):
+ with self.assertRaises(ValidationError) as raised:
+ validator.validate(bad)
+ self.assertIn(missing, str(raised.exception))
+
+ def test_not_same_as_current_password_validator(self):
+ from tom_common.accounts.password_validation import NotSameAsCurrentPasswordValidator
+ validator = NotSameAsCurrentPasswordValidator()
+ user = User.objects.create_user(username='validator_user', password='current-pass-1!')
+ with self.assertRaises(ValidationError):
+ validator.validate('current-pass-1!', user)
+ validator.validate('a-different-pass-2!', user) # no exception
+ validator.validate('current-pass-1!', None) # no user to compare: skipped
+ validator.validate('current-pass-1!', User(username='unsaved')) # unsaved user: skipped
+
+ @override_settings(AUTH_PASSWORD_VALIDATORS=[
+ {'NAME': 'tom_common.accounts.password_validation.NotSameAsCurrentPasswordValidator'},
+ ])
+ def test_change_password_to_itself_is_rejected(self):
+ cache.clear()
+ User.objects.create_user(username='same_pass_user', password='current-pass-1!')
+ self.client.login(username='same_pass_user', password='current-pass-1!')
+ response = self.client.post(reverse('account_change_password'), {
+ 'oldpassword': 'current-pass-1!',
+ 'password1': 'current-pass-1!',
+ 'password2': 'current-pass-1!',
+ })
+ self.assertContains(response, 'same as your current password')
+
+
+class TestAccountRequirements(TestCase):
+ """AccountRequirementsMiddleware + the built-in TOM_ACCOUNT_REQUIREMENTS checks."""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='req_user', password='password')
+ self.client.force_login(self.user)
+
+ def test_all_checks_inactive_by_default(self):
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.OK)
+
+ @override_settings(TOM_MFA_REQUIRED='all')
+ def test_unenrolled_user_is_sent_to_enrolment(self):
+ response = self.client.get(reverse('user-profile'))
+ self.assertRedirects(response, with_next(reverse('mfa_activate_totp'), reverse('user-profile')),
+ fetch_redirect_response=False)
+
+ @override_settings(TOM_MFA_REQUIRED='all')
+ def test_enrolment_page_and_logout_stay_reachable(self):
+ # a real login (not force_login) so allauth's reauthentication window is open
+ client = Client()
+ client.post(reverse('login'), {'login': 'req_user', 'password': 'password'})
+ self.assertEqual(client.get(reverse('mfa_activate_totp')).status_code, HTTPStatus.OK)
+ self.assertEqual(client.post(reverse('logout')).status_code, HTTPStatus.FOUND)
+
+ @override_settings(TOM_MFA_REQUIRED='all')
+ def test_enrolled_user_passes(self):
+ totp_auth.TOTP.activate(self.user, totp_auth.generate_totp_secret())
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.OK)
+
+ @override_settings(TOM_MFA_REQUIRED='superusers')
+ def test_superusers_scoping(self):
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.OK)
+ superuser = User.objects.create_user(username='req_super', password='password', is_superuser=True)
+ self.client.force_login(superuser)
+ response = self.client.get(reverse('user-profile'))
+ self.assertRedirects(response, with_next(reverse('mfa_activate_totp'), reverse('user-profile')),
+ fetch_redirect_response=False)
+
+ @override_settings(TOM_PASSWORD_EXPIRY_DAYS=60)
+ def test_password_expiry(self):
+ # the new user's stamp is None: counts as expired
+ response = self.client.get(reverse('user-profile'))
+ self.assertRedirects(response, with_next(reverse('account_change_password'), reverse('user-profile')),
+ fetch_redirect_response=False)
+ # a fresh stamp passes
+ Profile.objects.filter(user=self.user).update(password_changed_at=timezone.now())
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.OK)
+ # a stamp beyond the limit redirects again
+ Profile.objects.filter(user=self.user).update(
+ password_changed_at=timezone.now() - timedelta(days=61))
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.FOUND)
+
+ @override_settings(TOM_REQUIRED_USER_FIELDS=['first_name'])
+ def test_required_fields(self):
+ response = self.client.get(reverse('user-profile'))
+ self.assertRedirects(response,
+ with_next(reverse('user-update', kwargs={'pk': self.user.pk}),
+ reverse('user-profile')),
+ fetch_redirect_response=False)
+ User.objects.filter(pk=self.user.pk).update(first_name='Willa')
+ self.assertEqual(self.client.get(reverse('user-profile')).status_code, HTTPStatus.OK)
+
+ @override_settings(TOM_MFA_REQUIRED='all', TOM_PASSWORD_EXPIRY_DAYS=60)
+ def test_first_unmet_check_wins(self):
+ # both unmet; the default list runs mfa_enrolled before password_not_expired
+ response = self.client.get(reverse('user-profile'))
+ self.assertRedirects(response, with_next(reverse('mfa_activate_totp'), reverse('user-profile')),
+ fetch_redirect_response=False)
+
+ @override_settings(TOM_MFA_REQUIRED='all')
+ def test_blocked_htmx_request_becomes_full_page_navigation(self):
+ response = self.client.get(reverse('user-profile'), HTTP_HX_REQUEST='true')
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertEqual(response.headers['HX-Redirect'],
+ with_next(reverse('mfa_activate_totp'), reverse('user-profile')))
+
+ @override_settings(TOM_MFA_REQUIRED='all')
+ def test_anonymous_requests_are_untouched(self):
+ self.assertEqual(Client().get(reverse('account_login')).status_code, HTTPStatus.OK)
+
+
+class TestPasswordChangedStamp(TestCase):
+ """Profile.password_changed_at: dated only when the user chose the password themselves."""
+
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='stamp_user', password='old-password-123')
+
+ def _stamp(self):
+ self.user.profile.refresh_from_db()
+ return self.user.profile.password_changed_at
+
+ def test_new_users_have_no_stamp(self):
+ self.assertIsNone(self._stamp())
+
+ def test_self_service_change_stamps(self):
+ self.client.login(username='stamp_user', password='old-password-123')
+ response = self.client.post(reverse('account_change_password'), {
+ 'oldpassword': 'old-password-123',
+ 'password1': 'new-password-456',
+ 'password2': 'new-password-456',
+ })
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ self.assertIsNotNone(self._stamp())
+
+ def test_own_profile_edit_password_change_stamps(self):
+ self.client.force_login(self.user)
+ self.client.post(reverse('user-update', kwargs={'pk': self.user.pk}), {
+ 'profile-TOTAL_FORMS': '1', 'profile-INITIAL_FORMS': '1',
+ 'profile-0-id': str(self.user.profile.pk), 'profile-0-user': str(self.user.pk),
+ 'username': 'stamp_user', 'email': 'stamp@example.com',
+ 'password1': 'new-password-456', 'password2': 'new-password-456',
+ })
+ self.assertIsNotNone(self._stamp())
+
+ def test_administrator_set_password_clears_the_stamp(self):
+ Profile.objects.filter(user=self.user).update(password_changed_at=timezone.now())
+ superuser = User.objects.create_user(username='stamp_admin', password='password',
+ is_staff=True, is_superuser=True)
+ self.client.force_login(superuser)
+ self.client.post(reverse('admin-user-change-password', kwargs={'pk': self.user.pk}),
+ {'password': 'admin-chosen-789', 'change_password_form': '1'})
+ self.assertIsNone(self._stamp())
+
+ def test_login_does_not_touch_the_stamp(self):
+ stamp = timezone.now()
+ Profile.objects.filter(user=self.user).update(password_changed_at=stamp)
+ self.client.login(username='stamp_user', password='old-password-123') # saves last_login
+ self.assertEqual(self._stamp(), stamp)
+
+
+class TestSecurityCardAndUserList(TestCase):
+ def setUp(self):
+ cache.clear()
+ self.user = User.objects.create_user(username='card_user', password='password')
+
+ def test_card_offers_enrolment_when_not_enrolled(self):
+ self.client.force_login(self.user)
+ response = self.client.get(reverse('user-profile'))
+ self.assertContains(response, 'Enable two-factor authentication')
+ self.assertContains(response, reverse('mfa_activate_totp'))
+
+ def test_card_links_management_when_enrolled(self):
+ totp_auth.TOTP.activate(self.user, totp_auth.generate_totp_secret())
+ self.client.force_login(self.user)
+ response = self.client.get(reverse('user-profile'))
+ self.assertContains(response, 'Manage two-factor authentication')
+ self.assertContains(response, reverse('mfa_index'))
+
+ @override_settings(TOM_MFA_REQUIRED='all')
+ def test_blocked_disable_names_the_next_action(self):
+ totp_auth.TOTP.activate(self.user, totp_auth.generate_totp_secret())
+ self.client.force_login(self.user)
+ response = self.client.get(reverse('user-profile'))
+ self.assertContains(response, 'contact the administrators')
+
+ def test_adapter_refusal_message_names_the_next_action(self):
+ message = get_mfa_adapter().error_messages['cannot_delete_authenticator']
+ self.assertIn('contact the administrators', message)
+
+ def test_user_list_shows_two_factor_column(self):
+ totp_auth.TOTP.activate(self.user, totp_auth.generate_totp_secret())
+ superuser = User.objects.create_user(username='card_admin', password='password',
+ is_staff=True, is_superuser=True)
+ self.client.force_login(superuser)
+ response = self.client.get(reverse('user-list'))
+ self.assertContains(response, '
2FA | ', html=True)
+
+ def test_email_addresses_shown_only_to_superusers(self):
+ User.objects.filter(username='card_user').update(email='private@example.com')
+ superuser = User.objects.create_user(username='card_admin', password='password',
+ is_staff=True, is_superuser=True)
+ self.client.force_login(superuser)
+ self.assertContains(self.client.get(reverse('user-list')), 'private@example.com')
+ self.client.force_login(self.user)
+ response = self.client.get(reverse('user-list'))
+ self.assertNotContains(response, 'private@example.com')
+ self.assertNotContains(response, '
Email | ', html=True)
+
+
+class TestHTMXRedirectMiddleware(TestCase):
+ def test_redirects_on_htmx_requests_become_full_page_navigations(self):
+ # an anonymous HTMX request to a login-protected page: without the middleware, htmx
+ # would swap the login page into the requesting fragment
+ response = self.client.get(reverse('user-profile'), HTTP_HX_REQUEST='true')
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertIn(reverse('account_login'), response.headers['HX-Redirect'])
+
+ def test_redirects_on_ordinary_requests_are_unchanged(self):
+ response = self.client.get(reverse('user-profile'))
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ self.assertNotIn('HX-Redirect', response.headers)
+
+
+class TestPasswordResetOptIn(TestCase):
+ """TOM_PASSWORD_RESET_ENABLED=False (the default) leaves the reset routes unmounted."""
+
+ @staticmethod
+ def _reload_urlconf():
+ """Rebuild the URLconf so a changed TOM_PASSWORD_RESET_ENABLED takes effect."""
+ import importlib
+
+ import tom_common.urls
+ importlib.reload(tom_common.urls)
+ clear_url_caches()
+
+ def test_reset_routes_not_mounted_by_default(self):
+ with self.assertRaises(NoReverseMatch):
+ reverse('account_reset_password')
+ self.assertEqual(self.client.get('/accounts/password/reset/').status_code, HTTPStatus.NOT_FOUND)
+
+ def test_login_page_has_no_reset_link_by_default(self):
+ response = self.client.get(reverse('account_login'))
+ self.assertNotContains(response, 'Forgot your password')
+
+ def test_reset_routes_and_login_link_appear_when_enabled(self):
+ self.addCleanup(self._reload_urlconf) # runs after the override exits: back to unmounted
+ with override_settings(TOM_PASSWORD_RESET_ENABLED=True):
+ self._reload_urlconf()
+ self.assertEqual(self.client.get('/accounts/password/reset/').status_code, HTTPStatus.OK)
+ self.assertContains(self.client.get('/accounts/login/'), 'Forgot your password')
+
+ def test_reset_pages_open_on_locked_toms_when_enabled(self):
+ self.addCleanup(self._reload_urlconf)
+ with override_settings(TOM_PASSWORD_RESET_ENABLED=True, AUTH_STRATEGY='LOCKED', OPEN_URLS=[]):
+ self._reload_urlconf()
+ for path in ('/accounts/password/reset/',
+ '/accounts/password/reset/key/abc-def/'): # parametrized: no wildcard needed
+ with self.subTest(path=path):
+ self.assertEqual(self.client.get(path).status_code, HTTPStatus.OK)
+
+
+@override_settings(AUTH_STRATEGY='LOCKED', OPEN_URLS=[])
+class TestLockedAllauthExemptions(TestCase):
+ """Anonymous users on a LOCKED TOM can reach every page of the login flow — and nothing else."""
+
+ def test_authentication_pages_are_open(self):
+ # the password-reset pages join this list when TOM_PASSWORD_RESET_ENABLED mounts them
+ # (covered in TestPasswordResetOptIn)
+ for path in (
+ reverse('account_login'),
+ reverse('account_signup'),
+ reverse('account_inactive'),
+ ):
+ with self.subTest(path=path):
+ self.assertEqual(self.client.get(path).status_code, HTTPStatus.OK)
+
+ def test_second_factor_challenge_is_not_blocked(self):
+ # without a half-finished login to continue, allauth sends the visitor to the login
+ # page; the point is the middleware lets the request through instead of 403ing it
+ response = self.client.get(reverse('mfa_authenticate'))
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ self.assertIn(reverse('account_login'), response.headers['Location'])
+
+ def test_full_two_factor_login_works_when_locked(self):
+ cache.clear()
+ user = User.objects.create_user(username='locked_mfa_user', password='password')
+ secret = totp_auth.generate_totp_secret()
+ totp_auth.TOTP.activate(user, secret)
+ response = self.client.post(reverse('login'), {'login': 'locked_mfa_user', 'password': 'password'})
+ self.assertRedirects(response, reverse('mfa_authenticate'), fetch_redirect_response=False)
+ self.assertEqual(self.client.get(reverse('mfa_authenticate')).status_code, HTTPStatus.OK)
+ code = totp_auth.hotp_value(secret, int(time.time() // 30))
+ self.client.post(reverse('mfa_authenticate'), {'code': f'{code:06d}'})
+ self.assertEqual(self.client.get(reverse('tom_targets:list')).status_code, HTTPStatus.OK)
+
+ def test_other_pages_stay_locked(self):
+ response = self.client.get(reverse('tom_targets:list'))
+ # Raise403Middleware turns the middleware's 403 into a redirect to the login page
+ self.assertEqual(response.status_code, HTTPStatus.FOUND)
+ self.assertIn(reverse('account_login'), response.headers['Location'])
+
+
+class TestTomAccountAdapter(TestCase):
+ def test_signup_closed_by_default(self):
+ response = self.client.get(reverse('account_signup'))
+ self.assertTemplateUsed(response, 'account/signup_closed.html')
+
+ @override_settings(TOM_REGISTRATION_STRATEGY='open')
+ def test_signup_open_when_strategy_configured(self):
+ response = self.client.get(reverse('account_signup'))
+ self.assertTemplateUsed(response, 'account/signup.html')
+
+
+class TestTomMFAAdapter(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(username='mfa_user', password='password')
+ cache.clear() # allauth login rate limits are cache-counted
+
+ def test_totp_secret_is_stored_encrypted(self):
+ secret = totp_auth.generate_totp_secret()
+ totp_auth.TOTP.activate(self.user, secret)
+ stored = Authenticator.objects.get(user=self.user, type=Authenticator.Type.TOTP).data['secret']
+ self.assertNotEqual(stored, secret)
+ self.assertEqual(get_mfa_adapter().decrypt(stored), secret)
+
+ def test_login_with_totp_enrolled_redirects_to_challenge(self):
+ totp_auth.TOTP.activate(self.user, totp_auth.generate_totp_secret())
+ response = self.client.post(reverse('login'), {'login': 'mfa_user', 'password': 'password'})
+ self.assertRedirects(response, reverse('mfa_authenticate'), fetch_redirect_response=False)
+
+ def test_totp_issuer_is_tom_name(self):
+ with override_settings(TOM_NAME='My Fine TOM'):
+ self.assertEqual(get_mfa_adapter().get_totp_issuer(), 'My Fine TOM')
+
+ def test_can_delete_authenticator_follows_tom_mfa_required(self):
+ superuser = User.objects.create_user(username='mfa_super', password='password', is_superuser=True)
+ user_authenticator = Authenticator(user=self.user, type=Authenticator.Type.TOTP, data={})
+ superuser_authenticator = Authenticator(user=superuser, type=Authenticator.Type.TOTP, data={})
+ adapter = get_mfa_adapter()
+ self.assertTrue(adapter.can_delete_authenticator(user_authenticator)) # TOM_MFA_REQUIRED unset
+ with override_settings(TOM_MFA_REQUIRED='superusers'):
+ self.assertTrue(adapter.can_delete_authenticator(user_authenticator))
+ self.assertFalse(adapter.can_delete_authenticator(superuser_authenticator))
+ with override_settings(TOM_MFA_REQUIRED='all'):
+ self.assertFalse(adapter.can_delete_authenticator(user_authenticator))
+
+
+class TestAllauthTemplates(TestCase):
+ """The allauth pages render inside the TOM's base template with Bootstrap 5 styling."""
+
+ def setUp(self):
+ cache.clear() # allauth login rate limits are cache-counted
+ self.user = User.objects.create_user(username='template_user', password='template-pass')
+
+ def test_anonymous_pages_render_in_tom_base_template(self):
+ for url_name in ('account_login', 'account_signup'):
+ with self.subTest(url_name=url_name):
+ response = self.client.get(reverse(url_name))
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertContains(response, 'navbar-brand') # the navbar from tom_common/base.html
+
+ def test_signup_closed_page_suggests_next_action(self):
+ # blocking messages must point at the unblocking action, not just state the block
+ response = self.client.get(reverse('account_signup'))
+ self.assertContains(response, 'contact the administrators')
+
+ def test_login_form_is_bootstrap_styled(self):
+ response = self.client.get(reverse('account_login'))
+ self.assertContains(response, 'form-control')
+ self.assertContains(response, 'btn btn-primary')
+ self.assertNotContains(response, 'Menu:') # allauth's unstyled default layout
+
+ def test_allauth_page_scripts_survive_the_layout_bridge(self):
+ """allauth pages ship page scripts via extra_body; the layout must render that block.
+
+ The observable symptom of losing it: the recovery-codes "I have saved my recovery
+ codes" checkbox does nothing (its leave-warning script never loads).
+ """
+ from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
+ user = User.objects.create_user(username='script_user', password='script-pass-1!')
+ totp_auth.TOTP.activate(user, totp_auth.generate_totp_secret())
+ RecoveryCodes.activate(user)
+ client = Client()
+ client.post(reverse('login'), {'login': 'script_user', 'password': 'script-pass-1!'})
+ secret = get_mfa_adapter().decrypt(
+ Authenticator.objects.get(user=user, type=Authenticator.Type.TOTP).data['secret'])
+ code = totp_auth.hotp_value(secret, int(time.time() // 30))
+ client.post(reverse('mfa_authenticate'), {'code': f'{code:06d}'})
+ response = client.get(reverse('mfa_view_recovery_codes'))
+ self.assertContains(response, 'tom_common/js/recovery_codes.js') # our leave-dialog script
+ self.assertContains(response, 'codes_saved')
+ # the Download button only exists when codes are re-viewable (SHOW_ONCE off); there it
+ # must carry the id the leave-dialog script exempts — downloading IS saving the codes
+ with override_settings(MFA_RECOVERY_CODES_SHOW_ONCE=False):
+ response = client.get(reverse('mfa_view_recovery_codes'))
+ self.assertContains(response, 'id="download_codes"')
+
+ def test_two_factor_overview_renders_as_cards(self):
+ self.client.force_login(self.user)
+ response = self.client.get(reverse('mfa_index'))
+ self.assertEqual(response.status_code, HTTPStatus.OK)
+ self.assertContains(response, 'navbar-brand') # the navbar from tom_common/base.html
+ self.assertContains(response, 'card-body')
+
+
+class TestRotateEncryptionKeyAuthenticators(TestCase):
+ def test_rotate_reencrypts_totp_secret(self):
+ user = User.objects.create_user(username='rotate_mfa_user', password='password')
+ with override_settings(SECRET_KEY='old-key'):
+ secret = totp_auth.generate_totp_secret()
+ totp_auth.TOTP.activate(user, secret)
+ with override_settings(SECRET_KEY='new-key', SECRET_KEY_FALLBACKS=['old-key']):
+ call_command('rotate_encryption_key', stdout=StringIO())
+ # after rotation the fallback is no longer needed
+ with override_settings(SECRET_KEY='new-key', SECRET_KEY_FALLBACKS=[]):
+ stored = Authenticator.objects.get(user=user).data['secret']
+ self.assertEqual(get_mfa_adapter().decrypt(stored), secret)
+
+
class CommentDeleteViewTest(TestCase):
def setUp(self):
self.site = Site.objects.get_current()
diff --git a/tom_common/urls.py b/tom_common/urls.py
index 633e3373d..816f433e9 100644
--- a/tom_common/urls.py
+++ b/tom_common/urls.py
@@ -13,17 +13,19 @@
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
+from allauth.account import views as allauth_views
from django.contrib import admin
from django.urls import path
from django.urls import include
-from django.views.generic import TemplateView
-from django.contrib.auth.views import LoginView, LogoutView
+from django.views.generic import RedirectView, TemplateView
from django.conf import settings
from django.conf.urls.static import static
from django.apps import apps
-from rest_framework.authtoken import views
from tom_base import __version__
+from tom_common.accounts.api_views import TomObtainAuthToken
+from tom_common.accounts.urlpatterns import allauth_urlpatterns
+from tom_common.accounts.views import TermsAcceptView, TermsOfServiceView, UserApprovalView
from tom_common.api_views import GroupViewSet
from tom_common.views import UserListView, UserPasswordChangeView, UserCreateView, UserDeleteView, UserUpdateView
from tom_common.views import CommentDeleteView, GroupCreateView, GroupUpdateView, GroupDeleteView, UserProfileView
@@ -37,7 +39,15 @@
urlpatterns = [
path('', TemplateView.as_view(template_name='tom_common/index.html'),
- kwargs={'version': __version__}, name='home')]
+ kwargs={'version': __version__}, name='home'),
+ # django-allauth patterns come before the plugin loop so authentication URLs can't be shadowed
+ path('accounts/', include(allauth_urlpatterns())),
+
+ # for backwards compatibility with templates in tom_base and deployed TOMs,
+ # (they reverse 'login' and 'logout' URL names) route to django-allauth views.
+ path('accounts/login/', allauth_views.login, name='login'),
+ path('accounts/logout/', allauth_views.logout, name='logout'),
+]
# Add the urls from each app that has an include_url_paths method in its AppConfig
for app in apps.get_app_configs():
@@ -53,9 +63,12 @@
path('comments/', include('django_comments.urls')),
path('observations/', include('tom_observations.urls', namespace='observations')),
path('dataproducts/', include('tom_dataproducts.urls', namespace='dataproducts')),
+ path('terms/', TermsOfServiceView.as_view(), name='terms-of-service'),
+ path('terms/accept/', TermsAcceptView.as_view(), name='terms-accept'),
path('users/', UserListView.as_view(), name='user-list'),
path('users/
/changepassword/', UserPasswordChangeView.as_view(), name='admin-user-change-password'),
path('users/create/', UserCreateView.as_view(), name='user-create'),
+ path('users//approve/', UserApprovalView.as_view(), name='user-approve'),
path('users//delete/', UserDeleteView.as_view(), name='user-delete'),
path('users//update/', UserUpdateView.as_view(), name='user-update'),
path('users//regenerate-token/', RegenerateAPITokenView.as_view(), name='regenerate-api-token'),
@@ -63,13 +76,20 @@
path('groups/create/', GroupCreateView.as_view(), name='group-create'),
path('groups//update/', GroupUpdateView.as_view(), name='group-update'),
path('groups//delete/', GroupDeleteView.as_view(), name='group-delete'),
- path('accounts/login/', LoginView.as_view(), name='login'),
- path('accounts/logout/', LogoutView.as_view(), name='logout'),
path('comment//delete', CommentDeleteView.as_view(), name='comment-delete'),
path('admin/', admin.site.urls),
- path('api-auth/', include('rest_framework.urls')),
+ # The REST framework's own login page is password-only and would bypass two-factor
+ # authentication. The 'rest_framework' namespace must still exist (the browsable API's
+ # login/logout links reverse it), so keep the names but send both to the TOM's pages.
+ path('api-auth/', include(([
+ path('login/', RedirectView.as_view(pattern_name='account_login', query_string=True), name='login'),
+ path('logout/', RedirectView.as_view(pattern_name='account_logout', query_string=True), name='logout'),
+ ], 'rest_framework'))),
path('api/', include((collect_api_urls(), 'api'), namespace='api')),
- path('api/token-auth/', views.obtain_auth_token),
+
+ # instead of rest_framework.authtoken.views.obtain_auth_token, call our MFA-compliant wrapper:
+ path('api/token-auth/', TomObtainAuthToken.as_view()),
+
# The static helper below only works in development see
# https://docs.djangoproject.com/en/2.1/howto/static-files/#serving-files-uploaded-by-a-user-during-development
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
diff --git a/tom_common/views.py b/tom_common/views.py
index 470b9981c..2004a115b 100644
--- a/tom_common/views.py
+++ b/tom_common/views.py
@@ -1,4 +1,5 @@
import logging
+from allauth.account.decorators import reauthentication_required
from django.views import View
from django.views.generic import TemplateView
from django.views.generic.edit import FormView, DeleteView
@@ -14,14 +15,18 @@
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.contrib.auth import update_session_auth_hash
+from django.utils import timezone
from rest_framework.authtoken.models import Token
+from tom_common.accounts.api_auth import token_expiry_date
from tom_common.forms import ChangeUserPasswordForm, CustomUserCreationForm, GroupForm
from tom_common.mixins import SuperuserRequiredMixin
+from tom_common.models import Profile
logger = logging.getLogger(__name__)
+security_logger = logging.getLogger('tom_common.security')
class GroupCreateView(SuperuserRequiredMixin, CreateView):
@@ -97,19 +102,37 @@ class RegenerateAPITokenView(LoginRequiredMixin, View):
partial_template_name = 'tom_common/partials/api_token.html'
def dispatch(self, *args, **kwargs):
- """Ensure non-superusers can only regenerate their own token.
+ """Authorize the regeneration, then dispatch it.
- Checks authentication first (via LoginRequiredMixin), then checks
- that non-superusers are only operating on their own token.
+ Checks authentication first (via LoginRequiredMixin), then who may regenerate this
+ token, then — when API tokens require two-factor enrolment — demands recently
+ proven identity.
"""
# the User must be authenticated
if not self.request.user.is_authenticated:
return self.handle_no_permission()
- # don't let a non-super-user regenerate someone else's API Token,
- # instead, redirect them to their own user-update view.
- if not self.request.user.is_superuser and self.request.user.id != int(self.kwargs['pk']):
+ # some predicates for the conditional logic below:
+ token_requires_mfa = getattr(settings, 'TOM_API_TOKEN_REQUIRES_MFA', False) # tokens need 2FA-enrolled owner?
+ regenerating_own_token = self.request.user.id == int(self.kwargs['pk']) # requester's own token?
+ # a superuser may act for others only while tokens are NOT second-factor credentials
+ # because under TOM_API_TOKEN_REQUIRES_MFA, regenerating another user's token — this
+ # view displays the new token — would leave the superuser holding a credential that
+ # bypasses that user's second factor. (that would be a privilege escalation).
+ superuser_may_act_for_others = self.request.user.is_superuser and not token_requires_mfa
+
+ if not (regenerating_own_token or superuser_may_act_for_others):
+ if self.request.user.is_superuser: # blocked by the token-MFA policy: say so
+ messages.error(self.request,
+ 'Because API tokens on this TOM require two-factor authentication, they can '
+ 'only be regenerated by their owner. Ask the user to regenerate their own token.')
+ # this request targeted another user's token and is refused;
+ # send the requester to their own edit page instead
return redirect('user-update', pk=self.request.user.id)
+
+ if token_requires_mfa:
+ # sensitive action: the user must have recently proven their identity
+ return reauthentication_required(super().dispatch)(*args, **kwargs)
return super().dispatch(*args, **kwargs)
def post(self, request, pk: int) -> HttpResponse:
@@ -118,13 +141,15 @@ def post(self, request, pk: int) -> HttpResponse:
# Delete existing token (safe even if none exists) and create a new one
Token.objects.filter(user=target_user).delete()
new_token = Token.objects.create(user=target_user)
+ security_logger.info(f'API token regenerated for {target_user.username} by {request.user.username}')
# handle HTMX requests here
if request.htmx:
# Return just the partial for in-place replacement (avoid full page reload)
html = render_to_string(
self.partial_template_name,
- {'drf_api_token': new_token, 'user_pk': target_user.pk},
+ {'drf_api_token': new_token, 'user_pk': target_user.pk,
+ 'drf_api_token_expires': token_expiry_date(new_token)},
request=request,
)
return HttpResponse(html)
@@ -257,6 +282,7 @@ def get_context_data(self, **kwargs):
# add context required to Regenerate the user's DRF API token
context['drf_api_token'] = getattr(user_being_updated, 'auth_token', None)
+ context['drf_api_token_expires'] = token_expiry_date(context['drf_api_token'])
context['user_pk'] = user_being_updated.pk
return context
@@ -296,7 +322,11 @@ def form_valid(self, form):
self.object = form.save()
if form.cleaned_data.get("password1"):
+ # oh, the password changed
update_session_auth_hash(self.request, self.object)
+ if self.object == self.request.user:
+ # only timestamp this password if the user changed it themselves.
+ Profile.objects.filter(user=self.object).update(password_changed_at=timezone.now())
messages.success(self.request, 'Profile updated')
return HttpResponseRedirect(self.get_success_url())
diff --git a/tom_setup/templates/tom_setup/settings.tmpl b/tom_setup/templates/tom_setup/settings.tmpl
index a0d158b64..55ee09d38 100644
--- a/tom_setup/templates/tom_setup/settings.tmpl
+++ b/tom_setup/templates/tom_setup/settings.tmpl
@@ -114,10 +114,38 @@ LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
-AUTHENTICATION_BACKENDS = (
- 'django.contrib.auth.backends.ModelBackend',
- 'guardian.backends.ObjectPermissionBackend',
-)
+AUTHENTICATION_BACKENDS = TOMTOOLKIT_AUTHENTICATION_BACKENDS
+
+# --- Accounts, registration, and authentication controls -------------------------------------
+# Each is opt-in and documented in the "Accounts and Authentication" and "Custom settings"
+# pages of the TOM Toolkit documentation.
+
+# Password reset by email: adds the "Forgot your password?" flow. Requires a working EMAIL_BACKEND.
+# TOM_PASSWORD_RESET_ENABLED = True
+
+# Require two-factor authentication: 'all' for every user, 'superusers' for superusers only.
+# TOM_MFA_REQUIRED = 'all'
+
+# Self-registration: 'open' (sign up and log in immediately) or 'approval_required'
+# (a superuser approves each account from the Users page). Default: only administrators create accounts.
+# TOM_REGISTRATION_STRATEGY = 'approval_required'
+
+# Passwords older than this many days must be changed. A password set by an administrator counts as expired.
+# TOM_PASSWORD_EXPIRY_DAYS = 60
+
+# User/Profile fields every account must have filled in.
+# TOM_REQUIRED_USER_FIELDS = ['first_name', 'last_name', 'email']
+
+# Users must accept this version of the terms of service (write the terms in
+# templates/tom_common/partials/terms_of_service_text.html). Change the value to require re-acceptance.
+# TOM_TERMS_OF_SERVICE_VERSION = '2026-01-01'
+
+# API tokens older than this many days are rejected; users regenerate on their profile edit page.
+# TOM_API_TOKEN_EXPIRY_DAYS = 60
+
+# API tokens are honoured only for two-factor-enrolled users (and only tokens created after
+# enrolment); the password-only api/token-auth/ endpoint is disabled.
+# TOM_API_TOKEN_REQUIRES_MFA = True
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
@@ -274,6 +302,11 @@ SELECTION_EXTRA_FIELDS = []
# or READ_ONLY (read only access to views)
AUTH_STRATEGY = 'READ_ONLY'
+# URLs that should be allowed access even with AUTH_STRATEGY = LOCKED. Can use wildcards.
+# The login, sign-up, second-factor, pending-approval and (when enabled) password-reset pages
+# are open automatically. For example: OPEN_URLS = ['/', '/about', '/api/*']
+OPEN_URLS = []
+
# Row-level data permissions restrict users from viewing certain objects unless they are a member of the group to which
# the object belongs. Setting this value to True will allow all `ObservationRecord`, `DataProduct`, and `ReducedDatum`
# objects to be seen by everyone. Setting it to False will allow users to specify which groups can access
@@ -290,10 +323,6 @@ TARGET_LIST_COLUMNS = [
"name", "type", "observations", "saved_data"
]
-# URLs that should be allowed access even with AUTH_STRATEGY = LOCKED. Can use wildcards.
-# for example: OPEN_URLS = ['/', '/about', '/accounts/reset/*/']
-OPEN_URLS = []
-
HOOKS = {
'target_post_save': 'tom_common.hooks.target_post_save',
'observation_change_state': 'tom_common.hooks.observation_change_state',
@@ -313,7 +342,7 @@ HINT_LEVEL = 20
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
- 'rest_framework.authentication.TokenAuthentication', # for API token authentication
+ 'tom_common.accounts.api_auth.TomTokenAuthentication', # API tokens, honouring the TOM_API_TOKEN_* settings
'rest_framework.authentication.SessionAuthentication', # for logged-in browsers (cookie + CSRF protection)
'rest_framework.authentication.BasicAuthentication', # for username/password
],