diff --git a/codeforlife/user/migrations/0001_initial.py b/codeforlife/user/migrations/0001_initial.py index 034c7cbe..d27af2a9 100644 --- a/codeforlife/user/migrations/0001_initial.py +++ b/codeforlife/user/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-09-29 17:53 +# Generated by Django 3.2.20 on 2024-01-24 18:42 import django.contrib.auth.models import django.core.validators @@ -43,6 +43,14 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='OtpBypassToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(max_length=8, validators=[django.core.validators.MinLengthValidator(8)])), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='otp_bypass_tokens', to='user.user')), + ], + ), migrations.CreateModel( name='AuthFactor', fields=[ @@ -65,15 +73,4 @@ class Migration(migrations.Migration): 'unique_together': {('session', 'auth_factor')}, }, ), - migrations.CreateModel( - name='OtpBypassToken', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(max_length=8, validators=[django.core.validators.MinLengthValidator(8)])), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='otp_bypass_tokens', to='user.user')), - ], - options={ - 'unique_together': {('user', 'token')}, - }, - ), ] diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index fbd22ad8..ac3ee41a 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -5,11 +5,14 @@ from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models +from django.utils.crypto import get_random_string from . import user class OtpBypassToken(models.Model): + length = 8 + allowed_chars = "abcdefghijklmnopqrstuv" max_count = 10 max_count_validation_error = ValidationError( f"Exceeded max count of {max_count}" @@ -51,13 +54,10 @@ def key(otp_bypass_token: OtpBypassToken): ) token = models.CharField( - max_length=8, - validators=[MinLengthValidator(8)], + max_length=length, + validators=[MinLengthValidator(length)], ) - class Meta: - unique_together = ["user", "token"] - def save(self, *args, **kwargs): if self.id is None: if ( @@ -69,7 +69,24 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) def check_token(self, token: str): - if check_password(token, self.token): + if check_password(token.lower(), self.token): self.delete() return True return False + + @classmethod + def generate_tokens(cls, count: int = max_count): + """Generates a number of tokens. + + Args: + count: The number of tokens to generate. Default to max. + + Returns: + Raw tokens that are random and unique. + """ + + tokens: t.Set[str] = set() + while len(tokens) < count: + tokens.add(get_random_string(cls.length, cls.allowed_chars)) + + return tokens diff --git a/codeforlife/user/tests/models/test_otp_bypass_token.py b/codeforlife/user/tests/models/test_otp_bypass_token.py index a6683d5c..8aeb8656 100644 --- a/codeforlife/user/tests/models/test_otp_bypass_token.py +++ b/codeforlife/user/tests/models/test_otp_bypass_token.py @@ -1,9 +1,15 @@ +""" +© Ocado Group +Created on 24/01/2024 at 16:17:22(+00:00). +""" + +from unittest.mock import call, patch + from django.contrib.auth.hashers import check_password from django.core.exceptions import ValidationError from django.test import TestCase -from django.utils.crypto import get_random_string -from ...models import OtpBypassToken, User +from ...models import OtpBypassToken, User, otp_bypass_token class TestOtpBypassToken(TestCase): @@ -11,7 +17,7 @@ def setUp(self): self.user = User.objects.get(id=2) def test_bulk_create(self): - token = get_random_string(8) + token = next(iter(OtpBypassToken.generate_tokens(1))) otp_bypass_tokens = OtpBypassToken.objects.bulk_create( [OtpBypassToken(user=self.user, token=token)] ) @@ -20,16 +26,13 @@ def test_bulk_create(self): with self.assertRaises(ValidationError): OtpBypassToken.objects.bulk_create( [ - OtpBypassToken( - user=self.user, - token=get_random_string(8), - ) - for _ in range(OtpBypassToken.max_count) + OtpBypassToken(user=self.user, token=token) + for token in OtpBypassToken.generate_tokens() ] ) def test_create(self): - token = get_random_string(8) + token = next(iter(OtpBypassToken.generate_tokens(1))) otp_bypass_token = OtpBypassToken.objects.create( user=self.user, token=token ) @@ -38,22 +41,21 @@ def test_create(self): OtpBypassToken.objects.bulk_create( [ - OtpBypassToken( - user=self.user, - token=get_random_string(8), + OtpBypassToken(user=self.user, token=token) + for token in OtpBypassToken.generate_tokens( + OtpBypassToken.max_count - 1 ) - for _ in range(OtpBypassToken.max_count - 1) ] ) with self.assertRaises(ValidationError): OtpBypassToken.objects.create( user=self.user, - token=get_random_string(8), + token=next(iter(OtpBypassToken.generate_tokens(1))), ) def test_check_token(self): - token = get_random_string(8) + token = next(iter(OtpBypassToken.generate_tokens(1))) otp_bypass_token = OtpBypassToken.objects.create( user=self.user, token=token ) @@ -65,3 +67,36 @@ def test_check_token(self): user=otp_bypass_token.user, token=otp_bypass_token.token, ) + + def test_generate_tokens(self): + """ + Generates a number of unique tokens. + """ + + count = 3 + get_random_string_side_effect = [ + "aaaaaaaa", + "aaaaaaaa", + "bbbbbbbb", + "cccccccc", + ] + + with patch.object( + otp_bypass_token, + "get_random_string", + side_effect=get_random_string_side_effect, + ) as get_random_string: + tokens = OtpBypassToken.generate_tokens(count) + assert len(tokens) == count + assert tokens == { + "aaaaaaaa", + "bbbbbbbb", + "cccccccc", + } + + get_random_string.assert_has_calls( + [ + call(OtpBypassToken.length, OtpBypassToken.allowed_chars) + for _ in range(len(get_random_string_side_effect)) + ] + )