Skip to content

Commit

Permalink
fix: Portal frontend 40 (#391)
Browse files Browse the repository at this point in the history
* fix: generate and list otp bypass tokens

* fix: auto-create otp bypass tokens when enabling otp

* merge from main

* new cfl package

* fix: new cfl package
  • Loading branch information
SKairinos authored Feb 26, 2025
1 parent 7c59749 commit 70a189b
Show file tree
Hide file tree
Showing 9 changed files with 541 additions and 230 deletions.
4 changes: 2 additions & 2 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ name = "pypi"
# 5. Run `pipenv install --dev` in your terminal.

[packages]
codeforlife = "==0.25.3"
codeforlife = "==0.25.4"
# 🚫 Don't add [packages] below that are inherited from the CFL package.
pyjwt = "==2.6.0" # TODO: upgrade to latest version
# TODO: Needed by RR. Remove when RR has moved to new system.
Expand All @@ -32,7 +32,7 @@ django-sekizai = "==4.1.0"
django-classy-tags = "==4.1.0"

[dev-packages]
codeforlife = {version = "==0.25.3", extras = ["dev"]}
codeforlife = {version = "==0.25.4", extras = ["dev"]}
# codeforlife = {file = "../codeforlife-package-python", editable = true, extras = ["dev"]}
# 🚫 Don't add [dev-packages] below that are inherited from the CFL package.

Expand Down
646 changes: 438 additions & 208 deletions Pipfile.lock

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@

secrets = set_up_settings(BASE_DIR, service_name="portal")

# pylint: disable-next=wrong-import-position,wildcard-import,unused-wildcard-import
from codeforlife.settings import *

SECRET_KEY = secrets.SECRET_KEY

# ------------------------------------------------------------------------------
# TODO: Clean settings below
# ------------------------------------------------------------------------------
Expand Down Expand Up @@ -80,7 +85,10 @@

if os.environ.get("STATIC_MODE", "") == "pipeline":
STATICFILES_FINDERS = ["pipeline.finders.PipelineFinder"]
STATICFILES_STORAGE = "pipeline.storage.PipelineStorage"
STORAGES = {
**STORAGES, # type: ignore[has-type]
"staticfiles": {"BACKEND": "pipeline.storage.PipelineStorage"},
}

PIPELINE = {
"COMPILERS": ("portal.pipeline_compilers.LibSassCompiler",),
Expand Down Expand Up @@ -251,8 +259,6 @@ def domain():
# TODO: Clean settings above
# ------------------------------------------------------------------------------

# pylint: disable-next=wrong-import-position,wildcard-import,unused-wildcard-import
from codeforlife.settings import *

ROOT_URLCONF = "src.urls"

Expand Down
1 change: 1 addition & 0 deletions src/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .auth_factor import AuthFactorSerializer
from .klass import ReadClassSerializer, WriteClassSerializer
from .otp_bypass_token import OtpBypassTokenSerializer
from .school import SchoolSerializer
from .school_teacher_invitation import (
AcceptSchoolTeacherInvitationSerializer,
Expand Down
9 changes: 7 additions & 2 deletions src/api/serializers/auth_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import re

from codeforlife.serializers import ModelSerializer
from codeforlife.user.models import AuthFactor, User
from codeforlife.user.models import AuthFactor, OtpBypassToken, User
from rest_framework import serializers

# pylint: disable=missing-class-docstring
Expand Down Expand Up @@ -62,4 +62,9 @@ def validate(self, attrs):
def create(self, validated_data):
validated_data["user_id"] = self.request.auth_user.id
validated_data.pop("otp", None)
return super().create(validated_data)
auth_factor = super().create(validated_data)

if auth_factor.type == AuthFactor.Type.OTP:
OtpBypassToken.objects.bulk_create(auth_factor.user)

return auth_factor
9 changes: 8 additions & 1 deletion src/api/serializers/auth_factor_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
from unittest.mock import Mock, patch

from codeforlife.tests import ModelSerializerTestCase
from codeforlife.user.models import AuthFactor, TeacherUser, User
from codeforlife.user.models import (
AuthFactor,
OtpBypassToken,
TeacherUser,
User,
)

from .auth_factor import AuthFactorSerializer

Expand Down Expand Up @@ -99,3 +104,5 @@ def test_create__otp(self):
new_data={"user": user.id},
context={"request": self.request_factory.post(user=user)},
)

assert user.otp_bypass_tokens.count() == OtpBypassToken.max_count
17 changes: 17 additions & 0 deletions src/api/serializers/otp_bypass_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""
© Ocado Group
Created on 20/02/2025 at 15:23:05(+00:00).
"""

from codeforlife.serializers import ModelSerializer
from codeforlife.user.models import OtpBypassToken, User

# pylint: disable=missing-class-docstring
# pylint: disable=too-many-ancestors


class OtpBypassTokenSerializer(ModelSerializer[User, OtpBypassToken]):
class Meta:
model = OtpBypassToken
fields = ["decrypted_token"]
extra_kwargs = {"decrypted_token": {"read_only": True}}
24 changes: 14 additions & 10 deletions src/api/views/otp_bypass_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,34 @@

from codeforlife.permissions import AllowNone
from codeforlife.request import Request
from codeforlife.user.models import OtpBypassToken, User
from codeforlife.user.models import AuthFactor, OtpBypassToken, User
from codeforlife.user.permissions import IsTeacher
from codeforlife.views import ModelViewSet, action
from rest_framework import status
from rest_framework.response import Response

from ..permissions import HasAuthFactor
from ..serializers import OtpBypassTokenSerializer


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

# pylint: disable-next=missing-function-docstring
def get_permissions(self):
if self.action in ["create", "bulk"]:
if self.action in ["retrieve", "create", "bulk"]:
return [AllowNone()]

return [IsTeacher()]
return [IsTeacher(), HasAuthFactor(AuthFactor.Type.OTP)]

# pylint: disable-next=missing-function-docstring
def get_queryset(self):
return OtpBypassToken.objects.filter(user=self.request.auth_user)

# TODO: replace this custom action with bulk create and list serializer.
@action(detail=False, methods=["post"])
def generate(self, request: Request):
"""
Expand All @@ -37,9 +44,6 @@ def generate(self, request: Request):
otp_bypass_tokens = OtpBypassToken.objects.bulk_create(
request.auth_user
)
serializer = self.serializer_class(otp_bypass_tokens, many=True)

return Response(
# pylint: disable-next=protected-access
[otp_bypass_token._token for otp_bypass_token in otp_bypass_tokens],
status.HTTP_201_CREATED,
)
return Response(serializer.data, status.HTTP_201_CREATED)
49 changes: 45 additions & 4 deletions src/api/views/otp_bypass_token_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@

from codeforlife.permissions import AllowNone
from codeforlife.tests import ModelViewSetTestCase
from codeforlife.user.models import OtpBypassToken, User
from codeforlife.user.models import AuthFactor, OtpBypassToken, User
from codeforlife.user.permissions import IsTeacher
from rest_framework import status

from ..permissions import HasAuthFactor
from .otp_bypass_token import OtpBypassTokenViewSet


Expand All @@ -27,6 +28,23 @@ def setUp(self):

# test: get permissions

def test_get_permissions__retrieve(self):
"""No one can retrieve a single otp-bypass-token."""
self.assert_get_permissions(
permissions=[AllowNone()],
action="retrieve",
)

def test_get_permissions__list(self):
"""
Only teachers who have enabled OTP as an auth factor can list
otp-bypass-tokens.
"""
self.assert_get_permissions(
permissions=[IsTeacher(), HasAuthFactor(AuthFactor.Type.OTP)],
action="list",
)

def test_get_permissions__create(self):
"""No one can create a single otp-bypass-token."""
self.assert_get_permissions(
Expand All @@ -42,14 +60,32 @@ def test_get_permissions__bulk(self):
)

def test_get_permissions__generate(self):
"""Only teachers can generate otp-bypass-tokens."""
"""
Only teachers who have enabled OTP as an auth factor can generate
otp-bypass-tokens.
"""
self.assert_get_permissions(
permissions=[IsTeacher()],
permissions=[IsTeacher(), HasAuthFactor(AuthFactor.Type.OTP)],
action="generate",
)

# test: get queryset

def test_get_queryset__list(self):
"""Users can only list their own OTP bypass tokens."""
self.assert_get_queryset(
values=self.user.otp_bypass_tokens.all(),
action="list",
request=self.client.request_factory.get(user=self.user),
)

# test: actions

def test_list(self):
"""Can list a user's OTP bypass tokens."""
self.client.login(email=self.user.email, password="password")
self.client.list(self.user.otp_bypass_tokens.all())

def test_generate(self):
"""Generate max number of OTP bypass tokens."""
otp_bypass_token_pks = list(
Expand Down Expand Up @@ -91,7 +127,12 @@ def test_generate(self):
)

# We received the expected tokens.
assert set(response.json()) == tokens
response_json = response.json()
assert isinstance(response_json, list) and tokens == {
otp_bypass_token["decrypted_token"]
for otp_bypass_token in response_json
if isinstance(otp_bypass_token, dict)
}

# The user's pre-existing tokens were deleted.
assert not OtpBypassToken.objects.filter(
Expand Down

0 comments on commit 70a189b

Please sign in to comment.