diff --git a/backend/backend/settings.py b/backend/backend/settings.py index aeb0cef..26bd8a8 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -57,10 +57,14 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: 'fallback-insecure-key-for-local-dev-and-testing' if (DEBUG or TESTING) else '', ) ALLOWED_HOSTS = ['*'] -CORS_ALLOWED_ORIGINS = [ - "http://localhost:3000", - "http://127.0.0.1:3000", -] +ADMIN_PATH = os.getenv('ADMIN_PATH', 'secure-admin-portal') + +# Cookie Security +SESSION_COOKIE_SECURE = not DEBUG +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' +CSRF_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SAMESITE = 'Lax' INSTALLED_APPS = [ 'django.contrib.admin', @@ -71,6 +75,7 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: 'django.contrib.staticfiles', 'rest_framework', 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', 'corsheaders', 'django_celery_beat', 'tenants', @@ -124,9 +129,13 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, - {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': {'min_length': 10}, + }, {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, + {'NAME': 'users.validators.ComplexityPasswordValidator'}, ] LANGUAGE_CODE = 'en-us' @@ -142,7 +151,16 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', - ) + ), + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/day', + 'user': '1000/day', + 'login': '5/minute', + } } # Celery Configuration @@ -179,10 +197,19 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'USER_ID_FIELD': 'id', 'USER_ID_CLAIM': 'user_id', + 'SIGNING_KEY': os.getenv('JWT_SIGNING_KEY', SECRET_KEY), } -# Allow all origins in development -CORS_ALLOW_ALL_ORIGINS = True +# Restrict CORS to trusted origins +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:8080", + "http://127.0.0.1:8080", +] +if os.getenv('FRONTEND_BASE_URL'): + CORS_ALLOWED_ORIGINS.append(os.getenv('FRONTEND_BASE_URL').rstrip('/')) # Gemini API Key GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', _read_local_env_value('GEMINI_API_KEY', '')) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 82f6b7a..41628dd 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -6,8 +6,12 @@ from rest_framework_simplejwt.views import TokenObtainPairView as BaseTokenObtainPairView from users.jwt import CustomTokenObtainSerializer +from django.conf import settings +from users.throttling import LoginRateThrottle + class CustomTokenObtainPairView(BaseTokenObtainPairView): serializer_class = CustomTokenObtainSerializer + throttle_classes = [LoginRateThrottle] from users.views import AuthViewSet from leads.views import BlockedDomainViewSet, LeadImportJobViewSet, LeadViewSet, TagViewSet @@ -49,7 +53,7 @@ def api_root(_request): urlpatterns = [ path('', api_root, name='api_root'), - path('admin/', admin.site.urls), + path(f'{settings.ADMIN_PATH}/', admin.site.urls), path('api/v1/token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/v1/webhooks/email/', WebhookView.as_view(), name='email_webhook'), diff --git a/backend/db.sqlite3 b/backend/db.sqlite3 index e43ec6b..f2cd4fb 100644 Binary files a/backend/db.sqlite3 and b/backend/db.sqlite3 differ diff --git a/backend/tenants/security.py b/backend/tenants/security.py index c346d57..de4d16c 100644 --- a/backend/tenants/security.py +++ b/backend/tenants/security.py @@ -58,4 +58,5 @@ def process_response(self, request, response): response['X-XSS-Protection'] = '1; mode=block' response['Referrer-Policy'] = 'strict-origin-when-cross-origin' response['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()' + response['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" return response diff --git a/backend/users/jwt.py b/backend/users/jwt.py index 4cb1bfc..c931914 100644 --- a/backend/users/jwt.py +++ b/backend/users/jwt.py @@ -1,4 +1,35 @@ +import jwt +from datetime import datetime, timedelta, timezone +from django.conf import settings +from rest_framework import serializers from rest_framework_simplejwt.serializers import TokenObtainPairSerializer class CustomTokenObtainSerializer(TokenObtainPairSerializer): username_field = 'email' + + def validate(self, attrs): + # We need to authenticate first. SimpleJWT validation handles active user check. + data = super().validate(attrs) + + # Enforce email verification + if not self.user.is_email_verified: + raise serializers.ValidationError('Email not verified. Please verify your email first.') + + # Enforce MFA/2FA check + if self.user.mfa_enabled: + temp_token = jwt.encode( + { + 'user_id': str(self.user.id), + 'mfa_pending': True, + 'exp': datetime.now(timezone.utc) + timedelta(minutes=5) + }, + settings.SECRET_KEY, + algorithm='HS256' + ) + return { + 'mfa_required': True, + 'mfa_token': temp_token, + 'email': self.user.email + } + + return data diff --git a/backend/users/migrations/0003_user_is_email_verified_user_mfa_enabled_and_more.py b/backend/users/migrations/0003_user_is_email_verified_user_mfa_enabled_and_more.py new file mode 100644 index 0000000..e2f9c89 --- /dev/null +++ b/backend/users/migrations/0003_user_is_email_verified_user_mfa_enabled_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.14 on 2026-07-12 06:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_user_member_role_default'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_email_verified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='mfa_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='mfa_secret', + field=models.CharField(blank=True, max_length=32, null=True), + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index 7bbe8a6..b9031fa 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -33,6 +33,9 @@ class User(AbstractBaseUser, PermissionsMixin, TenantModel): role = models.CharField(max_length=20, choices=ROLE_CHOICES, default=ROLE_MEMBER) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) + is_email_verified = models.BooleanField(default=False) + mfa_enabled = models.BooleanField(default=False) + mfa_secret = models.CharField(max_length=32, blank=True, null=True) objects = UserManager() diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 7ac9fbc..c90224c 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -25,6 +25,16 @@ def validate_email(self, value): raise serializers.ValidationError('A user with this email already exists.') return value + def validate(self, attrs): + from django.contrib.auth.password_validation import validate_password + from django.core.exceptions import ValidationError as DjangoValidationError + user = User(email=attrs.get('email')) + try: + validate_password(attrs.get('password'), user) + except DjangoValidationError as exc: + raise serializers.ValidationError({'password': list(exc.messages)}) + return attrs + def create(self, validated_data): org = Organization.objects.create(name=validated_data['organization_name']) user = User.objects.create_user( diff --git a/backend/users/tests.py b/backend/users/tests.py index 2c73fd9..59a5b8e 100644 --- a/backend/users/tests.py +++ b/backend/users/tests.py @@ -1,5 +1,9 @@ from rest_framework import status from rest_framework.test import APITestCase +from django.core import mail +from django.utils.http import urlsafe_base64_encode +from django.utils.encoding import force_bytes +from django.contrib.auth.tokens import default_token_generator from tenants.models import Organization from users.models import User @@ -104,3 +108,152 @@ def test_member_cannot_delete_organization(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertTrue(Organization.objects.filter(id=self.organization.id).exists()) self.assertTrue(User.objects.filter(id=self.user.id).exists()) + + +class SecurityHardeningTests(APITestCase): + def setUp(self): + self.organization = Organization.objects.create(name='Security Org') + + def test_password_complexity_validator(self): + # Register with simple password (missing special char and digit) + response = self.client.post( + '/api/v1/auth/register/', + { + 'email': 'test_weak@example.com', + 'password': 'password', + 'organization_name': 'New Org', + }, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('password', response.data) + + def test_email_verification_flow(self): + # 1. Register user + response = self.client.post( + '/api/v1/auth/register/', + { + 'email': 'unverified@example.com', + 'password': 'StrongPass123!', + 'organization_name': 'New Org', + }, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('user', response.data) + user = User.objects.get(email='unverified@example.com') + self.assertFalse(user.is_email_verified) + + # Ensure verification email was sent to outbox + self.assertEqual(len(mail.outbox), 1) + self.assertIn('Verify Your LeadOrbit Email', mail.outbox[0].subject) + + # 2. Try logging in (should fail since email is unverified) + login_response = self.client.post( + '/api/v1/token/', + { + 'email': 'unverified@example.com', + 'password': 'StrongPass123!', + }, + format='json', + ) + self.assertEqual(login_response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('non_field_errors', login_response.data) + + # 3. Get verification token/uid + uidb64 = urlsafe_base64_encode(force_bytes(user.pk)) + token = default_token_generator.make_token(user) + + # 4. Verify email + verify_response = self.client.post( + '/api/v1/auth/verify-email/', + { + 'uid': uidb64, + 'token': token, + }, + format='json', + ) + self.assertEqual(verify_response.status_code, status.HTTP_200_OK) + user.refresh_from_db() + self.assertTrue(user.is_email_verified) + + # 5. Log in after verification (should succeed) + login_response2 = self.client.post( + '/api/v1/token/', + { + 'email': 'unverified@example.com', + 'password': 'StrongPass123!', + }, + format='json', + ) + self.assertEqual(login_response2.status_code, status.HTTP_200_OK) + self.assertIn('access', login_response2.data) + + def test_mfa_flow(self): + import pyotp + # Create a verified user + user = User.objects.create_user( + email='mfa_user@example.com', + password='StrongPass123!', + organization=self.organization, + role=User.ROLE_ADMIN, + ) + user.is_email_verified = True + user.save() + + # 1. Enable MFA setup + self.client.force_authenticate(user) + enable_response = self.client.post('/api/v1/auth/enable-mfa/', format='json') + self.assertEqual(enable_response.status_code, status.HTTP_200_OK) + self.assertIn('secret', enable_response.data) + secret = enable_response.data['secret'] + + # 2. Confirm MFA setup with valid TOTP code + totp = pyotp.TOTP(secret) + confirm_response = self.client.post( + '/api/v1/auth/confirm-mfa/', + {'code': totp.now()}, + format='json', + ) + self.assertEqual(confirm_response.status_code, status.HTTP_200_OK) + user.refresh_from_db() + self.assertTrue(user.mfa_enabled) + + # 3. Log in (should return MFA challenge/token) + self.client.logout() + login_response = self.client.post( + '/api/v1/token/', + { + 'email': 'mfa_user@example.com', + 'password': 'StrongPass123!', + }, + format='json', + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + self.assertTrue(login_response.data.get('mfa_required')) + mfa_token = login_response.data.get('mfa_token') + self.assertIsNotNone(mfa_token) + + # 4. Verify MFA with invalid code (should fail) + verify_response_fail = self.client.post( + '/api/v1/auth/verify-mfa/', + { + 'mfa_token': mfa_token, + 'code': '000000', + }, + format='json', + ) + self.assertEqual(verify_response_fail.status_code, status.HTTP_400_BAD_REQUEST) + + # 5. Verify MFA with valid code (should succeed and return JWT tokens) + verify_response_success = self.client.post( + '/api/v1/auth/verify-mfa/', + { + 'mfa_token': mfa_token, + 'code': totp.now(), + }, + format='json', + ) + self.assertEqual(verify_response_success.status_code, status.HTTP_200_OK) + self.assertIn('access', verify_response_success.data) + diff --git a/backend/users/throttling.py b/backend/users/throttling.py new file mode 100644 index 0000000..e70be67 --- /dev/null +++ b/backend/users/throttling.py @@ -0,0 +1,24 @@ +from rest_framework.throttling import SimpleRateThrottle + +class LoginRateThrottle(SimpleRateThrottle): + scope = 'login' + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) + + username = None + if hasattr(request, 'data') and isinstance(request.data, dict): + username = request.data.get('email') + + if username: + return self.cache_format % { + 'scope': self.scope, + 'ident': f"{ident}_{username}" + } + return self.cache_format % { + 'scope': self.scope, + 'ident': ident + } diff --git a/backend/users/validators.py b/backend/users/validators.py new file mode 100644 index 0000000..025fec7 --- /dev/null +++ b/backend/users/validators.py @@ -0,0 +1,32 @@ +import re +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +class ComplexityPasswordValidator: + def validate(self, password, user=None): + if not re.search(r'[A-Z]', password): + raise ValidationError( + _("The password must contain at least one uppercase letter."), + code='password_no_upper', + ) + if not re.search(r'[a-z]', password): + raise ValidationError( + _("The password must contain at least one lowercase letter."), + code='password_no_lower', + ) + if not re.search(r'\d', password): + raise ValidationError( + _("The password must contain at least one digit."), + code='password_no_digit', + ) + if not re.search(r'[!@#$%^&*(),.?":{}|<>\-_=+\[\];:\']', password): + raise ValidationError( + _("The password must contain at least one special character."), + code='password_no_special', + ) + + def get_help_text(self): + return _( + "Your password must contain at least one uppercase letter, " + "one lowercase letter, one digit, and one special character." + ) diff --git a/backend/users/views.py b/backend/users/views.py index 71d5c1b..0583284 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -9,20 +9,124 @@ from .serializers import UserSerializer, RegisterSerializer from rest_framework_simplejwt.tokens import RefreshToken +from django.contrib.auth.tokens import default_token_generator +from django.utils.http import urlsafe_base64_encode +from django.utils.encoding import force_bytes +from django.core.mail import send_mail +from django.conf import settings +from users.throttling import LoginRateThrottle +import pyotp +import jwt + class AuthViewSet(viewsets.GenericViewSet): - @action(detail=False, methods=['post'], permission_classes=[AllowAny]) + @action(detail=False, methods=['post'], permission_classes=[AllowAny], throttle_classes=[LoginRateThrottle]) def register(self, request): serializer = RegisterSerializer(data=request.data) if serializer.is_valid(): user = serializer.save() - refresh = RefreshToken.for_user(user) + + # Generate email verification token and UID + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + + # Build verification link + frontend_url = getattr(settings, 'FRONTEND_BASE_URL', '') or 'http://localhost:3000' + verification_link = f"{frontend_url}/verify-email?uid={uid}&token={token}" + + # Send verification email + send_mail( + 'Verify Your LeadOrbit Email', + f'Please verify your email by clicking the following link: {verification_link}', + 'noreply@leadorbit.com', + [user.email], + fail_silently=False, + ) + return Response({ 'user': UserSerializer(user).data, - 'refresh': str(refresh), - 'access': str(refresh.access_token), + 'message': 'Registration successful. Please check your email to verify your account.' }, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @action(detail=False, methods=['post'], permission_classes=[AllowAny], url_path='verify-email', throttle_classes=[LoginRateThrottle]) + def verify_email(self, request): + uidb64 = request.data.get('uid') + token = request.data.get('token') + if not uidb64 or not token: + return Response({'error': 'Missing uid or token.'}, status=status.HTTP_400_BAD_REQUEST) + + try: + from django.utils.http import urlsafe_base64_decode + from django.utils.encoding import force_str + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + user = None + + if user is not None and default_token_generator.check_token(user, token): + user.is_email_verified = True + user.save(update_fields=['is_email_verified']) + return Response({'message': 'Email successfully verified.'}, status=status.HTTP_200_OK) + return Response({'error': 'Invalid or expired verification token.'}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated], url_path='enable-mfa') + def enable_mfa(self, request): + user = request.user + if user.mfa_enabled: + return Response({'detail': 'MFA is already enabled.'}, status=status.HTTP_400_BAD_REQUEST) + + secret = pyotp.random_base32() + user.mfa_secret = secret + user.save(update_fields=['mfa_secret']) + + totp = pyotp.TOTP(secret) + provisioning_uri = totp.provisioning_uri(name=user.email, issuer_name="LeadOrbit") + return Response({ + 'secret': secret, + 'provisioning_uri': provisioning_uri + }, status=status.HTTP_200_OK) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated], url_path='confirm-mfa') + def confirm_mfa(self, request): + user = request.user + code = request.data.get('code') + if not code: + return Response({'error': 'Verification code is required.'}, status=status.HTTP_400_BAD_REQUEST) + if not user.mfa_secret: + return Response({'error': 'MFA setup has not been initiated.'}, status=status.HTTP_400_BAD_REQUEST) + + totp = pyotp.TOTP(user.mfa_secret) + if totp.verify(code): + user.mfa_enabled = True + user.save(update_fields=['mfa_enabled']) + return Response({'message': 'MFA enabled successfully.'}, status=status.HTTP_200_OK) + return Response({'error': 'Invalid verification code.'}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['post'], permission_classes=[AllowAny], url_path='verify-mfa', throttle_classes=[LoginRateThrottle]) + def verify_mfa(self, request): + mfa_token = request.data.get('mfa_token') + code = request.data.get('code') + if not mfa_token or not code: + return Response({'error': 'mfa_token and verification code are required.'}, status=status.HTTP_400_BAD_REQUEST) + + try: + payload = jwt.decode(mfa_token, settings.SECRET_KEY, algorithms=['HS256']) + if not payload.get('mfa_pending'): + raise jwt.InvalidTokenError() + user_id = payload.get('user_id') + user = User.objects.get(pk=user_id) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist): + return Response({'error': 'Invalid or expired MFA token.'}, status=status.HTTP_400_BAD_REQUEST) + + totp = pyotp.TOTP(user.mfa_secret) + if totp.verify(code): + refresh = RefreshToken.for_user(user) + return Response({ + 'refresh': str(refresh), + 'access': str(refresh.access_token), + }, status=status.HTTP_200_OK) + return Response({'error': 'Invalid verification code.'}, status=status.HTTP_400_BAD_REQUEST) + @action(detail=False, methods=['get', 'patch'], permission_classes=[IsAuthenticated]) def me(self, request): if request.method == 'PATCH': diff --git a/requirements.txt b/requirements.txt index 1598dc3..c213f8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ cryptography>=48.0.1 firebase-admin>=6.5,<7.0 twilio>=9.0,<10.0 gunicorn>=20.1,<21.0 +pyotp>=2.9.0