diff --git a/backend/Pipfile b/backend/Pipfile index 162707b..07d8745 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -codeforlife = {ref = "v0.8.0", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"} +codeforlife = {ref = "v0.8.3", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"} django = "==3.2.20" djangorestframework = "==3.13.1" django-cors-headers = "==4.1.0" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index 520d772..115a4d6 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "9b5dd7292210fab043685afe18e5fc517103bc85902a80699ebf09b26c760ee3" + "sha256": "35a1f0e3d41bff90adb93acc781bbf0db41767aac9a22db6baadcdcfccd9490c" }, "pipfile-spec": 6, "requires": { @@ -155,7 +155,7 @@ }, "codeforlife": { "git": "https://github.com/ocadotechnology/codeforlife-package-python.git", - "ref": "5fb23069bb2ca1ccd9a1e9e5d3db1841d2758fd1" + "ref": "f11c55524e8ef4a78a07f996978f2a11b553f326" }, "codeforlife-portal": { "hashes": [ @@ -1141,11 +1141,11 @@ }, "platformdirs": { "hashes": [ - "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d", - "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d" + "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3", + "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e" ], "markers": "python_version >= '3.7'", - "version": "==3.10.0" + "version": "==3.11.0" }, "pluggy": { "hashes": [ diff --git a/backend/api/permissions.py b/backend/api/permissions.py new file mode 100644 index 0000000..d82aa0f --- /dev/null +++ b/backend/api/permissions.py @@ -0,0 +1,12 @@ +from codeforlife.user.models import User +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import View + + +class UserHasSessionAuthFactors(BasePermission): + def has_permission(self, request: Request, view: View): + return ( + isinstance(request.user, User) + and request.user.session.session_auth_factors.exists() + ) diff --git a/backend/api/tests/test_views.py b/backend/api/tests/test_views.py index e58dbca..4591149 100644 --- a/backend/api/tests/test_views.py +++ b/backend/api/tests/test_views.py @@ -4,6 +4,7 @@ from codeforlife.tests import CronTestCase from codeforlife.user.models import AuthFactor, User from django.core import management +from django.http import HttpResponse from django.test import TestCase from django.urls import reverse from django.utils import timezone @@ -13,6 +14,15 @@ class TestLoginView(TestCase): def setUp(self): self.user = User.objects.get(id=2) + def _get_session_auth_factors(self, response: HttpResponse): + return [ + auth_factor + for auth_factor in response.cookies[ + "sessionid_httponly_false" + ].value.split(",") + if auth_factor != "" + ] + def test_post__otp(self): AuthFactor.objects.create( user=self.user, @@ -28,9 +38,7 @@ def test_post__otp(self): ) assert response.status_code == 200 - self.assertDictEqual( - response.json(), {"auth_factors": [AuthFactor.Type.OTP]} - ) + assert self._get_session_auth_factors(response) == [AuthFactor.Type.OTP] self.user.userprofile.otp_secret = pyotp.random_base32() self.user.userprofile.save() @@ -45,7 +53,7 @@ def test_post__otp(self): ) assert response.status_code == 200 - self.assertDictEqual(response.json(), {"auth_factors": []}) + assert self._get_session_auth_factors(response) == [] class TestClearExpiredView(CronTestCase): diff --git a/backend/api/urls.py b/backend/api/urls.py index 6df0a75..7218a4a 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,16 +1,28 @@ from django.urls import include, path, re_path -from .views import ClearExpiredView, LoginView +from .views import ClearExpiredView, LoginOptionsView, LoginView urlpatterns = [ path( "session/", include( [ - re_path( - r"^login/(?P
email|username|user-id|otp|otp-bypass-token)/$", - LoginView.as_view(), - name="login", + path( + "login/", + include( + [ + path( + "options/", + LoginOptionsView.as_view(), + name="login-options", + ), + re_path( + r"^(?Pemail|username|user-id|otp|otp-bypass-token)/$", + LoginView.as_view(), + name="login", + ), + ] + ), ), path( "clear-expired/", diff --git a/backend/api/views.py b/backend/api/views.py index 709e324..80728cd 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,13 +1,15 @@ import logging from codeforlife.mixins import CronMixin -from codeforlife.request import HttpRequest +from codeforlife.request import HttpRequest, Request +from codeforlife.user.models import AuthFactor, User from common.models import UserSession +from django.conf import settings from django.contrib.auth import login from django.contrib.auth.views import LoginView as _LoginView from django.contrib.sessions.models import Session, SessionManager from django.core import management -from django.http import JsonResponse +from django.http import HttpResponse, JsonResponse from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -20,6 +22,7 @@ UserIdAuthForm, UsernameAuthForm, ) +from .permissions import UserHasSessionAuthFactors # TODO: add 2FA logic @@ -58,20 +61,52 @@ def form_valid(self, form: BaseAuthForm): # Save session (with data). self.request.session.save() - return JsonResponse( - { - "auth_factors": list( - self.request.user.session.session_auth_factors.values_list( - "auth_factor__type", flat=True - ) + response = HttpResponse() + + # Create a non-HTTP-only session cookie with the pending auth factors. + response.set_cookie( + key="sessionid_httponly_false", + value=",".join( + self.request.user.session.session_auth_factors.values_list( + "auth_factor__type", flat=True ) - } + ), + max_age=( + None + if settings.SESSION_EXPIRE_AT_BROWSER_CLOSE + else settings.SESSION_COOKIE_AGE + ), + secure=settings.SESSION_COOKIE_SECURE, + samesite=settings.SESSION_COOKIE_SAMESITE, + domain=settings.SESSION_COOKIE_DOMAIN, + httponly=False, ) + return response + def form_invalid(self, form: BaseAuthForm): return JsonResponse(form.errors, status=status.HTTP_400_BAD_REQUEST) +class LoginOptionsView(APIView): + http_method_names = ["get"] + permission_classes = [UserHasSessionAuthFactors] + + def get(self, request: Request): + user: User = request.user + session_auth_factors = user.session.session_auth_factors + + response_data = {"id": user.id} + if session_auth_factors.filter( + auth_factor__type=AuthFactor.Type.OTP + ).exists(): + response_data[ + "otp_bypass_token_exists" + ] = user.otp_bypass_tokens.exists() + + return Response(response_data) + + class ClearExpiredView(CronMixin, APIView): def get(self, request): # objects is missing type SessionManager