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..f93309a1d 100644 --- a/backend/authentication/factories.py +++ b/backend/authentication/factories.py @@ -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 diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 0847d3f30..ed449a604 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) + 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) @@ -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 16d4c9ad0..1afc26abc 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 from rest_framework.authtoken.models import Token @@ -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,}$" @@ -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 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..407173b6c --- /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, then it's safe to ignore this email.

+

Best regards,

+

Your activist team

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

Hi {{username}},

+ +

Thank you for signing up!

+ +

Please click the button below to confirm your email address:

+ Confirm Email + +

Best regards,

+

Your activist team

+ + diff --git a/backend/authentication/tests.py b/backend/authentication/tests.py index 8261e6aab..4d957ed40 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, @@ -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 @@ -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() @@ -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( @@ -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 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 876d58ceb..caacec8e9 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,9 +1,14 @@ +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 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 @@ -22,6 +27,7 @@ ) from .serializers import ( LoginSerializer, + PasswordResetSerializer, SignupSerializer, SupportEntityTypeSerializer, SupportSerializer, @@ -31,7 +37,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]): @@ -46,8 +55,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 @@ -76,15 +85,63 @@ 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.verifictaion_code = uuid.uuid4() + + 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", + 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="verifictaion_code", type=str, required=True)] + ) + def get(self, request: Request) -> Response: + """Confirm a user's email address.""" + verifictaion_code = request.GET.get("verifictaion_code") + user = UserModel.objects.filter(verifictaion_code=verifictaion_code).first() + + if user is None: + return Response( + {"message": "User does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + user.is_confirmed = True + user.verifictaion_code = "" + user.save() + + return Response( + {"message": "Email is confirmed. You can now log in."}, + status=status.HTTP_201_CREATED, + ) + @method_decorator(csrf_exempt, name="dispatch") class LoginView(APIView): @@ -92,6 +149,10 @@ class LoginView(APIView): 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) @@ -106,6 +167,62 @@ 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, 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": "User does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + user.verifictaion_code = uuid.uuid4() + + 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", + 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 09052963b..fd7f1c371 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/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/entities/views.py b/backend/entities/views.py index 3c9ae3f61..c14e77bd4 100644 --- a/backend/entities/views.py +++ b/backend/entities/views.py @@ -116,7 +116,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/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 diff --git a/backend/fixtures/superuser.json b/backend/fixtures/superuser.json index 082b54cc7..9000d4e0a 100644 --- a/backend/fixtures/superuser.json +++ b/backend/fixtures/superuser.json @@ -8,20 +8,17 @@ "first_name": "", "last_name": "", "is_staff": true, - "date_joined": "2024-06-01T16:03:10.439Z", + "date_joined": "2024-04-29T17:30:42.975Z", + "creation_date": "2024-04-29T17:30:43.127Z", "username": "admin", "name": "", "password": "pbkdf2_sha256$600000$V4ADJJtADmbfpWmhbysc0v$9LBg11KagabWLdZ7W2c/GUfnrrJwf/xS4221vGt5T/Q=", "description": "", "verified": false, "verification_method": "", - "verification_partner": null, - "icon_url": null, - "email": "admin@admin.admin", - "social_links": null, - "is_private": false, + "verification_partner": "7664552d-e9cb-49f8-9683-a58acdd4f504", + "email": "admin@activist.org", "is_high_risk": false, - "creation_date": "2024-06-01T16:03:10.804Z", "is_active": true, "is_admin": false, "groups": [], 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: