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)