Skip to content

Commit

Permalink
Verify email (#328)
Browse files Browse the repository at this point in the history
* fix: verify email address

* on push

* fix lint error

* ignore private files

* test

* test again

* catch decode error

* add cron jobs
  • Loading branch information
SKairinos authored May 13, 2024
1 parent 98d9848 commit a79203e
Show file tree
Hide file tree
Showing 13 changed files with 589 additions and 345 deletions.
5 changes: 1 addition & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
name: Main

on:
pull_request:
push:
paths-ignore:
- "**/*.md"
- "**/.*"
pull_request:
workflow_dispatch:

env:
Expand Down
6 changes: 4 additions & 2 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

## ℹ️ HOW-TO: Make the python-package editable.
#
# 1. Comment out the git-codeforlife package under [packages].
Expand All @@ -22,7 +23,7 @@ name = "pypi"
# 5. Run `pipenv install --dev` in your terminal.

[packages]
codeforlife = {ref = "v0.16.7", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
codeforlife = {ref = "v0.16.8", git = "https://github.com/ocadotechnology/codeforlife-package-python.git"}
# 🚫 Don't add [packages] below that are inhertited from the CFL package.
# TODO: check if we need the below packages
whitenoise = "==6.5.0"
Expand All @@ -45,9 +46,10 @@ google-cloud-logging = "==1.*"
google-auth = "==2.*"
google-cloud-container = "==2.3.0"
# "django-anymail[amazon_ses]" = "==7.0.*"
pyjwt = "==2.6.0" # TODO: upgrade to latest version

[dev-packages]
codeforlife = {ref = "v0.16.7", git = "https://github.com/ocadotechnology/codeforlife-package-python.git", extras = ["dev"]}
codeforlife = {ref = "v0.16.8", git = "https://github.com/ocadotechnology/codeforlife-package-python.git", extras = ["dev"]}
# codeforlife = {file = "../../codeforlife-package-python", editable = true, extras = ["dev"]}
# 🚫 Don't add [dev-packages] below that are inhertited from the CFL package.
# TODO: check if we need the below packages
Expand Down
10 changes: 7 additions & 3 deletions backend/Pipfile.lock

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

9 changes: 9 additions & 0 deletions backend/api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""
© Ocado Group
Created on 10/05/2024 at 14:37:11(+01:00).
"""

from .token_generators import (
email_verification_token_generator,
password_reset_token_generator,
)
82 changes: 82 additions & 0 deletions backend/api/auth/token_generators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""
© Ocado Group
Created on 10/05/2024 at 14:37:36(+01:00).
"""

import typing as t
from datetime import timedelta

import jwt
from codeforlife.user.models import User
from django.conf import settings
from django.contrib.auth.tokens import (
PasswordResetTokenGenerator,
default_token_generator,
)
from django.utils import timezone

# NOTE: type hint to help Intellisense.
password_reset_token_generator: PasswordResetTokenGenerator = (
default_token_generator
)


class EmailVerificationTokenGenerator:
"""Custom token generator used to verify a user's email address."""

def _get_audience(self, user_or_pk: t.Union[User, t.Any]):
pk = user_or_pk.pk if isinstance(user_or_pk, User) else user_or_pk
return f"user:{pk}"

def make_token(self, user_or_pk: t.Union[User, t.Any]):
"""Generate a token used to verify user's email address.
https://pyjwt.readthedocs.io/en/stable/usage.html
Args:
user: The user to generate a token for.
Returns:
A token used to verify user's email address.
"""
return jwt.encode(
payload={
"exp": (
timezone.now()
+ timedelta(seconds=settings.EMAIL_VERIFICATION_TIMEOUT)
),
"aud": [self._get_audience(user_or_pk)],
},
key=settings.SECRET_KEY,
algorithm="HS256",
)

def check_token(self, user_or_pk: t.Union[User, t.Any], token: str):
"""Check the token belongs to the user and has not expired.
Args:
user: The user to check.
token: The token to check.
Returns:
A flag designating whether the token belongs to the user and has not
expired.
"""
try:
jwt.decode(
jwt=token,
key=settings.SECRET_KEY,
audience=self._get_audience(user_or_pk),
algorithms=["HS256"],
)
except (
jwt.DecodeError,
jwt.ExpiredSignatureError,
jwt.InvalidAudienceError,
):
return False

return True


email_verification_token_generator = EmailVerificationTokenGenerator()
1 change: 1 addition & 0 deletions backend/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@
RequestUserPasswordResetSerializer,
ResetUserPasswordSerializer,
UpdateUserSerializer,
VerifyUserEmailAddressSerializer,
)
42 changes: 33 additions & 9 deletions backend/api/serializers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,15 @@
from django.contrib.auth.password_validation import (
validate_password as _validate_password,
)
from django.contrib.auth.tokens import (
PasswordResetTokenGenerator,
default_token_generator,
)
from django.core.exceptions import ValidationError as CoreValidationError
from django.utils import timezone
from rest_framework import serializers

# NOTE: type hint to help Intellisense.
password_reset_token_generator: PasswordResetTokenGenerator = (
default_token_generator
from ..auth import (
email_verification_token_generator,
password_reset_token_generator,
)


# pylint: disable=missing-class-docstring
# pylint: disable=too-many-ancestors
# pylint: disable=missing-function-docstring
Expand Down Expand Up @@ -294,7 +289,7 @@ def update(self, instance, validated_data):
return instance


class RequestUserPasswordResetSerializer(_UserSerializer):
class RequestUserPasswordResetSerializer(_UserSerializer[User]):
class Meta(_UserSerializer.Meta):
extra_kwargs = {
**_UserSerializer.Meta.extra_kwargs,
Expand Down Expand Up @@ -358,3 +353,32 @@ def update(self, instance: User, validated_data: DataDict):
instance.save(update_fields=["password"])

return instance


class VerifyUserEmailAddressSerializer(_UserSerializer[User]):
token = serializers.CharField(write_only=True)

class Meta(_UserSerializer.Meta):
fields = [*_UserSerializer.Meta.fields, "token"]

def validate_token(self, value: str):
if not self.instance:
raise serializers.ValidationError(
"Can only verify the email address of an existing user.",
code="user_does_not_exist",
)
if not email_verification_token_generator.check_token(
self.instance, value
):
raise serializers.ValidationError(
"Does not match the given user.",
code="does_not_match",
)

return value

def update(self, instance, validated_data):
instance.userprofile.is_verified = True
instance.userprofile.save(update_fields=["is_verified"])

return instance
46 changes: 36 additions & 10 deletions backend/api/serializers/user_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,18 @@
User,
)
from django.contrib.auth.hashers import make_password
from django.contrib.auth.tokens import (
PasswordResetTokenGenerator,
default_token_generator,
)

from ..auth import password_reset_token_generator
from .user import (
BaseUserSerializer,
CreateUserSerializer,
HandleIndependentUserJoinClassRequestSerializer,
RequestUserPasswordResetSerializer,
ResetUserPasswordSerializer,
UpdateUserSerializer,
VerifyUserEmailAddressSerializer,
)

# NOTE: type hint to help Intellisense.
password_reset_token_generator: PasswordResetTokenGenerator = (
default_token_generator
)


# pylint: disable=missing-class-docstring


Expand Down Expand Up @@ -424,3 +416,37 @@ def test_update(self):

user_make_password.assert_called_once_with(password)
assert self.user.check_password(password)


class TestVerifyUserEmailAddressSerializer(ModelSerializerTestCase[User, User]):
model_serializer_class = VerifyUserEmailAddressSerializer
# fixtures = ["school_1"]

def setUp(self):
user = User.objects.filter(userprofile__is_verified=False).first()
assert user
self.user = user

def test_validate_token__user_does_not_exist(self):
"""Cannot validate the token of a user that does not exist."""
self.assert_validate_field(
name="token",
error_code="user_does_not_exist",
)

def test_validate_token__does_not_match(self):
"""The token must match the user's tokens."""
self.assert_validate_field(
name="token",
error_code="does_not_match",
value="invalid-token",
instance=self.user,
)

def test_update(self):
"""Can successfully reset a user's password."""
self.assert_update(
instance=self.user,
validated_data={},
new_data={"userprofile": {"is_verified": True}},
)
30 changes: 0 additions & 30 deletions backend/api/views/cron/__init__.py

This file was deleted.

Loading

0 comments on commit a79203e

Please sign in to comment.