diff --git a/Backend/API_DOCS.md b/Backend/API_DOCS.md index c830d07..e970f93 100644 --- a/Backend/API_DOCS.md +++ b/Backend/API_DOCS.md @@ -25,22 +25,46 @@ Content-Type: application/json **Response (201):** ```json { + "email": "string", + "message": "Registration successful. Please check your email to verify your account." +} +``` + +--- + +### 2. Verify Email +```http +POST /auth/verify-email/ +Content-Type: application/json +``` + +**Request:** +```json +{ + "email": "string", + "code": "string" +} +``` + +**Response (200):** +```json +{ + "message": "Email verified successfully", + "access": "jwt_token", + "refresh": "jwt_token", "user": { "id": 1, - "username": "string", "email": "string", + "username": "string", "first_name": "string", "last_name": "string" - }, - "refresh": "jwt_token", - "access": "jwt_token", - "message": "Registration successful" + } } ``` --- -### 2. Login +### 3. Login ```http POST /auth/login/ Content-Type: application/json @@ -62,9 +86,11 @@ Content-Type: application/json } ``` +**Note:** User must verify email before login. Unverified accounts will receive `401 Unauthorized`. + --- -### 3. Refresh Token +### 4. Refresh Token ```http POST /auth/token/refresh/ Content-Type: application/json @@ -101,6 +127,13 @@ Authorization: Bearer - **Refresh Token:** 7 days - Refresh tokens rotate on use (new refresh token returned) +## Email Verification + +- Verification codes are **6 characters** (alphanumeric, excluding ambiguous characters) +- Codes expire after **10 minutes** +- Codes are **single-use** and cannot be reused +- Users receive verification email immediately after registration + ## Error Responses ```json @@ -110,4 +143,4 @@ Authorization: Bearer } ``` -Common status codes: `400` (validation error), `401` (unauthorized), `404` (not found) +Common status codes: `400` (validation error), `401` (unauthorized), `404` (not found), `500` (server error) diff --git a/Backend/apps/authentication/migrations/0002_user_is_email_verified_alter_user_is_active_and_more.py b/Backend/apps/authentication/migrations/0002_user_is_email_verified_alter_user_is_active_and_more.py new file mode 100644 index 0000000..7985cab --- /dev/null +++ b/Backend/apps/authentication/migrations/0002_user_is_email_verified_alter_user_is_active_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.3 on 2025-12-09 19:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_email_verified', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='user', + name='is_active', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='EmailVerificationCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=6)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField()), + ('is_used', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_verification_codes', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/Backend/apps/authentication/models.py b/Backend/apps/authentication/models.py index 71c7cec..6ff58ab 100644 --- a/Backend/apps/authentication/models.py +++ b/Backend/apps/authentication/models.py @@ -1,5 +1,8 @@ from django.db import models from django.contrib.auth.models import AbstractUser +from django.utils import timezone +from datetime import timedelta +import random # Create your models here. @@ -7,6 +10,8 @@ class User(AbstractUser): email = models.EmailField(unique=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + is_email_verified = models.BooleanField(default=False) + is_active = models.BooleanField(default=False) USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['username'] @@ -15,4 +20,34 @@ def __str__(self): return self.email class Meta: - db_table = 'auth_user' \ No newline at end of file + db_table = 'auth_user' + +class EmailVerificationCode(models.Model): + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='email_verification_codes' + ) + code = models.CharField(max_length=6) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField() + is_used = models.BooleanField(default=False) + + def generate_code(self): + chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + return ''.join(random.choices(chars, k=6)) + + def save(self, *args, **kwargs): + if not self.code: + self.code = self.generate_code() + if not self.expires_at: + self.expires_at = timezone.now() + timedelta(minutes=10) + super().save(*args, **kwargs) + + def is_valid(self): + if timezone.now() > self.expires_at or self.is_used: + return False + return True + + def __str__(self): + return f"{self.user.email} - {self.code}" \ No newline at end of file diff --git a/Backend/apps/authentication/serializers.py b/Backend/apps/authentication/serializers.py index 61e46b2..22ee29e 100644 --- a/Backend/apps/authentication/serializers.py +++ b/Backend/apps/authentication/serializers.py @@ -36,3 +36,14 @@ def create(self, validated_data): user.set_password(validated_data['password']) user.save() return user + +class EmailVerificationCodeSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) + code = serializers.CharField(min_length=6, max_length=6, required=True) + + def validate_code(self, value): + """Validate that code contains only allowed characters""" + chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + if not all(char in chars for char in value): + raise serializers.ValidationError("Invalid verification code format.") + return value diff --git a/Backend/apps/authentication/services.py b/Backend/apps/authentication/services.py new file mode 100644 index 0000000..f69e6c6 --- /dev/null +++ b/Backend/apps/authentication/services.py @@ -0,0 +1,67 @@ +from mailjet_rest import Client +from django.conf import settings +import os + +class EmailService: + def __init__(self): + self.api_key = settings.MAILJET_API_KEY + self.api_secret = settings.MAILJET_API_SECRET + self.client = Client(auth=(self.api_key, self.api_secret), version='v3.1') + self.sender_email = "theshahidkhan.2004@gmail.com" + self.sender_name = "TOC Simulator" + + def send_verification_email(self, user_email, user_name, verification_code): + """ + Send verification code to user. + + Args: + user_email (str): Recipient email + user_name (str): User's first name for personalization + verification_code (str): 6-digit code + + Returns: + bool: True if sent successfully + """ + data = { + 'Messages': [ + { + "From": { + "Email": self.sender_email, + "Name": self.sender_name + }, + "To": [ + { + "Email": user_email, + "Name": user_name + } + ], + "Subject": "Verify Your Email - TOC Simulator", + "TextPart": f"Your verification code is: {verification_code}", + "HTMLPart": f""" +

Welcome to TOC Simulator, {user_name}!

+

Your verification code is:

+

{verification_code}

+

This code will expire in 15 minutes.

+

If you didn't create an account, please ignore this email.

+ """ + } + ] + } + + try: + result = self.client.send.create(data=data) + print(f"📧 Mailjet Response Status: {result.status_code}") + print(f"📧 Mailjet Response: {result.json()}") + + if result.status_code == 200: + print(f"✅ Email sent successfully to {user_email}") + return True + else: + print(f"❌ Mailjet returned status {result.status_code}") + print(f"Response: {result.json()}") + return False + except Exception as e: + print(f"❌ Error sending email: {str(e)}") + import traceback + traceback.print_exc() + return False \ No newline at end of file diff --git a/Backend/apps/authentication/urls.py b/Backend/apps/authentication/urls.py index 895ed17..ac5c927 100644 --- a/Backend/apps/authentication/urls.py +++ b/Backend/apps/authentication/urls.py @@ -1,9 +1,10 @@ from django.urls import path from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView -from .views import RegisterView +from .views import RegisterView, VerifyEmailView urlpatterns = [ path('register/', RegisterView.as_view(), name='register'), + path('verify-email/', VerifyEmailView.as_view(), name='verify_email'), path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), ] \ No newline at end of file diff --git a/Backend/apps/authentication/views.py b/Backend/apps/authentication/views.py index 71c2f35..6e4caed 100644 --- a/Backend/apps/authentication/views.py +++ b/Backend/apps/authentication/views.py @@ -3,8 +3,11 @@ from rest_framework.views import APIView from rest_framework.permissions import AllowAny from rest_framework_simplejwt.tokens import RefreshToken -from .serializers import UserSerializer +from .serializers import UserSerializer, EmailVerificationCodeSerializer +from .models import EmailVerificationCode +from django.utils import timezone from django.contrib.auth import get_user_model +from .services import EmailService User = get_user_model() @@ -17,11 +20,80 @@ def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() - refresh = RefreshToken.for_user(user) + code = user.email_verification_codes.create() + email_service = EmailService() + email_sent = email_service.send_verification_email( + user_email=user.email, + user_name=user.first_name, + verification_code=code.code + ) + if not email_sent: + user.delete() + return Response( + {"error": "Failed to send verification email. Please try again."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) res_data = { - "user": serializer.data, - "refresh": str(refresh), - "access": str(refresh.access_token), - "message": "Registration successful" + "email": user.email, + "message": "Registration successful. Please check your email to verify your account." } return Response(res_data, status=status.HTTP_201_CREATED) + +class VerifyEmailView(APIView): + permission_classes = (AllowAny,) + + def post(self, request): + serializer = EmailVerificationCodeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + email = serializer.validated_data['email'] + code_value = serializer.validated_data['code'] + + try: + # Find user + user = User.objects.get(email=email) + + # Find the most recent unused, unexpired code + verification_code = EmailVerificationCode.objects.filter( + user=user, + code=code_value, + is_used=False, + expires_at__gt=timezone.now() + ).first() + + if not verification_code: + return Response( + {"error": "Expired verification code"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Mark code as used + verification_code.is_used = True + verification_code.save() + + # Activate user + user.is_active = True + user.is_email_verified = True + user.save() + + # Generate tokens + refresh = RefreshToken.for_user(user) + + return Response({ + "message": "Email verified successfully", + "access": str(refresh.access_token), + "refresh": str(refresh), + "user": { + "id": user.id, + "email": user.email, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + } + }, status=status.HTTP_200_OK) + + except User.DoesNotExist: + return Response( + {"error": "User not found"}, + status=status.HTTP_404_NOT_FOUND + ) \ No newline at end of file diff --git a/Backend/build.sh b/Backend/build.sh new file mode 100644 index 0000000..b8a9360 --- /dev/null +++ b/Backend/build.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# exit on error +set -o errexit + +pip install -r requirements.txt + +python manage.py collectstatic --no-input +python manage.py migrate diff --git a/Backend/config/settings.py b/Backend/config/settings.py index 2f2b019..63da4d2 100644 --- a/Backend/config/settings.py +++ b/Backend/config/settings.py @@ -30,7 +30,8 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.getenv('DEBUG', 'True') == 'True' -ALLOWED_HOSTS = ['localhost', 'toc-simulator.vercel.app', '127.0.0.1'] +# Allow all hosts for development/testing +ALLOWED_HOSTS = ['*'] # Application definition @@ -50,13 +51,14 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'corsheaders.middleware.CorsMiddleware', ] ROOT_URLCONF = 'config.urls' @@ -163,9 +165,21 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.1/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') + +# WhiteNoise configuration for serving static files +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} # Default primary key field type # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Mailjet Email Configuration +MAILJET_API_KEY = os.getenv('MAILJET_API_KEY') +MAILJET_API_SECRET = os.getenv('MAILJET_API_SECRET') diff --git a/Backend/requirements.txt b/Backend/requirements.txt index 590e8e3..9896a3f 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -4,4 +4,7 @@ djangorestframework-simplejwt==5.3.1 django-cors-headers==4.5.0 dj-database-url==2.2.0 psycopg2-binary==2.9.10 -python-dotenv==1.0.0 \ No newline at end of file +python-dotenv==1.0.0 +mailjet-rest==1.5.1 +gunicorn==21.2.0 +whitenoise==6.6.0 \ No newline at end of file