diff --git a/backend/common/middleware/get_company.py b/backend/common/middleware/get_company.py index 85f0e9f6..c3868659 100644 --- a/backend/common/middleware/get_company.py +++ b/backend/common/middleware/get_company.py @@ -28,9 +28,7 @@ class GetProfileAndOrg: def process_request(self, request): # Skip JWT validation for authentication endpoints that don't need org context auth_skip_paths = [ - "/api/auth/login/", "/api/auth/google/", - "/api/auth/register/", "/api/auth/refresh-token/", "/api/auth/me/", "/api/auth/switch-org/", diff --git a/backend/common/middleware/rls_context.py b/backend/common/middleware/rls_context.py index ec47611c..8ea5a85f 100644 --- a/backend/common/middleware/rls_context.py +++ b/backend/common/middleware/rls_context.py @@ -107,8 +107,6 @@ class RequireOrgContext: # Paths that don't require org context EXEMPT_PATHS = [ - "/api/auth/login/", - "/api/auth/register/", "/api/auth/refresh-token/", "/api/auth/me/", "/api/auth/switch-org/", diff --git a/backend/common/serializer.py b/backend/common/serializer.py index 7a121ec8..514a02af 100644 --- a/backend/common/serializer.py +++ b/backend/common/serializer.py @@ -1,10 +1,11 @@ import re -from django.contrib.auth import authenticate from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework_simplejwt.tokens import RefreshToken +from disposable_email_domains import blocklist as disposable_domains + from common.utils import CURRENCY_SYMBOLS from common.models import ( Activity, @@ -588,33 +589,18 @@ class UserUpdateStatusSwaggerSerializer(serializers.Serializer): # JWT Authentication Serializers for SvelteKit Integration -class LoginSerializer(serializers.Serializer): - """Serializer for user login with email and password""" - - email = serializers.EmailField(required=True) - password = serializers.CharField(required=True, write_only=True) - - def validate(self, attrs): - email = attrs.get("email") - password = attrs.get("password") - - if email and password: - user = authenticate(username=email, password=password) - if not user: - raise serializers.ValidationError("Invalid email or password") - if not user.is_active: - raise serializers.ValidationError("User account is disabled") - else: - raise serializers.ValidationError('Must include "email" and "password"') - - attrs["user"] = user - return attrs - - class MagicLinkRequestSerializer(serializers.Serializer): """Serializer for requesting a magic link.""" email = serializers.EmailField(required=True) + def validate_email(self, value): + domain = value.rsplit("@", 1)[-1].lower() + if domain in disposable_domains: + raise serializers.ValidationError( + "Disposable email addresses are not allowed." + ) + return value + class MagicLinkVerifySerializer(serializers.Serializer): """Serializer for verifying a magic link token.""" diff --git a/backend/common/tests/test_auth.py b/backend/common/tests/test_auth.py index 65a91f60..9449688d 100644 --- a/backend/common/tests/test_auth.py +++ b/backend/common/tests/test_auth.py @@ -1,5 +1,5 @@ """ -Tests for authentication views: login, register, me, token refresh, org switch, +Tests for authentication views: me, token refresh, org switch, Google OAuth callback, Google ID token, and token refresh edge cases. Run with: pytest common/tests/test_auth.py -v @@ -18,141 +18,6 @@ from common.models import Org, Profile, User from common.serializer import OrgAwareRefreshToken -@pytest.mark.django_db -class TestLoginView: - """Tests for POST /api/auth/login/""" - - url = "/api/auth/login/" - - def test_login_success(self, unauthenticated_client, admin_user, admin_profile): - response = unauthenticated_client.post( - self.url, - {"email": "admin@test.com", "password": "testpass123"}, - format="json", - ) - assert response.status_code == status.HTTP_200_OK - assert "access_token" in response.data - assert "refresh_token" in response.data - assert "current_org" in response.data - - def test_login_wrong_password(self, unauthenticated_client, admin_user, admin_profile): - response = unauthenticated_client.post( - self.url, - {"email": "admin@test.com", "password": "wrongpassword"}, - format="json", - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - def test_login_nonexistent_user(self, unauthenticated_client): - response = unauthenticated_client.post( - self.url, - {"email": "nobody@test.com", "password": "testpass123"}, - format="json", - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - def test_login_returns_tokens_and_org( - self, unauthenticated_client, admin_user, admin_profile, org_a - ): - response = unauthenticated_client.post( - self.url, - {"email": "admin@test.com", "password": "testpass123"}, - format="json", - ) - assert response.status_code == status.HTTP_200_OK - data = response.data - assert "access_token" in data - assert "refresh_token" in data - assert "user" in data - assert "current_org" in data - assert data["current_org"]["id"] == str(org_a.id) - - def test_login_with_specific_org(self, unauthenticated_client, admin_user, org_b): - # Give admin_user access to org_b - Profile.objects.create( - user=admin_user, org=org_b, role="USER", is_active=True - ) - response = unauthenticated_client.post( - self.url, - { - "email": "admin@test.com", - "password": "testpass123", - "org_id": str(org_b.id), - }, - format="json", - ) - assert response.status_code == status.HTTP_200_OK - assert response.data["current_org"]["id"] == str(org_b.id) - - def test_login_unauthorized_org( - self, unauthenticated_client, admin_user, admin_profile, org_b - ): - # admin_user does NOT have a profile in org_b - response = unauthenticated_client.post( - self.url, - { - "email": "admin@test.com", - "password": "testpass123", - "org_id": str(org_b.id), - }, - format="json", - ) - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_login_missing_email(self, unauthenticated_client): - """Login without email should fail.""" - response = unauthenticated_client.post( - self.url, - {"password": "testpass123"}, - format="json", - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - def test_login_missing_password(self, unauthenticated_client, admin_user): - """Login without password should fail.""" - response = unauthenticated_client.post( - self.url, - {"email": "admin@test.com"}, - format="json", - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - def test_login_empty_body(self, unauthenticated_client): - """Login with empty body should fail.""" - response = unauthenticated_client.post( - self.url, - {}, - format="json", - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - def test_login_user_data_returned( - self, unauthenticated_client, admin_user, admin_profile - ): - """Login should return user details.""" - response = unauthenticated_client.post( - self.url, - {"email": "admin@test.com", "password": "testpass123"}, - format="json", - ) - assert response.status_code == status.HTTP_200_OK - user_data = response.data["user"] - assert "email" in user_data - assert user_data["email"] == "admin@test.com" - - def test_login_user_no_org(self, unauthenticated_client): - """Login for user without any org should return tokens without current_org.""" - User.objects.create_user(email="orphan@test.com", password="testpass123") - response = unauthenticated_client.post( - self.url, - {"email": "orphan@test.com", "password": "testpass123"}, - format="json", - ) - assert response.status_code == status.HTTP_200_OK - assert "access_token" in response.data - assert "current_org" not in response.data - - @pytest.mark.django_db class TestMeView: """Tests for GET /api/auth/me/""" diff --git a/backend/common/urls.py b/backend/common/urls.py index d2d83d31..7a68e846 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -3,7 +3,6 @@ from django.urls import path from common.views.auth_views import ( GoogleIdTokenView, GoogleOAuthCallbackView, - LoginView, MagicLinkRequestView, MagicLinkVerifyView, MeView, @@ -35,7 +34,6 @@ app_name = "api_common" urlpatterns = [ path("dashboard/", ApiHomeView.as_view()), # JWT Authentication endpoints for SvelteKit integration - path("auth/login/", LoginView.as_view(), name="login"), path( "auth/refresh-token/", OrgAwareTokenRefreshView.as_view(), diff --git a/backend/common/views/auth_views.py b/backend/common/views/auth_views.py index 1761ecad..397f12bd 100644 --- a/backend/common/views/auth_views.py +++ b/backend/common/views/auth_views.py @@ -253,118 +253,6 @@ class GoogleIdTokenView(APIView): ) -class LoginView(APIView): - """ - Login with email and password, returns JWT tokens - """ - - permission_classes = [] - authentication_classes = [] - - @extend_schema( - description="Login with email and password", - request=serializer.LoginSerializer, - responses={ - 200: { - "type": "object", - "properties": { - "access_token": {"type": "string"}, - "refresh_token": {"type": "string"}, - "user": { - "type": "object", - "properties": { - "id": {"type": "string"}, - "email": {"type": "string"}, - "organizations": {"type": "array"}, - }, - }, - }, - } - }, - ) - def post(self, request): - from django.utils import timezone - - from common.audit_log import audit_log - - serializer_obj = serializer.LoginSerializer(data=request.data) - if serializer_obj.is_valid(): - user = serializer_obj.validated_data["user"] - - # Update last_login timestamp - user.last_login = timezone.now() - user.save(update_fields=["last_login"]) - - # Get user's organizations - profiles = Profile.objects.filter(user=user, is_active=True) - - # Get the default org (first org or from request) - org_id = request.data.get("org_id") - default_org = None - - if org_id: - try: - uuid.UUID(str(org_id)) - except (ValueError, AttributeError): - return Response( - {"error": "org_id must be a valid UUID"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # Use specified org if user has access - try: - profile = profiles.get(org_id=org_id) - default_org = profile.org - except Profile.DoesNotExist: - audit_log.login_failure( - user.email, f"No access to org {org_id}", request - ) - return Response( - { - "error": "User does not have access to specified organization" - }, - status=status.HTTP_403_FORBIDDEN, - ) - elif profiles.exists(): - # Use first available org - profile = profiles.first() - default_org = profile.org - - # Generate JWT tokens with org context (include profile for role) - if default_org: - token = OrgAwareRefreshToken.for_user_and_org( - user, default_org, profile - ) - else: - # User has no orgs - generate token without org context (but with user info) - token = OrgAwareRefreshToken.for_user_and_org(user, None) - - # Audit log successful login - audit_log.login_success(user, default_org, request) - - # Get user details with organizations - user_serializer = serializer.UserDetailSerializer(user) - - response_data = { - "access_token": str(token.access_token), - "refresh_token": str(token), - "user": user_serializer.data, - } - - # Include current org info if available - if default_org: - response_data["current_org"] = { - "id": str(default_org.id), - "name": default_org.name, - } - - return Response(response_data, status=status.HTTP_200_OK) - - # Log failed login - email = request.data.get("email", "unknown") - audit_log.login_failure(email, str(serializer_obj.errors), request) - return Response(serializer_obj.errors, status=status.HTTP_400_BAD_REQUEST) - - class MeView(APIView): """ Get current authenticated user details diff --git a/backend/requirements.txt b/backend/requirements.txt index 2a81346e..193d3afc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,6 +12,7 @@ sentry-sdk==2.53.0 django_storages==1.14.6 django-crum==0.7.9 +disposable-email-domains>=0.0.162 redis==7.2.0 pytz==2025.2 requests==2.32.5 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 23872cbf..417707fa 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -222,43 +222,6 @@ export async function apiRequest(endpoint, options = {}) { * Authentication API methods */ export const auth = { - /** - * Login with email and password - * @param {string} email - User email - * @param {string} password - User password - * @param {string} [orgId] - Optional organization ID to login to - * @returns {Promise} User data with tokens and current_org - */ - async login(email, password, orgId = undefined) { - /** @type {Record} */ - const body = { email, password }; - if (orgId) { - if (!UUID_RE.test(orgId)) { - throw new Error('Invalid organization ID format'); - } - body.org_id = orgId; - } - - /** @type {any} */ - const data = await apiRequest('/auth/login/', { - method: 'POST', - body, - requiresAuth: false - }); - - // Store tokens and user data - setInStorage(STORAGE_KEYS.ACCESS_TOKEN, data.access_token); - setInStorage(STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); - setInStorage(STORAGE_KEYS.USER, JSON.stringify(data.user)); - - // Store current org if provided - if (data.current_org) { - setInStorage(STORAGE_KEYS.ORG_ID, data.current_org.id); - } - - return data; - }, - /** * Switch to a different organization * This issues new JWT tokens with the new org context diff --git a/mobile/lib/config/api_config.dart b/mobile/lib/config/api_config.dart index ed29a7df..3ba7d939 100644 --- a/mobile/lib/config/api_config.dart +++ b/mobile/lib/config/api_config.dart @@ -25,9 +25,6 @@ class ApiConfig { // AUTHENTICATION ENDPOINTS // ========================================================================== - /// Email/password login - static String get login => '$apiBaseUrl/auth/login/'; - /// Google Sign-In with ID token (same as old app) static String get googleLogin => '$apiBaseUrl/auth/google/'; @@ -43,9 +40,6 @@ class ApiConfig { /// Switch organization context static String get switchOrg => '$apiBaseUrl/auth/switch-org/'; - /// User registration - static String get register => '$apiBaseUrl/auth/register/'; - // ========================================================================== // DASHBOARD // ==========================================================================