Skip to content

Commit

Permalink
generate otp bypass tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
SKairinos committed Jan 25, 2024
1 parent 9e88009 commit 757a773
Show file tree
Hide file tree
Showing 10 changed files with 124 additions and 30 deletions.
2 changes: 1 addition & 1 deletion backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ google-cloud-logging = "==1.*"
google-auth = "==2.*"
google-cloud-container = "==2.3.0"
# "django-anymail[amazon_ses]" = "==7.0.*"
codeforlife = {ref = "v0.9.5", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
codeforlife = {ref = "create_otp_bypass_tokens", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
django = "==3.2.20"
djangorestframework = "==3.13.1"
django-cors-headers = "==4.1.0"
Expand Down
18 changes: 9 additions & 9 deletions backend/Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions backend/api/serializers/klass.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"""
© Ocado Group
Created on 24/01/2024 at 12:14:21(+00:00).
"""

from codeforlife.user.serializers import ClassSerializer as _ClassSerializer


# pylint: disable-next=missing-class-docstring
class ClassSerializer(_ClassSerializer):
class Meta(_ClassSerializer.Meta):
pass
6 changes: 2 additions & 4 deletions backend/api/tests/views/test_auth_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@
from codeforlife.user.models import AuthFactor, User, UserProfile
from rest_framework import status

from ...serializers import AuthFactorSerializer
from ...views import AuthFactorViewSet


# pylint: disable-next=missing-class-docstring
class TestAuthFactorViewSet(
ModelViewSetTestCase[AuthFactorViewSet, AuthFactorSerializer, AuthFactor]
):
class TestAuthFactorViewSet(ModelViewSetTestCase[AuthFactor]):
basename = "auth-factor"
model_view_set_class = AuthFactorViewSet

def setUp(self):
self.one_factor_credentials = {
Expand Down
89 changes: 89 additions & 0 deletions backend/api/tests/views/test_otp_bypass_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""
© Ocado Group
Created on 24/01/2024 at 09:47:04(+00:00).
"""

from unittest.mock import patch

from codeforlife.tests import ModelViewSetTestCase
from codeforlife.user.models import OtpBypassToken, User
from rest_framework import status

from ...views import OtpBypassTokenViewSet


# pylint: disable-next=missing-class-docstring
class TestOtpBypassTokenViewSet(ModelViewSetTestCase[OtpBypassToken]):
basename = "otp-bypass-token"
model_view_set_class = OtpBypassTokenViewSet

def setUp(self):
self.user = User.objects.get(email="[email protected]")
assert not self.user.otp_bypass_tokens.exists()

self.otp_bypass_tokens = OtpBypassToken.objects.bulk_create(
[
OtpBypassToken(user=self.user, token=token)
for token in OtpBypassToken.generate_tokens()
]
)

def test_generate(self):
"""
Generate max number of OTP bypass tokens.
"""

user = self.client.login_teacher(
email="[email protected]",
password="Password1",
)
assert user == self.user

tokens = {
"aaaaaaaa",
"bbbbbbbb",
"cccccccc",
"dddddddd",
"eeeeeeee",
"ffffffff",
"gggggggg",
"hhhhhhhh",
"iiiiiiii",
"jjjjjjjj",
}
assert len(tokens) == OtpBypassToken.max_count

with patch.object(
OtpBypassToken, "generate_tokens", return_value=tokens
) as generate_tokens:
response = self.client.post(
self.client.reverse("generate"),
status_code_assertion=status.HTTP_201_CREATED,
)

generate_tokens.assert_called_once()

# We received the expected tokens.
assert set(response.json()) == tokens

# The user's pre-existing tokens were deleted.
assert (
OtpBypassToken.objects.filter(
pk__in=[
otp_bypass_token.pk
for otp_bypass_token in self.otp_bypass_tokens
]
).count()
== 0
)

# The new tokens all check out.
for otp_bypass_token in OtpBypassToken.objects.filter(user=user):
found_token = False
for token in tokens.copy():
found_token = otp_bypass_token.check_token(token)
if found_token:
tokens.remove(token)
break

assert found_token
4 changes: 2 additions & 2 deletions backend/api/tests/views/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
from codeforlife.tests import ModelViewSetTestCase
from codeforlife.user.models import User

from ...serializers import UserSerializer
from ...views import UserViewSet


class TestUserViewSet(ModelViewSetTestCase[UserViewSet, UserSerializer, User]):
class TestUserViewSet(ModelViewSetTestCase[User]):
"""
Base naming convention:
test_{action}
Expand All @@ -20,6 +19,7 @@ class TestUserViewSet(ModelViewSetTestCase[UserViewSet, UserSerializer, User]):
"""

basename = "user"
model_view_set_class = UserViewSet

def test_is_unique_email(self):
"""
Expand Down
8 changes: 7 additions & 1 deletion backend/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@

from rest_framework.routers import DefaultRouter

from .views import AuthFactorViewSet, ClassViewSet, SchoolViewSet, UserViewSet
from .views import (
AuthFactorViewSet,
ClassViewSet,
OtpBypassTokenViewSet,
SchoolViewSet,
UserViewSet,
)

router = DefaultRouter()
router.register(
Expand Down
2 changes: 1 addition & 1 deletion backend/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@

from .auth_factor import AuthFactorViewSet
from .klass import ClassViewSet
from .otp_bypass_token import OtpBypassTokenViewSet
from .school import SchoolViewSet
from .user import UserViewSet
from .otp_bypass_token import OtpBypassTokenViewSet
4 changes: 2 additions & 2 deletions backend/api/views/auth_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@

from codeforlife.permissions import AllowNone
from codeforlife.user.models import AuthFactor, User
from rest_framework.viewsets import ModelViewSet
from codeforlife.views import ModelViewSet

from ..serializers import AuthFactorSerializer


# pylint: disable-next=missing-class-docstring,too-many-ancestors
class AuthFactorViewSet(ModelViewSet):
class AuthFactorViewSet(ModelViewSet[AuthFactor]):
http_method_names = ["get", "post", "delete"]
serializer_class = AuthFactorSerializer

Expand Down
15 changes: 5 additions & 10 deletions backend/api/views/otp_bypass_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
from codeforlife.permissions import AllowNone
from codeforlife.request import Request
from codeforlife.user.models import OtpBypassToken, User
from django.utils.crypto import get_random_string
from codeforlife.user.permissions import IsTeacher
from codeforlife.views import ModelViewSet
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet


# pylint: disable-next=missing-class-docstring,too-many-ancestors
class OtpBypassTokenViewSet(ModelViewSet):
class OtpBypassTokenViewSet(ModelViewSet[OtpBypassToken]):
http_method_names = ["post"]

def get_queryset(self):
Expand All @@ -27,7 +27,7 @@ def get_permissions(self):
if self.action == "create":
return [AllowNone()]

return super().get_permissions()
return [IsTeacher()]

@action(detail=False, methods=["post"])
def generate(self, request: Request):
Expand All @@ -37,12 +37,7 @@ def generate(self, request: Request):

OtpBypassToken.objects.filter(user=user).delete()

tokens = [
get_random_string(
OtpBypassToken.token.max_length # type: ignore[attr-defined]
)
for _ in range(OtpBypassToken.max_count)
]
tokens = OtpBypassToken.generate_tokens()

OtpBypassToken.objects.bulk_create(
[OtpBypassToken(user=user, token=token) for token in tokens]
Expand Down

0 comments on commit 757a773

Please sign in to comment.