Skip to content

Commit

Permalink
Merge pull request #880 from to-sta/account_methods
Browse files Browse the repository at this point in the history
updated signup, login and added pwreset API
  • Loading branch information
andrewtavis committed Jun 9, 2024
2 parents 567ba99 + b2b2d8d commit 600d6e7
Show file tree
Hide file tree
Showing 18 changed files with 302 additions and 61 deletions.
7 changes: 7 additions & 0 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,10 @@ DATABASE_PASSWORD="postgres"

SECRET_KEY='secret'
DEBUG='1'

ACTIVIST_EMAIL="noreply@activist.org"
EMAIL_HOST="smtp.activist.org"
EMAIl_PORT="587"
EMAIL_HOST_USER="activst@activst.org"
EMAIL_HOST_PASSWORD="activist123!?"
EMAIL_USE_TLS="True"
2 changes: 1 addition & 1 deletion backend/authentication/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ class Meta:
description = factory.Faker("text", max_nb_chars=500)
verified = factory.Faker("boolean")
verification_method = factory.Faker("word")
verification_code = factory.Faker("uuid4")
email = factory.Faker("email")
social_links = factory.List([factory.Faker("user_name") for _ in range(3)])
is_private = factory.Faker("boolean")
is_high_risk = factory.Faker("boolean")
creation_date = factory.Faker("date_time_this_decade", before_now=True)
deletion_date = factory.Faker("date_time_this_decade", before_now=False)
plaintext_password = factory.PostGenerationMethodCall("set_password", "password")

# Workaround for the build method
Expand Down
23 changes: 12 additions & 11 deletions backend/authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
Models for the authentication app.
"""

from __future__ import annotations

from typing import Any
from uuid import uuid4

from django.contrib.auth.models import (
AbstractUser,
BaseUserManager,
PermissionsMixin,
User,
)
from django.contrib.postgres.fields import ArrayField
from django.db import models
Expand Down Expand Up @@ -42,7 +43,7 @@ def __str__(self) -> str:
return f"{self.id}"


class CustomAccountManager(BaseUserManager[User]):
class CustomAccountManager(BaseUserManager["UserModel"]):
def create_superuser(
self,
email: str,
Expand All @@ -63,16 +64,15 @@ def create_superuser(

def create_user(
self,
email: str,
username: str,
password: str,
**other_fields: bool,
) -> User:
if not email:
raise ValueError(("You must provide an email address"))
email: str = "",
**other_fields: Any,
) -> UserModel:
if email != "":
email = self.normalize_email(email)

email = self.normalize_email(email)
user: User = self.model(email=email, username=username, **other_fields)
user = self.model(email=email, username=username, **other_fields)
user.set_password(password)
user.save()
return user
Expand All @@ -92,7 +92,9 @@ class UserModel(AbstractUser, PermissionsMixin):
icon_url = models.ForeignKey(
"content.Image", on_delete=models.SET_NULL, blank=True, null=True
)
email = models.EmailField(unique=True)
verifictaion_code = models.UUIDField(blank=True, null=True)
email = models.EmailField(blank=True)
is_confirmed = models.BooleanField(default=False)
social_links = ArrayField(models.CharField(max_length=255), blank=True, null=True)
is_private = models.BooleanField(default=False)
is_high_risk = models.BooleanField(default=False)
Expand All @@ -105,7 +107,6 @@ class UserModel(AbstractUser, PermissionsMixin):
objects = CustomAccountManager() # type: ignore

USERNAME_FIELD = "username"
REQUIRED_FIELDS = ["email"]

def __str__(self) -> str:
return self.username
Expand Down
58 changes: 40 additions & 18 deletions backend/authentication/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from typing import Any, Dict, Union

from django.contrib.auth import authenticate, get_user_model
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from rest_framework import serializers
from rest_framework.authtoken.models import Token
Expand Down Expand Up @@ -98,15 +97,13 @@ class Meta:
fields = "__all__"


class SignupSerializer(serializers.ModelSerializer[User]):
class SignupSerializer(serializers.ModelSerializer[UserModel]):
password_confirmed = serializers.CharField(write_only=True)

class Meta:
model = USER
fields = ("username", "password", "password_confirmed", "email")
extra_kwargs = {
"password": {"write_only": True},
}
extra_kwargs = {"password": {"write_only": True}, "email": {"required": False}}

def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any]]:
pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()_+{}\[\]:;<>,.?~\\-]).{12,}$"
Expand All @@ -127,44 +124,69 @@ def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any

return data

def create(self, validated_data: Dict[str, Union[str, Any]]) -> User:
def create(self, validated_data: Dict[str, Union[str, Any]]) -> UserModel:
validated_data.pop("password_confirmed")

user = UserModel.objects.create_user(
username=validated_data["username"],
password=validated_data["password"],
email=validated_data["email"],
)
user: UserModel = UserModel.objects.create_user(**validated_data)
user.save()

return user


class LoginSerializer(serializers.Serializer[UserModel]):
username = serializers.CharField()
email = serializers.EmailField(required=False)
username = serializers.CharField(required=False)
password = serializers.CharField(write_only=True)

def validate(self, data: Dict[str, Union[str, Any]]) -> Dict[str, Union[str, Any]]:
username = UserModel.objects.filter(username=data.get("username")).first()
if not data.get("email"):
user = UserModel.objects.filter(username=data.get("username")).first()
else:
user = UserModel.objects.filter(email=data.get("email")).first()

if username is None:
if user is None:
raise serializers.ValidationError(
("Invalid credentials. Please try again."),
code="invalid_credentials",
)

user = authenticate(
username=username,
authenticated_user: UserModel = authenticate(
username=user,
password=data.get("password"),
)
) # type: ignore

if user is None:
if authenticated_user is None:
raise serializers.ValidationError(
("Invalid credentials. Please try again."),
code="invalid_credentials",
)

if authenticated_user.email != "" and authenticated_user.is_confirmed is False:
raise serializers.ValidationError(
("Please confirm your email address."),
code="email_not_confirmed",
)

data["user"] = authenticated_user

token, _ = Token.objects.get_or_create(user=user)
data["token"] = token.key
data["user"] = user

return data


class PasswordResetSerializer(serializers.Serializer[UserModel]):
email = serializers.EmailField()
password = serializers.CharField(write_only=True)

def validate(self, data: Dict[str, Union[str, Any]]) -> UserModel:
user = UserModel.objects.filter(email=data.get("email")).first()

if user is None:
raise serializers.ValidationError(
_("Invalid email address. Please try again."),
code="invalid_email",
)

return user
Empty file.
15 changes: 15 additions & 0 deletions backend/authentication/templates/pwreset_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Password Reset</title>
</head>
<body>
<p>Hi {{username}},</p>
<p>We have received a request to reset your password. To proceed with the password reset, please click on the link below:</p>
<p><a href="{{pwreset_link}}">Reset Password</a></p>
<p>If you did not request a password reset, then it's safe to ignore this email.</p>
<p>Best regards,</p>
<p>Your activist team</p>
</body>
</html>
17 changes: 17 additions & 0 deletions backend/authentication/templates/signup_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>Signup Confirmation</title>
</head>
<body>
<p>Hi {{username}},</p>

<p>Thank you for signing up!</p>

<p>Please click the button below to confirm your email address:</p>
<a href="{{confirmation_link}}" style="display: inline-block; padding: 10px 20px; background-color: #000; color: #fff; text-decoration: none;">Confirm Email</a>

<p>Best regards,</p>
<p>Your activist team</p>
</body>
</html>
82 changes: 66 additions & 16 deletions backend/authentication/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Testing for the authentication app.
"""

# mypy: ignore-errors
import pytest
from .factories import (
SupportEntityTypeFactory,
Expand All @@ -14,10 +15,12 @@
from .models import UserModel

from django.test import Client
from django.core import mail
from faker import Faker

from .models import UserModel
from django.test import Client
from uuid import UUID


@pytest.mark.django_db
Expand Down Expand Up @@ -45,12 +48,12 @@ def test_signup(client: Client) -> None:
Scenarios:
1. Password strength fails
2. Password confirmation fails
3. User is created successfully
3. User is created successfully with an email
- Check response status code
- Check if user exists in the DB
- Check if user password is hashed
4. User already exists / Username already exists
5. Different User with the same email already exists
4. User already exists
5. User is created without an email
"""
# Setup
fake = Faker()
Expand Down Expand Up @@ -105,11 +108,16 @@ def test_signup(client: Client) -> None:
"email": email,
},
)

user = UserModel.objects.filter(username=username).first()
assert response.status_code == 201
assert UserModel.objects.filter(username=username).exists()
assert UserModel.objects.filter(username=username)
# code for Email confirmation is generated and is a UUID
assert isinstance(user.verifictaion_code, UUID)
assert user.is_confirmed is False
# Confirmation Email was sent
assert len(mail.outbox) == 1
# Assert that the password within the dashboard is hashed and not the original string.
assert UserModel.objects.get(username=username).password != strong_password
assert user.password != strong_password

# 4. User already exists
response = client.post(
Expand All @@ -125,50 +133,92 @@ def test_signup(client: Client) -> None:
assert response.status_code == 400
assert UserModel.objects.filter(username=username).count() == 1

# 5. Different User with the same email already exists
# 5. User is created without an email
response = client.post(
path="/v1/auth/signup/",
data={
"username": second_username,
"password": strong_password,
"password_confirmed": strong_password,
"email": email,
},
)
assert response.status_code == 400
assert not UserModel.objects.filter(username=second_username).exists()

user = UserModel.objects.filter(username=second_username).first()
assert response.status_code == 201
assert UserModel.objects.filter(username=second_username).exists()
assert user.email == ""
assert user.is_confirmed is False
assert user.verifictaion_code is None


@pytest.mark.django_db
def test_login(client: Client) -> None:
"""
Test login view.
Scenarios:
1. User is logged in successfully
2. User exists but password is incorrect
3. User does not exists and tries to login
1. User that signed up with email, that has not confirmed their email
2. User that signed up with email, confimred email address. Is logged in successfully
3. User exists but password is incorrect
4. User does not exists and tries to login
"""
# Setup
plaintext_password = "Activist@123!?"
user = UserFactory(plaintext_password=plaintext_password)

# 1. User is logged in successfully
# 1. User that signed up with email, that has not confirmed their email
response = client.post(
path="/v1/auth/login/",
data={"username": user.username, "password": plaintext_password},
)
assert response.status_code == 400

# 2. User that signed up with email, confimred email address. Is logged in successfully
user.is_confirmed = True
user.save()
response = client.post(
path="/v1/auth/login/",
data={"email": user.email, "password": plaintext_password},
)
assert response.status_code == 200
# login via username
response = client.post(
path="/v1/auth/login/",
data={"username": user.username, "password": plaintext_password},
)
assert response.status_code == 200

# 2. User exists but password is incorrect
# 3. User exists but password is incorrect
response = client.post(
path="/v1/auth/login/",
data={"email": user.email, "password": "Strong_But_Incorrect?!123"},
)
assert response.status_code == 400

# 2. User does not exists and tries to login
# 4. User does not exists and tries to login
response = client.post(
path="/v1/auth/login/",
data={"email": "unknown_user@example.com", "password": "Password@123!?"},
)
assert response.status_code == 400


@pytest.mark.django_db
def test_pwreset(client: Client) -> None:
"""
Test password reset view.
Scenarios:
1. Password reset email is sent successfully
"""
# Setup
plaintext_password = "Activist@123!?"
user = UserFactory(plaintext_password=plaintext_password)

# 1. User exists and password reset is successful
response = client.get(
path="/v1/auth/pwreset/",
data={"email": user.email},
)
assert response.status_code == 200
assert len(mail.outbox) == 1
Loading

0 comments on commit 600d6e7

Please sign in to comment.