From d74fcc0b9c6e4c5e2aa5c7faa2cd3e3f324b9d49 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 10 Apr 2026 10:18:32 +0530 Subject: [PATCH] feat: implement email verification flow with account activation and secure cookie configuration --- .env.example | 3 +++ backend/config/settings.py | 11 +++++++++++ backend/users/models.py | 6 ++++++ backend/users/urls.py | 4 ++++ backend/users/utils.py | 22 ++++++++++++++++++++++ backend/users/views.py | 24 +++++++++++++++++++++++- 6 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 backend/users/utils.py diff --git a/.env.example b/.env.example index 1f69f99..fe4b200 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,10 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 # EMAIL EMAIL_HOST=localhost EMAIL_PORT=1025 +FROM_EMAIL=Pi Ku # FRONTEND VITE_API_URL=http://localhost:8000 FRONTEND_PORT=5173 +FRONTEND_URL=http://localhost:5173 +FRONTEND_DOMAIN=localhost diff --git a/backend/config/settings.py b/backend/config/settings.py index ec79bcb..431b8d9 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -110,8 +110,19 @@ SIMPLE_JWT = { "BLACKLIST_AFTER_ROTATION": True, "AUTH_HEADER_TYPES": ("Bearer",), "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "AUTH_COOKIE": "refresh_token", + "AUTH_COOKIE_DOMAIN": f".{env('FRONTEND_DOMAIN')}", + "AUTH_COOKIE_SECURE": True, + "AUTH_COOKIE_HTTPONLY": True, + "AUTH_COOKIE_SAMESITE": "Lax", # Allow cross-site for links from email. Otherwise we'd use strict } +# Email config +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +FROM_EMAIL = env("FROM_EMAIL") + +FRONTEND_URL = env("FRONTEND_URL") + # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators diff --git a/backend/users/models.py b/backend/users/models.py index 30b4562..fb0fb8a 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -11,6 +11,8 @@ class CustomUserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError(_("The Email must be set")) + # set default activation state as False to enforce email verification + extra_fields.setdefault("is_active", False) email = self.normalize_email(email) user = self.model(email=email, **extra_fields) @@ -39,6 +41,10 @@ class User(AbstractUser): full_name = models.CharField(max_length=100) email = models.EmailField(_("email address"), unique=True) + kdf_salt = models.CharField(max_length=128, blank=True, null=True) + + # Default is False to enforce email verification + is_active = models.BooleanField(default=False) objects = CustomUserManager() diff --git a/backend/users/urls.py b/backend/users/urls.py index 0c275d6..68b0bbe 100644 --- a/backend/users/urls.py +++ b/backend/users/urls.py @@ -1,6 +1,8 @@ from django.urls import path from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from users.views import ActivationView + from .views import MeView, RegisterView urlpatterns = [ @@ -11,4 +13,6 @@ urlpatterns = [ path("refresh/", TokenRefreshView.as_view(), name="token_refresh"), # Get current user info path("me/", MeView.as_view(), name="me"), + # Activate user account + path("activate///", ActivationView.as_view(), name="activate"), ] diff --git a/backend/users/utils.py b/backend/users/utils.py new file mode 100644 index 0000000..b464f3d --- /dev/null +++ b/backend/users/utils.py @@ -0,0 +1,22 @@ +from django.conf import settings +from django.contrib.auth.tokens import default_token_generator +from django.core.mail import send_mail +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + + +def send_activation_email(user): + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + activation_url = f"{settings.FRONTEND_URL}/activate/{uid}/{token}" + subject = "Activate Your Piku Account" + message = f"""Hi {user.full_name}, + + Welcome to Pi Ku. + + Please click the link below to activate your account: + >> {activation_url} + + If you did not create this account, please ignore this email.""" + send_mail(subject, message, settings.FROM_EMAIL, [user.email], fail_silently=False) + return True diff --git a/backend/users/views.py b/backend/users/views.py index 03ae8a7..fa68e90 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,5 +1,8 @@ from django.contrib.auth import get_user_model -from rest_framework import generics, permissions +from django.contrib.auth.tokens import default_token_generator +from django.utils.http import urlsafe_base64_decode +from rest_framework import generics, permissions, status +from rest_framework.response import Response from .serializers import UserSerializer @@ -12,6 +15,25 @@ class RegisterView(generics.CreateAPIView): serializer_class = UserSerializer +class ActivationView(generics.GenericAPIView): + permission_classes = (permissions.AllowAny,) + serializer_class = UserSerializer + + def get(self, request, uidb64, token): + try: + uid = urlsafe_base64_decode(uidb64).decode() + user = User.objects.get(pk=uid) + except (User.DoesNotExist, TypeError, ValueError): + return Response({"detail": "Invalid activation link: User Error"}, status=status.HTTP_400_BAD_REQUEST) + # validate token + if not default_token_generator.check_token(user, token): + return Response({"detail": "Invalid activation link: Token Error"}, status=status.HTTP_400_BAD_REQUEST) + # activate user + user.is_active = True + user.save() + return Response({"detail": "Account activated successfully"}, status=status.HTTP_200_OK) + + class MeView(generics.RetrieveAPIView): serializer_class = UserSerializer permission_classes = (permissions.IsAuthenticated,)