Skip to content

Commit

Permalink
feat: otp (#8)
Browse files Browse the repository at this point in the history
* initial

* initial code

* quick save

* install portal instead

* auth backends

* tidy up auth backends

* update auth backends

* quick save

* deploy to gcloud

* fix pipeline

* don't check migrations

* use correct service name

* no pytest

* use latest cfl packages

* quick save

* include a base url for service routing

* set base route

* use cfl package

* tests and remove user import

* session config

* quick save

* remove logout endpoint

* login working

* set session cookie domain

* return invalid form errors

* login middleware

* simplify code

* remove  login middleware

* remove extra white spacing

* update launch

* use new cfl package

* fix pipeline

* raise validation errors

* remove todos

* use latest package version

* use latest cfl package

* set secret key

* new cfl package

* fix: set env vars

* use new cfl package

* user new cfl-common package

* house keeping [skip ci]

* use latest cfl package

* feedback

* remove unnecessary return types

* return remaining session auth factors

* codeforlife.user

* save session data before response

* return auth factors

* update readme

* merge from development

* update lock

* support backup token authentication

* test otp

* remove users expired sessions

* update lock

* fix mock

* use new cfl package

* remove todo
  • Loading branch information
SKairinos authored Oct 2, 2023
1 parent 4ae6026 commit 276e681
Show file tree
Hide file tree
Showing 9 changed files with 118 additions and 39 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# codeforlife-service-template
# codeforlife-sso

[Frontend Docs](docs/frontend)
This repo contains CFL's Single Sign-On (SSO) service. This will be responsible for authenticating users.
6 changes: 3 additions & 3 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ verify_ssl = true
name = "pypi"

[packages]
codeforlife = {ref = "v0.7.14", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
codeforlife = {ref = "v0.8.0", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
django = "==3.2.20"
djangorestframework = "==3.13.1"
django-cors-headers = "==4.1.0"
# https://pypi.org/user/codeforlife/
cfl-common = "==6.36.2" # TODO: remove
codeforlife-portal = "==6.36.2" # TODO: remove
cfl-common = "==6.37.1" # TODO: remove
codeforlife-portal = "==6.37.1" # TODO: remove
aimmo = "==2.10.6" # TODO: remove
rapid-router = "==5.11.3" # TODO: remove
phonenumbers = "==8.12.12" # TODO: remove
Expand Down
48 changes: 28 additions & 20 deletions backend/Pipfile.lock

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

7 changes: 7 additions & 0 deletions backend/api/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ def get_invalid_login_error_message(self):
return "Please enter the correct one-time password."


class OtpBypassTokenAuthForm(BaseAuthForm):
token = forms.CharField(min_length=8, max_length=8)

def get_invalid_login_error_message(self):
return "Must be exactly 8 characters. A token can only be used once."


class EmailAuthForm(BaseAuthForm):
email = forms.EmailField()
password = forms.CharField(strip=False)
Expand Down
55 changes: 49 additions & 6 deletions backend/api/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,55 @@
from unittest.mock import Mock, patch
from unittest.mock import patch

import pyotp
from codeforlife.tests import CronTestCase
from codeforlife.user.models import AuthFactor, User
from django.core import management
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone


class TestClearExpiredView(CronTestCase):
@patch("django.core.management.call_command")
def test_clear_expired_view(self, call_command: Mock):
self.client.get(reverse("clear-expired-sessions"))
class TestLoginView(TestCase):
def setUp(self):
self.user = User.objects.get(id=2)

def test_post__otp(self):
AuthFactor.objects.create(
user=self.user,
type=AuthFactor.Type.OTP,
)

response = self.client.post(
reverse("login", kwargs={"form": "email"}),
data={
"email": self.user.email,
"password": "Password1",
},
)

assert response.status_code == 200
self.assertDictEqual(
response.json(), {"auth_factors": [AuthFactor.Type.OTP]}
)

self.user.userprofile.otp_secret = pyotp.random_base32()
self.user.userprofile.save()

call_command.assert_called_once_with("clearsessions")
totp = pyotp.TOTP(self.user.userprofile.otp_secret)

now = timezone.now()
with patch.object(timezone, "now", return_value=now):
response = self.client.post(
reverse("login", kwargs={"form": "otp"}),
data={"otp": totp.at(now)},
)

assert response.status_code == 200
self.assertDictEqual(response.json(), {"auth_factors": []})


class TestClearExpiredView(CronTestCase):
def test_clear_expired_view(self):
with patch.object(management, "call_command") as call_command:
self.client.get(reverse("clear-expired-sessions"))
call_command.assert_called_once_with("clearsessions")
2 changes: 1 addition & 1 deletion backend/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
include(
[
re_path(
r"^login/(?P<form>email|username|user-id|otp)/$",
r"^login/(?P<form>email|username|user-id|otp|otp-bypass-token)/$",
LoginView.as_view(),
name="login",
),
Expand Down
31 changes: 26 additions & 5 deletions backend/api/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import logging

from codeforlife.mixins import CronMixin
from codeforlife.request import HttpRequest
from common.models import UserSession
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.management import call_command
from django.http import HttpResponse, JsonResponse
from django.core import management
from django.http import JsonResponse
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
Expand All @@ -15,13 +16,16 @@
BaseAuthForm,
EmailAuthForm,
OtpAuthForm,
OtpBypassTokenAuthForm,
UserIdAuthForm,
UsernameAuthForm,
)


# TODO: add 2FA logic
class LoginView(_LoginView):
request: HttpRequest

def get_form_class(self):
form = self.kwargs["form"]
if form == "email":
Expand All @@ -30,10 +34,16 @@ def get_form_class(self):
return UsernameAuthForm
elif form == "user-id":
return UserIdAuthForm
elif form == "otp": # TODO: add 2fa logic.
elif form == "otp":
return OtpAuthForm
elif form == "otp-bypass-token":
return OtpBypassTokenAuthForm

def form_valid(self, form: BaseAuthForm):
# Clear expired sessions.
self.request.session.clear_expired(form.user.id)

# Create session (without data).
login(self.request, form.user)

# TODO: use google analytics
Expand All @@ -45,7 +55,18 @@ def form_valid(self, form: BaseAuthForm):
)
UserSession.objects.create(**user_session)

return HttpResponse()
# 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
)
)
}
)

def form_invalid(self, form: BaseAuthForm):
return JsonResponse(form.errors, status=status.HTTP_400_BAD_REQUEST)
Expand All @@ -61,7 +82,7 @@ def get(self, request):

# Clears expired sessions.
# https://docs.djangoproject.com/en/3.2/ref/django-admin/#clearsessions
call_command("clearsessions")
management.call_command("clearsessions")

after_session_count = session_objects.count()
logging.info(f"Session count after clearance: {after_session_count}")
Expand Down
2 changes: 1 addition & 1 deletion backend/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ env_variables:
SECRET_KEY: "REPLACE_ME"
SERVICE_NAME: "REPLACE_ME"
SERVICE_PROTOCOL: "https"
SERVICE_DOMAIN: "codeforlife.education"
SERVICE_DOMAIN: "www.codeforlife.education"
SERVICE_PORT: "443"
2 changes: 1 addition & 1 deletion backend/service/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"codeforlife",
"codeforlife.user",
"api",
"aimmo", # TODO: remove this
"game", # TODO: remove this
Expand Down

0 comments on commit 276e681

Please sign in to comment.