from datetime import timedelta from doctest import DocTestSuite from io import StringIO import pickle from threading import Thread import unittest from freezegun import freeze_time from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.core.management import call_command from django.core.management.base import CommandError from django.db import IntegrityError, connection from django.test import RequestFactory from django.test import TestCase as DjangoTestCase from django.test import TransactionTestCase as DjangoTransactionTestCase from django.test import skipUnlessDBFeature from django.test.utils import override_settings from django.urls import reverse from django.utils import timezone from django_otp import ( DEVICE_ID_SESSION_KEY, device_classes, match_token, oath, user_has_device, util, verify_token, ) from django_otp.forms import OTPTokenForm, otp_verification_failed from django_otp.middleware import OTPMiddleware from django_otp.models import GenerateNotAllowed, VerifyNotAllowed from django_otp.plugins.otp_static.models import StaticDevice, StaticToken def load_tests(loader, tests, pattern): suite = unittest.TestSuite() suite.addTests(tests) suite.addTest(DocTestSuite(util)) suite.addTest(DocTestSuite(oath)) return suite class TestThread(Thread): "Django testing quirk: threads have to close their DB connections." def run(self): super().run() connection.close() class OTPTestCaseMixin: """ Utilities for dealing with custom user models. """ @classmethod def setUpClass(cls): super().setUpClass() cls.User = get_user_model() cls.USERNAME_FIELD = cls.User.USERNAME_FIELD def create_user(self, username, password, **kwargs): """ Try to create a user, honoring the custom user model, if any. This may raise an exception if the user model is too exotic for our purposes. """ return self.User.objects.create_user(username, password=password, **kwargs) class TestCase(OTPTestCaseMixin, DjangoTestCase): pass class TransactionTestCase(OTPTestCaseMixin, DjangoTransactionTestCase): pass class TimestampTestMixin: """ Generic tests for :class:`~django_otp.models.TimestampMixin`. Implementing tests must initialize `self.device` with the model instance to test and provide `valid_token` and `invalid_token` methods for verifying token behavior. Includes tests to: - Check automatic setting of `created_at` upon object creation. - Validate that `last_used_at` is initially None and updated only after successful token verification. - Ensure `set_last_used_timestamp` behaves correctly, respecting the `commit` parameter. """ def setUp(self): self.device = None def valid_token(self): """Returns a valid token to pass to our device under test.""" raise NotImplementedError() def invalid_token(self): """Returns an invalid token to pass to our device under test.""" raise NotImplementedError() # # Tests # def test_created_at_set_on_creation(self): """Verify that the `created_at` field is automatically set upon creation.""" self.assertIsNotNone( self.device.created_at, "created_at should be automatically set." ) def test_last_used_at_initially_none(self): """Ensure `last_used_at` is None upon initial creation.""" self.assertIsNone( self.device.last_used_at, "last_used_at should be None initially." ) def test_set_last_used_timestamp_updates_field(self): """Check if `set_last_used_timestamp` correctly updates the `last_used_at` field.""" self.device.set_last_used_timestamp(commit=True) self.device.refresh_from_db() # Assuming it's a persisted model self.assertIsNotNone( self.device.last_used_at, "last_used_at should be updated." ) def test_set_last_used_timestamp_without_commit(self): """ Ensure `set_last_used_timestamp` updates `last_used_at` without persisting when commit=False. """ original_last_used_at = self.device.last_used_at self.device.set_last_used_timestamp(commit=False) # Check in-memory update without saving self.assertNotEqual( self.device.last_used_at, original_last_used_at, "last_used_at should be updated in memory without commit.", ) # Refresh from db to confirm it wasn't committed self.device.refresh_from_db() self.assertEqual( self.device.last_used_at, original_last_used_at, "last_used_at should not be updated in db without commit.", ) def test_verify_token_successful_updates_last_used_at(self): """ Verifying with a valid token updates 'last_used_at'. """ valid_token = self.valid_token() # Method to generate a valid token initial_last_used_at = self.device.last_used_at verified = self.device.verify_token(valid_token) self.assertTrue(verified, "Token should be verified successfully.") self.device.refresh_from_db() self.assertNotEqual( self.device.last_used_at, initial_last_used_at, "'last_used_at' should be updated on successful verification.", ) def test_verify_token_failed_does_not_update_last_used_at(self): """ Verifying with an invalid token does not update 'last_used_at'. """ invalid_token = self.invalid_token() # Method to generate an invalid token initial_last_used_at = self.device.last_used_at verified = self.device.verify_token(invalid_token) self.assertFalse(verified, "Token should not be verified.") self.device.refresh_from_db() self.assertEqual( self.device.last_used_at, initial_last_used_at, "'last_used_at' should not be updated on failed verification.", ) class ThrottlingTestMixin: """ Generic tests for throttled devices. Any concrete device implementation that uses throttling should define a TestCase subclass that includes this as a base class. This will help verify a correct integration of ThrottlingMixin. Subclasses are responsible for populating self.device with a device to test as well as implementing methods to generate tokens to test with. """ def setUp(self): self.device = None def valid_token(self): """Returns a valid token to pass to our device under test.""" raise NotImplementedError() def invalid_token(self): """Returns an invalid token to pass to our device under test.""" raise NotImplementedError() # # Tests # def test_delay_imposed_after_fail(self): verified1 = self.device.verify_token(self.invalid_token()) self.assertFalse(verified1) verified2 = self.device.verify_token(self.valid_token()) self.assertFalse(verified2) def test_delay_after_fail_expires(self): verified1 = self.device.verify_token(self.invalid_token()) self.assertFalse(verified1) with freeze_time() as frozen_time: # With default settings initial delay is 1 second frozen_time.tick(delta=timedelta(seconds=1.1)) verified2 = self.device.verify_token(self.valid_token()) self.assertTrue(verified2) def test_throttling_failure_count(self): self.assertEqual(self.device.throttling_failure_count, 0) for i in range(0, 5): self.device.verify_token(self.invalid_token()) # Only the first attempt will increase throttling_failure_count, # the others will all be within 1 second of first # and therefore not count as attempts. self.assertEqual(self.device.throttling_failure_count, 1) def test_verify_is_allowed(self): # Initially should be allowed verify_is_allowed1, data1 = self.device.verify_is_allowed() self.assertEqual(verify_is_allowed1, True) self.assertEqual(data1, None) # After failure, verify is not allowed with freeze_time(): self.device.verify_token(self.invalid_token()) verify_is_allowed2, data2 = self.device.verify_is_allowed() self.assertEqual(verify_is_allowed2, False) self.assertEqual( data2, { 'reason': VerifyNotAllowed.N_FAILED_ATTEMPTS, 'failure_count': 1, 'locked_until': timezone.now() + timezone.timedelta(seconds=1), }, ) # After a successful attempt, should be allowed again with freeze_time() as frozen_time: frozen_time.tick(delta=timedelta(seconds=1.1)) self.device.verify_token(self.valid_token()) verify_is_allowed3, data3 = self.device.verify_is_allowed() self.assertEqual(verify_is_allowed3, True) self.assertEqual(data3, None) class CooldownTestMixin: def setUp(self): self.device = None def valid_token(self): """Returns a valid token to pass to our device under test.""" raise NotImplementedError() def invalid_token(self): """Returns an invalid token to pass to our device under test.""" raise NotImplementedError() # # Tests # def test_generate_is_allowed_on_first_try(self): """Token generation should be allowed on first try.""" allowed, _ = self.device.generate_is_allowed() self.assertTrue(allowed) def test_cooldown_imposed_after_successful_generation(self): """ Token generation before cooldown should not be allowed and the relevant reason should be returned. """ with freeze_time(): self.device.generate_challenge() self.device.refresh_from_db() allowed, details = self.device.generate_is_allowed() self.assertFalse(allowed) self.assertEqual( details['reason'], GenerateNotAllowed.COOLDOWN_DURATION_PENDING ) def test_cooldown_expire_time(self): """ When token generation is not allowed, the cooldown expire time should be returned. """ with freeze_time(): self.device.generate_challenge() self.device.refresh_from_db() _, details = self.device.generate_is_allowed() self.assertEqual( details['next_generation_at'], timezone.now() + timedelta(seconds=10) ) def test_cooldown_reset(self): """Cooldown can be reset and allow token generation again before the initial period expires.""" with freeze_time(): self.device.generate_is_allowed() self.device.refresh_from_db() self.device.cooldown_reset() self.device.refresh_from_db() allowed, _ = self.device.generate_is_allowed() self.assertTrue(allowed) def test_valid_token_verification_resets_cooldown(self): """When the token is verified, the cooldown period is reset.""" with freeze_time(): self.device.generate_challenge() self.device.refresh_from_db() verified = self.device.verify_token(self.valid_token()) self.assertTrue(verified) self.device.refresh_from_db() allowed, _ = self.device.generate_is_allowed() self.assertTrue(allowed) def test_invalid_token_verification_does_not_reset_cooldown(self): """When the token is not verified, the cooldown period is not reset.""" with freeze_time(): self.device.generate_challenge() self.device.refresh_from_db() verified = self.device.verify_token(self.invalid_token()) self.assertFalse(verified) self.device.refresh_from_db() allowed, _ = self.device.generate_is_allowed() self.assertFalse(allowed) @override_settings(OTP_STATIC_THROTTLE_FACTOR=0) class APITestCase(TestCase): def setUp(self): try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: device = self.alice.staticdevice_set.create() device.token_set.create(token='alice') def test_user_has_device(self): with self.subTest(user='anonymous'): self.assertFalse(user_has_device(AnonymousUser())) with self.subTest(user='alice'): self.assertTrue(user_has_device(self.alice)) with self.subTest(user='bob'): self.assertFalse(user_has_device(self.bob)) def test_verify_token(self): device = self.alice.staticdevice_set.first() verified = verify_token(self.alice, device.persistent_id, 'bogus') self.assertIsNone(verified) verified = verify_token(self.alice, device.persistent_id, 'alice') self.assertIsNotNone(verified) def test_match_token(self): verified = match_token(self.alice, 'bogus') self.assertIsNone(verified) verified = match_token(self.alice, 'alice') self.assertEqual(verified, self.alice.staticdevice_set.first()) def test_device_classes(self): classes = list(device_classes()) self.assertFalse(any(model._meta.proxy for model in classes)) class OTPVerificationFailedSignalTestCase(TestCase): def setUp(self): try: self.alice = self.create_user('alice', 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: self.device = self.alice.staticdevice_set.create() self.device.token_set.create(token='valid') self.signal_received = False otp_verification_failed.connect(self.signal_handler) def tearDown(self): otp_verification_failed.disconnect(self.signal_handler) def signal_handler(self, sender, **kwargs): self.signal_received = True def test_otp_verification_failed_signal(self): form = OTPTokenForm( self.alice, None, {'otp_device': self.device.persistent_id, 'otp_token': 'invalid'}, ) form.is_valid() self.assertTrue( self.signal_received, "otp_verification_failed signal was not emitted." ) class OTPMiddlewareTestCase(TestCase): def setUp(self): self.factory = RequestFactory() try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: for user in [self.alice, self.bob]: device = user.staticdevice_set.create() device.token_set.create(token=user.get_username()) self.middleware = OTPMiddleware(lambda r: None) def test_verified(self): request = self.factory.get('/') request.user = self.alice device = self.alice.staticdevice_set.get() request.session = {DEVICE_ID_SESSION_KEY: device.persistent_id} self.middleware(request) self.assertTrue(request.user.is_verified()) def test_verified_legacy_device_id(self): request = self.factory.get('/') request.user = self.alice device = self.alice.staticdevice_set.get() request.session = { DEVICE_ID_SESSION_KEY: '{}.{}/{}'.format( device.__module__, device.__class__.__name__, device.id ) } self.middleware(request) self.assertTrue(request.user.is_verified()) def test_unverified(self): request = self.factory.get('/') request.user = self.alice request.session = {} self.middleware(request) self.assertFalse(request.user.is_verified()) def test_no_device(self): request = self.factory.get('/') request.user = self.alice request.session = { DEVICE_ID_SESSION_KEY: 'otp_static.staticdevice/0', } self.middleware(request) self.assertFalse(request.user.is_verified()) def test_no_model(self): request = self.factory.get('/') request.user = self.alice request.session = { DEVICE_ID_SESSION_KEY: 'otp_bogus.bogusdevice/0', } self.middleware(request) self.assertFalse(request.user.is_verified()) def test_wrong_user(self): request = self.factory.get('/') request.user = self.alice device = self.bob.staticdevice_set.get() request.session = {DEVICE_ID_SESSION_KEY: device.persistent_id} self.middleware(request) self.assertFalse(request.user.is_verified()) def test_pickling(self): request = self.factory.get('/') request.user = self.alice device = self.alice.staticdevice_set.get() request.session = {DEVICE_ID_SESSION_KEY: device.persistent_id} self.middleware(request) # Should not raise an exception. pickle.dumps(request.user) class LoginViewTestCase(TestCase): def setUp(self): try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password', is_staff=True) except IntegrityError: self.skipTest("Unable to create a test user.") else: for user in [self.alice, self.bob]: device = user.staticdevice_set.create() device.token_set.create(token=user.get_username()) def test_admin_login_template(self): response = self.client.get(reverse('otpadmin:login')) self.assertContains(response, 'Username:') self.assertContains(response, 'Password:') self.assertNotContains(response, 'OTP Device:') self.assertContains(response, 'OTP Token:') response = self.client.post( reverse('otpadmin:login'), data={ 'username': self.bob.get_username(), 'password': 'password', }, ) self.assertContains(response, 'Username:') self.assertContains(response, 'Password:') self.assertContains(response, 'OTP Device:') self.assertContains(response, 'OTP Token:') device = self.bob.staticdevice_set.get() token = device.token_set.get() response = self.client.post( reverse('otpadmin:login'), data={ 'username': self.bob.get_username(), 'password': 'password', 'otp_device': device.persistent_id, 'otp_token': token.token, 'next': '/', }, ) self.assertRedirects(response, '/') def test_authenticate(self): device = self.alice.staticdevice_set.get() token = device.token_set.get() params = { 'username': self.alice.get_username(), 'password': 'password', 'otp_device': device.persistent_id, 'otp_token': token.token, 'next': '/', } response = self.client.post(reverse('login'), params) self.assertRedirects(response, '/') response = self.client.get('/') self.assertInHTML( f'{self.alice.get_username()}', response.content.decode(response.charset), ) def test_verify(self): device = self.alice.staticdevice_set.get() token = device.token_set.get() params = { 'otp_device': device.persistent_id, 'otp_token': token.token, 'next': '/', } self.client.login(username=self.alice.get_username(), password='password') response = self.client.post(reverse('login-otp'), params) self.assertRedirects(response, '/') response = self.client.get('/') self.assertInHTML( f'{self.alice.get_username()}', response.content.decode(response.charset), ) @skipUnlessDBFeature('has_select_for_update') @override_settings(OTP_STATIC_THROTTLE_FACTOR=0) class ConcurrencyTestCase(TransactionTestCase): def setUp(self): try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password') except IntegrityError: self.skipTest("Unable to create a test user.") else: for user in [self.alice, self.bob]: device = user.staticdevice_set.create() device.token_set.create(token='valid') def test_verify_token(self): class VerifyThread(Thread): def __init__(self, user, device_id, token): super().__init__() self.user = user self.device_id = device_id self.token = token self.verified = None def run(self): self.verified = verify_token(self.user, self.device_id, self.token) connection.close() device = self.alice.staticdevice_set.get() threads = [ VerifyThread(device.user, device.persistent_id, 'valid') for _ in range(10) ] for thread in threads: thread.start() for thread in threads: thread.join() self.assertEqual(sum(1 for t in threads if t.verified is not None), 1) def test_match_token(self): class VerifyThread(Thread): def __init__(self, user, token): super().__init__() self.user = user self.token = token self.verified = None def run(self): self.verified = match_token(self.user, self.token) connection.close() threads = [VerifyThread(self.alice, 'valid') for _ in range(10)] for thread in threads: thread.start() for thread in threads: thread.join() self.assertEqual(sum(1 for t in threads if t.verified is not None), 1) def test_concurrent_throttle_count(self): self._test_throttling_concurrency(thread_count=10, expected_failures=10) @override_settings(OTP_STATIC_THROTTLE_FACTOR=1) def test_serialized_throttling(self): # After the first failure, verification will be skipped and the count # will not be incremented. self._test_throttling_concurrency(thread_count=10, expected_failures=1) def _test_throttling_concurrency(self, thread_count, expected_failures): forms = ( OTPTokenForm( device.user, None, {'otp_device': device.persistent_id, 'otp_token': 'bogus'}, ) for _ in range(thread_count) for device in StaticDevice.objects.all() ) threads = [TestThread(target=form.is_valid) for form in forms] for thread in threads: thread.start() for thread in threads: thread.join() for device in StaticDevice.objects.all(): with self.subTest(user=device.user.get_username()): self.assertEqual(device.throttling_failure_count, expected_failures) class AddStaticTokenTestCase(TestCase): def setUp(self): try: self.alice = self.create_user('alice', 'password') self.bob = self.create_user('bob', 'password', is_staff=True) except IntegrityError: self.skipTest("Unable to create a test user.") def test_no_user(self): with self.assertRaises(CommandError): call_command('addstatictoken', 'bogus') def test_new_device(self): out = StringIO() call_command('addstatictoken', 'alice', stdout=out) token = out.getvalue().strip() static_token = StaticToken.objects.select_related('device__user').get( token=token ) self.assertEqual(static_token.device.user, self.alice) def test_existing_device(self): device = self.alice.staticdevice_set.create() out = StringIO() call_command('addstatictoken', 'alice', stdout=out) token = out.getvalue().strip() static_token = StaticToken.objects.select_related('device__user').get( token=token ) self.assertEqual(static_token.device, device) def test_explicit_token(self): device = self.alice.staticdevice_set.create() out = StringIO() call_command('addstatictoken', 'alice', '-t', 'secret-token', stdout=out) token = out.getvalue().strip() static_token = StaticToken.objects.select_related('device__user').get( token=token ) self.assertEqual(token, 'secret-token') self.assertEqual(static_token.device, device)