diff --git a/codeforlife/serializers/base.py b/codeforlife/serializers/base.py index aff2db43..16af791b 100644 --- a/codeforlife/serializers/base.py +++ b/codeforlife/serializers/base.py @@ -8,6 +8,7 @@ import typing as t from django.contrib.auth.models import AnonymousUser +from django.views import View from rest_framework.serializers import BaseSerializer as _BaseSerializer from ..request import Request @@ -39,3 +40,9 @@ def request_anon_user(self): """ return t.cast(AnonymousUser, self.request.user) + + @property + def view(self): + """The view that instantiated this serializer.""" + + return t.cast(View, self.context["view"]) diff --git a/codeforlife/serializers/model.py b/codeforlife/serializers/model.py index 88539bf2..346a93ff 100644 --- a/codeforlife/serializers/model.py +++ b/codeforlife/serializers/model.py @@ -24,12 +24,26 @@ class ModelSerializer( ): """Base model serializer for all model serializers.""" + instance: AnyModel + + @property + def view(self): + # NOTE: import outside top-level to avoid circular imports. + # pylint: disable-next=import-outside-toplevel + from ..views import ModelViewSet + + return t.cast(ModelViewSet[AnyModel], super().view) + # pylint: disable-next=useless-parent-delegation - def update(self, instance, validated_data: t.Dict[str, t.Any]): + def update( + self, + instance: AnyModel, + validated_data: t.Dict[str, t.Any], + ) -> AnyModel: return super().update(instance, validated_data) # pylint: disable-next=useless-parent-delegation - def create(self, validated_data: t.Dict[str, t.Any]): + def create(self, validated_data: t.Dict[str, t.Any]) -> AnyModel: return super().create(validated_data) def validate(self, attrs: t.Dict[str, t.Any]): @@ -61,6 +75,14 @@ class Meta: instance: t.List[AnyModel] batch_size: t.Optional[int] = None + @property + def view(self): + # NOTE: import outside top-level to avoid circular imports. + # pylint: disable-next=import-outside-toplevel + from ..views import ModelViewSet + + return t.cast(ModelViewSet[AnyModel], super().view) + @classmethod def get_model_class(cls) -> t.Type[AnyModel]: """Get the model view set's class. @@ -74,7 +96,10 @@ def get_model_class(cls) -> t.Type[AnyModel]: 0 ] - def create(self, validated_data: t.List[t.Dict[str, t.Any]]): + def create( + self, + validated_data: t.List[t.Dict[str, t.Any]], + ) -> t.List[AnyModel]: """Bulk create many instances of a model. https://www.django-rest-framework.org/api-guide/serializers/#customizing-multiple-create @@ -96,7 +121,7 @@ def update( self, instance: t.List[AnyModel], validated_data: t.List[t.Dict[str, t.Any]], - ): + ) -> t.List[AnyModel]: """Bulk update many instances of a model. https://www.django-rest-framework.org/api-guide/serializers/#customizing-multiple-update diff --git a/codeforlife/user/auth/backends/__init__.py b/codeforlife/user/auth/backends/__init__.py index cbe607b0..7b960c98 100644 --- a/codeforlife/user/auth/backends/__init__.py +++ b/codeforlife/user/auth/backends/__init__.py @@ -1,3 +1,8 @@ +""" +© Ocado Group +Created on 01/02/2024 at 14:48:57(+00:00). +""" + from .email_and_password import EmailAndPasswordBackend from .otp import OtpBackend from .otp_bypass_token import OtpBypassTokenBackend diff --git a/codeforlife/user/auth/backends/email_and_password.py b/codeforlife/user/auth/backends/email_and_password.py index 934fa29b..c1c30e9a 100644 --- a/codeforlife/user/auth/backends/email_and_password.py +++ b/codeforlife/user/auth/backends/email_and_password.py @@ -1,31 +1,40 @@ +""" +© Ocado Group +Created on 01/02/2024 at 14:34:04(+00:00). +""" + import typing as t from django.contrib.auth.backends import BaseBackend -from ....request import WSGIRequest +from ....request import HttpRequest from ...models import User class EmailAndPasswordBackend(BaseBackend): - def authenticate( + """Authenticate a user by checking their email and password.""" + + def authenticate( # type: ignore[override] self, - request: WSGIRequest, + request: t.Optional[HttpRequest], email: t.Optional[str] = None, password: t.Optional[str] = None, **kwargs ): if email is None or password is None: - return + return None try: - user = User.objects.get(email=email) + user = User.objects.get(email__iexact=email) if user.check_password(password): return user except User.DoesNotExist: - return + return None + + return None def get_user(self, user_id: int): try: return User.objects.get(id=user_id) except User.DoesNotExist: - return + return None diff --git a/codeforlife/user/auth/backends/otp.py b/codeforlife/user/auth/backends/otp.py index 40c2f565..1c289ecc 100644 --- a/codeforlife/user/auth/backends/otp.py +++ b/codeforlife/user/auth/backends/otp.py @@ -1,17 +1,24 @@ +""" +© Ocado Group +Created on 01/02/2024 at 14:41:20(+00:00). +""" + import typing as t import pyotp from django.contrib.auth.backends import BaseBackend from django.utils import timezone -from ....request import WSGIRequest +from ....request import HttpRequest from ...models import AuthFactor, User class OtpBackend(BaseBackend): - def authenticate( + """Check a user's multi-factor OTP authentication.""" + + def authenticate( # type: ignore[override] self, - request: WSGIRequest, + request: t.Optional[HttpRequest], otp: t.Optional[str] = None, **kwargs, ): @@ -20,13 +27,14 @@ def authenticate( if ( otp is None + or request is None or not isinstance(request.user, User) or not request.user.userprofile.otp_secret or not request.user.session.session_auth_factors.filter( auth_factor__type=AuthFactor.Type.OTP ).exists() ): - return + return None totp = pyotp.TOTP(request.user.userprofile.otp_secret) @@ -35,7 +43,7 @@ def authenticate( # Deny replay attacks by rejecting the otp for last time. last_otp_for_time = request.user.userprofile.last_otp_for_time if last_otp_for_time and totp.verify(otp, last_otp_for_time): - return + return None request.user.userprofile.last_otp_for_time = now request.user.userprofile.save() @@ -46,8 +54,10 @@ def authenticate( return request.user + return None + def get_user(self, user_id: int): try: return User.objects.get(id=user_id) except User.DoesNotExist: - return + return None diff --git a/codeforlife/user/auth/backends/otp_bypass_token.py b/codeforlife/user/auth/backends/otp_bypass_token.py index 3e20cfde..3ed07281 100644 --- a/codeforlife/user/auth/backends/otp_bypass_token.py +++ b/codeforlife/user/auth/backends/otp_bypass_token.py @@ -1,26 +1,36 @@ +""" +© Ocado Group +Created on 01/02/2024 at 14:39:16(+00:00). +""" + import typing as t from django.contrib.auth.backends import BaseBackend -from ....request import WSGIRequest +from ....request import HttpRequest from ...models import AuthFactor, User class OtpBypassTokenBackend(BaseBackend): - def authenticate( + """ + Bypass a user's multi-factor OTP authentication with a single-use token. + """ + + def authenticate( # type: ignore[override] self, - request: WSGIRequest, + request: t.Optional[HttpRequest], token: t.Optional[str] = None, **kwargs, ): if ( token is None + or request is None or not isinstance(request.user, User) or not request.user.session.session_auth_factors.filter( auth_factor__type=AuthFactor.Type.OTP ).exists() ): - return + return None for otp_bypass_token in request.user.otp_bypass_tokens.all(): if otp_bypass_token.check_token(token): @@ -31,8 +41,10 @@ def authenticate( return request.user + return None + def get_user(self, user_id: int): try: return User.objects.get(id=user_id) except User.DoesNotExist: - return + return None diff --git a/codeforlife/user/auth/backends/user_id_and_login_id.py b/codeforlife/user/auth/backends/user_id_and_login_id.py index 15da47ca..9e711b91 100644 --- a/codeforlife/user/auth/backends/user_id_and_login_id.py +++ b/codeforlife/user/auth/backends/user_id_and_login_id.py @@ -1,23 +1,30 @@ +""" +© Ocado Group +Created on 01/02/2024 at 14:44:16(+00:00). +""" + import typing as t from common.helpers.generators import get_hashed_login_id from common.models import Student from django.contrib.auth.backends import BaseBackend -from ....request import WSGIRequest +from ....request import HttpRequest from ...models import User class UserIdAndLoginIdBackend(BaseBackend): - def authenticate( + """Authenticate a student using their ID and auto-generated password.""" + + def authenticate( # type: ignore[override] self, - request: WSGIRequest, + request: t.Optional[HttpRequest], user_id: t.Optional[int] = None, login_id: t.Optional[str] = None, **kwargs ): if user_id is None or login_id is None: - return + return None user = self.get_user(user_id) if user: @@ -30,8 +37,10 @@ def authenticate( ): return user + return None + def get_user(self, user_id: int): try: return User.objects.get(id=user_id) except User.DoesNotExist: - return + return None diff --git a/codeforlife/user/auth/backends/username_and_password_and_class_id.py b/codeforlife/user/auth/backends/username_and_password_and_class_id.py index 0a1c8cd1..b80a761d 100644 --- a/codeforlife/user/auth/backends/username_and_password_and_class_id.py +++ b/codeforlife/user/auth/backends/username_and_password_and_class_id.py @@ -1,22 +1,29 @@ +""" +© Ocado Group +Created on 01/02/2024 at 14:48:17(+00:00). +""" + import typing as t from django.contrib.auth.backends import BaseBackend -from ....request import WSGIRequest +from ....request import HttpRequest from ...models import User class UsernameAndPasswordAndClassIdBackend(BaseBackend): - def authenticate( + """Authenticate a student using their username, password and class ID.""" + + def authenticate( # type: ignore[override] self, - request: WSGIRequest, + request: t.Optional[HttpRequest], username: t.Optional[str] = None, password: t.Optional[str] = None, class_id: t.Optional[str] = None, **kwargs ): if username is None or password is None or class_id is None: - return + return None try: user = User.objects.get( @@ -26,10 +33,12 @@ def authenticate( if user.check_password(password): return user except User.DoesNotExist: - return + return None + + return None def get_user(self, user_id: int): try: return User.objects.get(id=user_id) except User.DoesNotExist: - return + return None