Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

updated signup, login and added pwreset API #880

Merged
merged 7 commits into from
Jun 9, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"
1 change: 0 additions & 1 deletion backend/authentication/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ class Meta:
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)
email = models.EmailField(blank=True)
code = models.UUIDField(blank=True, null=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, please ignore this email.</p>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do "If you did not request a password reset, then it's safe to ignore this email."

<p>Best regards,</p>
<p>Your activist.org Team</p>
</body>
</html>
16 changes: 16 additions & 0 deletions backend/authentication/templates/signup_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!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>Kind Regards</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.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.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
1 change: 1 addition & 0 deletions backend/authentication/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@
path("signup/", views.SignupView.as_view(), name="signup"),
path("delete/", views.DeleteUserView.as_view(), name="delete"),
path("login/", views.LoginView.as_view(), name="login"),
path("pwreset/", views.PasswordResetView.as_view(), name="pwreset"),
]