From b7cb9c3191b0b0ebe4b1c79b94f802012543e9cd Mon Sep 17 00:00:00 2001 From: tosta Date: Sun, 19 May 2024 17:41:26 +0200 Subject: [PATCH 1/6] updated signup, login and added pwreset --- .env.dev | 7 + backend/authentication/factories.py | 1 - backend/authentication/models.py | 23 ++-- backend/authentication/serializers.py | 57 +++++--- backend/authentication/templates/.keep | 0 .../templates/pwreset_email.html | 15 ++ .../templates/signup_email.html | 16 +++ backend/authentication/tests.py | 18 +-- backend/authentication/urls.py | 1 + backend/authentication/views.py | 129 +++++++++++++++++- backend/backend/settings.py | 11 ++ backend/content/factories.py | 1 - backend/entities/views.py | 1 - backend/fixtures/superuser.json | 9 +- docker-compose.yml | 2 + 15 files changed, 231 insertions(+), 60 deletions(-) delete mode 100644 backend/authentication/templates/.keep create mode 100644 backend/authentication/templates/pwreset_email.html create mode 100644 backend/authentication/templates/signup_email.html diff --git a/.env.dev b/.env.dev index 18622074d..8f0fa8b34 100644 --- a/.env.dev +++ b/.env.dev @@ -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" diff --git a/backend/authentication/factories.py b/backend/authentication/factories.py index 8c81b83d9..6150219ea 100644 --- a/backend/authentication/factories.py +++ b/backend/authentication/factories.py @@ -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 diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 0847d3f30..62fb42b16 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -2,6 +2,8 @@ Models for the authentication app. """ +from __future__ import annotations + from typing import Any from uuid import uuid4 @@ -9,7 +11,6 @@ AbstractUser, BaseUserManager, PermissionsMixin, - User, ) from django.contrib.postgres.fields import ArrayField from django.db import models @@ -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, @@ -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 @@ -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) @@ -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 diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 758621a6d..ae5515381 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -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 @@ -97,15 +96,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,}$" @@ -126,42 +123,64 @@ 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", ) - data["user"] = user + 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 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 diff --git a/backend/authentication/templates/.keep b/backend/authentication/templates/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/authentication/templates/pwreset_email.html b/backend/authentication/templates/pwreset_email.html new file mode 100644 index 000000000..215516350 --- /dev/null +++ b/backend/authentication/templates/pwreset_email.html @@ -0,0 +1,15 @@ + + + + + Password Reset + + +

Hi {{username}},

+

We have received a request to reset your password. To proceed with the password reset, please click on the link below:

+

Reset Password

+

If you did not request a password reset, please ignore this email.

+

Best regards,

+

Your activist.org Team

+ + diff --git a/backend/authentication/templates/signup_email.html b/backend/authentication/templates/signup_email.html new file mode 100644 index 000000000..d05695ed7 --- /dev/null +++ b/backend/authentication/templates/signup_email.html @@ -0,0 +1,16 @@ + + + + Signup Confirmation + + +

Hi {{username}},

+ +

Thank you for signing up!

+ +

Please click the button below to confirm your email address:

+ Confirm Email + +

Kind Regards

+ + diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 8261e6aab..b0a0b687b 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -55,7 +55,6 @@ def test_signup(client: Client) -> None: # Setup fake = Faker() username = fake.name() - second_username = fake.name() email = fake.email() strong_password = fake.password( length=12, special_chars=True, digits=True, upper_case=True @@ -125,20 +124,8 @@ 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 - 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() - +@pytest.mark.django_db def test_login(client: Client) -> None: """ Test login view. @@ -155,8 +142,9 @@ def test_login(client: Client) -> None: # 1. User is logged in successfully response = client.post( path="/v1/auth/login/", - data={"email": user.email, "password": plaintext_password}, + data={"username": user.username, "password": plaintext_password}, ) + print(response.content) assert response.status_code == 200 # 2. User exists but password is incorrect diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 183f7c6ec..6507e3c8e 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -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"), ] diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 02241653c..24fa782e8 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,7 +1,12 @@ +import os +import uuid from uuid import UUID -from django.contrib.auth import get_user_model, login -from django.contrib.auth.models import User +import dotenv +from django.contrib.auth import login +from django.core.mail import send_mail +from django.template.loader import render_to_string +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status, viewsets from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request @@ -20,6 +25,7 @@ ) from .serializers import ( LoginSerializer, + PasswordResetSerializer, SignupSerializer, SupportEntityTypeSerializer, SupportSerializer, @@ -29,7 +35,10 @@ UserTopicSerializer, ) -USER = get_user_model() +dotenv.load_dotenv() + +FRONTEND_BASE_URL = os.getenv("VITE_FRONTEND_URL") +ACTIVIST_EMAIL = os.getenv("ACTIVIST_EMAIL") class SupportEntityTypeViewSet(viewsets.ModelViewSet[SupportEntityType]): @@ -44,8 +53,8 @@ class SupportViewSet(viewsets.ModelViewSet[Support]): serializer_class = SupportSerializer -class UserViewSet(viewsets.ModelViewSet[User]): - queryset = USER.objects.all() +class UserViewSet(viewsets.ModelViewSet[UserModel]): + queryset = UserModel.objects.all() pagination_class = CustomPagination serializer_class = UserSerializer @@ -74,21 +83,71 @@ class SignupView(APIView): serializer_class = SignupSerializer def post(self, request: Request) -> Response: + """Create a new user.""" serializer = SignupSerializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save() + user: UserModel = serializer.save() + + if user.email != "": + user.code = uuid.uuid4() + + confirmation_link = f"{FRONTEND_BASE_URL}/confirm/{user.code}" + message = f"Welcome to activist.org, {user.username}!" + html_message = render_to_string( + template_name="signup_email.html", + context={ + "username": user.username, + confirmation_link: confirmation_link, + }, + ) + + send_mail( + subject="Welcome to activist.org", + message=message, + from_email=ACTIVIST_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + user.save() return Response( {"message": "User was created successfully"}, status=status.HTTP_201_CREATED, ) + @extend_schema(parameters=[OpenApiParameter(name="code", type=str)]) + def get(self, request: Request) -> Response: + """Confirm a user's email address.""" + code = request.GET.get("code") + user = UserModel.objects.filter(code=code).first() + + if user is None: + return Response( + {"message": "User does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + user.is_confirmed = True + user.code = "" + user.save() + + return Response( + {"message": "Email is confirmed. You can now log in."}, + status=status.HTTP_201_CREATED, + ) + class LoginView(APIView): serializer_class = LoginSerializer permission_classes = (AllowAny,) def post(self, request: Request) -> Response: + """Log in a user. + + Login is possible with either email or username + """ serializer = LoginSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -100,6 +159,64 @@ def post(self, request: Request) -> Response: ) +class PasswordResetView(APIView): + serializer_class = PasswordResetSerializer + permission_classes = (AllowAny,) + queryset = UserModel.objects.all() + + @extend_schema(parameters=[OpenApiParameter(name="email", type=str)]) + def get(self, request: Request) -> Response: + email = request.query_params.get("email") + user = UserModel.objects.filter(email=email).first() + + if user is None: + return Response( + { + "message": f"User does not exist {user}, {email}, {request.query_params}" + }, + status=status.HTTP_404_NOT_FOUND, + ) + + user.code = uuid.uuid4() + + pwreset_link = f"{FRONTEND_BASE_URL}/pwreset/{user.code}" + message = "Reset your password at activist.org" + html_message = render_to_string( + template_name="pwreset_email.html", + context={"username": user.username, pwreset_link: pwreset_link}, + ) + + send_mail( + subject="Reset your password at activist.org", + message=message, + from_email=ACTIVIST_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + user.save() + + return Response( + {"message": "Password reset email was sent successfully"}, + status=status.HTTP_200_OK, + ) + + def post(self, request: Request) -> Response: + serializer = PasswordResetSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = serializer.validated_data + + user.set_password(request.data.get("password")) + user.save() + + return Response( + {"message": "Password was reset successfully"}, + status=status.HTTP_200_OK, + ) + + class DeleteUserView(APIView): queryset = UserModel.objects.all() permission_classes = (IsAuthenticated,) diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 036855a7f..e3ad14116 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -157,6 +157,17 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# Email Settings +# https://docs.djangoproject.com/en/5.0/topics/email/ + +EMAIL_HOST = os.getenv("EMAIL_HOST") +EMAIL_PORT = os.getenv("EMAIL_PORT") +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = bool(os.getenv("EMAIL_USE_TLS") == "True") +# DEVELOPMENT ONLY +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + REST_FRAMEWORK = { "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", diff --git a/backend/content/factories.py b/backend/content/factories.py index 77128f414..3f33f05de 100644 --- a/backend/content/factories.py +++ b/backend/content/factories.py @@ -19,7 +19,6 @@ class Meta: name = factory.Faker("name") description = factory.Faker("text") - topics = factory.List([factory.Faker("word") for _ in range(5)]) url = factory.Faker("url") is_private = factory.Faker("boolean") created_by = factory.SubFactory("authentication.factories.UserFactory") diff --git a/backend/entities/views.py b/backend/entities/views.py index c232e75b9..3232554a7 100644 --- a/backend/entities/views.py +++ b/backend/entities/views.py @@ -112,7 +112,6 @@ def partial_update(self, request: Request, pk: str | None = None) -> Response: def destroy(self, request: Request, pk: str | None = None) -> Response: org = self.queryset.filter(id=pk).first() - print(pk, org) if org is None: return Response( {"error": "Organization not found"}, status.HTTP_404_NOT_FOUND diff --git a/backend/fixtures/superuser.json b/backend/fixtures/superuser.json index 91ef1b655..2e37c3564 100644 --- a/backend/fixtures/superuser.json +++ b/backend/fixtures/superuser.json @@ -10,7 +10,6 @@ "is_staff": true, "date_joined": "2024-04-29T17:30:42.975Z", "creation_date": "2024-04-29T17:30:43.127Z", - "deletion_date": null, "username": "admin", "name": "", "password": "pbkdf2_sha256$600000$ET0H01Ea4DhmDbLHfTSMJd$+tJ/5v898Kmp/8Y7R4zU2/BGB53Kd/S1U+G08W4kZaY=", @@ -18,14 +17,12 @@ "verified": false, "verification_method": "", "verification_partner": "7664552d-e9cb-49f8-9683-a58acdd4f504", - "user_icon": null, "email": "admin@activist.org", - "social_accounts": "[]", - "private": false, - "high_risk": false, + "is_high_risk": false, "is_active": true, "is_admin": true, "groups": [], "user_permissions": [] } - ] + } +] diff --git a/docker-compose.yml b/docker-compose.yml index 5023e1f91..23ecf97f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: container_name: django_backend command: sh -c "python manage.py makemigrations && python manage.py migrate && + python manage.py loaddata fixtures/superuser.json && python manage.py runserver 0.0.0.0:${BACKEND_PORT}" ports: - "${BACKEND_PORT}:${BACKEND_PORT}" @@ -21,6 +22,7 @@ services: - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} - DEBUG=${DEBUG} - SECRET_KEY=${SECRET_KEY} + - VITE_FRONTEND_URL=${VITE_FRONTEND_URL} depends_on: - db healthcheck: From 16baeb8464a29ef447700f390b30813ac9dd6089 Mon Sep 17 00:00:00 2001 From: tosta Date: Mon, 20 May 2024 12:17:54 +0200 Subject: [PATCH 2/6] updated signup tests --- backend/authentication/tests.py | 58 +++++++++++++++++++++++++++++---- backend/authentication/views.py | 2 +- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index b0a0b687b..e4588bfb4 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -14,10 +14,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 @@ -45,16 +47,17 @@ 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() username = fake.name() + second_username = fake.name() email = fake.email() strong_password = fake.password( length=12, special_chars=True, digits=True, upper_case=True @@ -104,11 +107,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( @@ -124,6 +132,23 @@ def test_signup(client: Client) -> None: assert response.status_code == 400 assert UserModel.objects.filter(username=username).count() == 1 + # 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, + }, + ) + + 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: @@ -160,3 +185,24 @@ def test_login(client: Client) -> None: 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. User exists and password reset is successful + 2. User does not exist + """ + # 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 diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 24fa782e8..473d4892d 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -92,7 +92,7 @@ def post(self, request: Request) -> Response: user.code = uuid.uuid4() confirmation_link = f"{FRONTEND_BASE_URL}/confirm/{user.code}" - message = f"Welcome to activist.org, {user.username}!" + message = f"Welcome to activist.org, {user.username}!, Please confirm your email address by clicking the link: {confirmation_link}" html_message = render_to_string( template_name="signup_email.html", context={ From 5051f4740f4020533d8ee07932d0f3b00db353eb Mon Sep 17 00:00:00 2001 From: tosta Date: Mon, 20 May 2024 12:45:05 +0200 Subject: [PATCH 3/6] added mypy: ignore-errors in tests.py --- backend/authentication/tests.py | 1 + backend/content/tests.py | 1 + backend/entities/tests.py | 1 + backend/events/tests.py | 1 + 4 files changed, 4 insertions(+) diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index e4588bfb4..e69538c5b 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -2,6 +2,7 @@ Testing for the authentication app. """ +# mypy: ignore-errors import pytest from .factories import ( SupportEntityTypeFactory, diff --git a/backend/content/tests.py b/backend/content/tests.py index 42bcf741f..b9fd3e1c3 100644 --- a/backend/content/tests.py +++ b/backend/content/tests.py @@ -2,6 +2,7 @@ Testing for the content app. """ +# mypy: ignore-errors from .factories import ResourceFactory, TaskFactory, TopicFactory, ResourceTopicFactory from tests.throttle import BaseTestThrottle from django.urls import reverse diff --git a/backend/entities/tests.py b/backend/entities/tests.py index 29fcfc4bc..f65ac809c 100644 --- a/backend/entities/tests.py +++ b/backend/entities/tests.py @@ -2,6 +2,7 @@ Testing for the entities app. """ +# mypy: ignore-errors from django.urls import reverse from tests.throttle import BaseTestThrottle diff --git a/backend/events/tests.py b/backend/events/tests.py index 9858bf657..d5d96883e 100644 --- a/backend/events/tests.py +++ b/backend/events/tests.py @@ -2,6 +2,7 @@ Testing for the events app. """ +# mypy: ignore-errors from django.urls import reverse from tests.throttle import BaseTestThrottle From 27d7a7eb0453ed09dd0489ebbb4177b612f4424b Mon Sep 17 00:00:00 2001 From: tosta Date: Mon, 20 May 2024 17:07:06 +0200 Subject: [PATCH 4/6] login test adjusted and pwreset added --- backend/authentication/tests.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index e69538c5b..acc60cb9c 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -157,30 +157,45 @@ 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}, ) - print(response.content) 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!?"}, @@ -194,8 +209,7 @@ def test_pwreset(client: Client) -> None: Test password reset view. Scenarios: - 1. User exists and password reset is successful - 2. User does not exist + 1. Password reset email is sent successfully """ # Setup plaintext_password = "Activist@123!?" @@ -207,3 +221,4 @@ def test_pwreset(client: Client) -> None: data={"email": user.email}, ) assert response.status_code == 200 + assert len(mail.outbox) == 1 From 06a0118a32d1b749f5a1bb043f25d243f99a2f24 Mon Sep 17 00:00:00 2001 From: tosta Date: Mon, 20 May 2024 17:26:40 +0200 Subject: [PATCH 5/6] add required to query parameter --- backend/authentication/views.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 473d4892d..a0e0dfe95 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -117,7 +117,7 @@ def post(self, request: Request) -> Response: status=status.HTTP_201_CREATED, ) - @extend_schema(parameters=[OpenApiParameter(name="code", type=str)]) + @extend_schema(parameters=[OpenApiParameter(name="code", type=str, required=True)]) def get(self, request: Request) -> Response: """Confirm a user's email address.""" code = request.GET.get("code") @@ -164,16 +164,14 @@ class PasswordResetView(APIView): permission_classes = (AllowAny,) queryset = UserModel.objects.all() - @extend_schema(parameters=[OpenApiParameter(name="email", type=str)]) + @extend_schema(parameters=[OpenApiParameter(name="email", type=str, required=True)]) def get(self, request: Request) -> Response: email = request.query_params.get("email") user = UserModel.objects.filter(email=email).first() if user is None: return Response( - { - "message": f"User does not exist {user}, {email}, {request.query_params}" - }, + {"message": "User does not exist"}, status=status.HTTP_404_NOT_FOUND, ) From b2b2d8daa636ddb616c493a2bc71643c908ef184 Mon Sep 17 00:00:00 2001 From: tosta Date: Sun, 9 Jun 2024 18:16:25 +0200 Subject: [PATCH 6/6] fixes --- backend/authentication/factories.py | 1 + backend/authentication/models.py | 2 +- .../templates/pwreset_email.html | 8 +++--- .../templates/signup_email.html | 5 ++-- backend/authentication/tests.py | 4 +-- backend/authentication/views.py | 25 ++++++++----------- 6 files changed, 22 insertions(+), 23 deletions(-) diff --git a/backend/authentication/factories.py b/backend/authentication/factories.py index 6150219ea..f93309a1d 100644 --- a/backend/authentication/factories.py +++ b/backend/authentication/factories.py @@ -39,6 +39,7 @@ 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") diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 62fb42b16..ed449a604 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -92,8 +92,8 @@ class UserModel(AbstractUser, PermissionsMixin): icon_url = models.ForeignKey( "content.Image", on_delete=models.SET_NULL, blank=True, null=True ) + verifictaion_code = models.UUIDField(blank=True, null=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) diff --git a/backend/authentication/templates/pwreset_email.html b/backend/authentication/templates/pwreset_email.html index 215516350..407173b6c 100644 --- a/backend/authentication/templates/pwreset_email.html +++ b/backend/authentication/templates/pwreset_email.html @@ -7,9 +7,9 @@

Hi {{username}},

We have received a request to reset your password. To proceed with the password reset, please click on the link below:

-

Reset Password

-

If you did not request a password reset, please ignore this email.

+

Reset Password

+

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

Best regards,

-

Your activist.org Team

+

Your activist team

- + diff --git a/backend/authentication/templates/signup_email.html b/backend/authentication/templates/signup_email.html index d05695ed7..f82084df3 100644 --- a/backend/authentication/templates/signup_email.html +++ b/backend/authentication/templates/signup_email.html @@ -11,6 +11,7 @@

Please click the button below to confirm your email address:

Confirm Email -

Kind Regards

+

Best regards,

+

Your activist team

- + diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index acc60cb9c..4d957ed40 100644 --- a/backend/authentication/tests.py +++ b/backend/authentication/tests.py @@ -112,7 +112,7 @@ def test_signup(client: Client) -> None: assert response.status_code == 201 assert UserModel.objects.filter(username=username) # code for Email confirmation is generated and is a UUID - assert isinstance(user.code, UUID) + assert isinstance(user.verifictaion_code, UUID) assert user.is_confirmed is False # Confirmation Email was sent assert len(mail.outbox) == 1 @@ -148,7 +148,7 @@ def test_signup(client: Client) -> None: assert UserModel.objects.filter(username=second_username).exists() assert user.email == "" assert user.is_confirmed is False - assert user.code is None + assert user.verifictaion_code is None @pytest.mark.django_db diff --git a/backend/authentication/views.py b/backend/authentication/views.py index 0b3a4cd72..caacec8e9 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -2,18 +2,13 @@ import uuid from uuid import UUID - import dotenv from django.contrib.auth import login from django.core.mail import send_mail from django.template.loader import render_to_string -from drf_spectacular.utils import OpenApiParameter, extend_schema - -from django.contrib.auth import get_user_model, login -from django.contrib.auth.models import User from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt - +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status, viewsets from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request @@ -96,9 +91,9 @@ def post(self, request: Request) -> Response: user: UserModel = serializer.save() if user.email != "": - user.code = uuid.uuid4() + user.verifictaion_code = uuid.uuid4() - confirmation_link = f"{FRONTEND_BASE_URL}/confirm/{user.code}" + confirmation_link = f"{FRONTEND_BASE_URL}/confirm/{user.verifictaion_code}" message = f"Welcome to activist.org, {user.username}!, Please confirm your email address by clicking the link: {confirmation_link}" html_message = render_to_string( template_name="signup_email.html", @@ -124,11 +119,13 @@ def post(self, request: Request) -> Response: status=status.HTTP_201_CREATED, ) - @extend_schema(parameters=[OpenApiParameter(name="code", type=str, required=True)]) + @extend_schema( + parameters=[OpenApiParameter(name="verifictaion_code", type=str, required=True)] + ) def get(self, request: Request) -> Response: """Confirm a user's email address.""" - code = request.GET.get("code") - user = UserModel.objects.filter(code=code).first() + verifictaion_code = request.GET.get("verifictaion_code") + user = UserModel.objects.filter(verifictaion_code=verifictaion_code).first() if user is None: return Response( @@ -137,7 +134,7 @@ def get(self, request: Request) -> Response: ) user.is_confirmed = True - user.code = "" + user.verifictaion_code = "" user.save() return Response( @@ -186,9 +183,9 @@ def get(self, request: Request) -> Response: status=status.HTTP_404_NOT_FOUND, ) - user.code = uuid.uuid4() + user.verifictaion_code = uuid.uuid4() - pwreset_link = f"{FRONTEND_BASE_URL}/pwreset/{user.code}" + pwreset_link = f"{FRONTEND_BASE_URL}/pwreset/{user.verifictaion_code}" message = "Reset your password at activist.org" html_message = render_to_string( template_name="pwreset_email.html",