Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions backend/common/middleware/get_company.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ def __call__(self, request):
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/",
Expand Down
2 changes: 0 additions & 2 deletions backend/common/middleware/rls_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
34 changes: 10 additions & 24 deletions backend/common/serializer.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Comment on lines +596 to +602
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disposable email validation added to MagicLinkRequestSerializer is a good security improvement, but it lacks test coverage. Consider adding tests to verify that disposable email domains are properly rejected and legitimate domains are allowed. This is especially important since the existing test file (backend/common/tests/test_magic_link.py) has comprehensive tests for other aspects of magic link functionality but doesn't test this validation.

Copilot uses AI. Check for mistakes.


class MagicLinkVerifySerializer(serializers.Serializer):
"""Serializer for verifying a magic link token."""
Expand Down
137 changes: 1 addition & 136 deletions backend/common/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,141 +18,6 @@
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/"""
Expand Down
50 changes: 0 additions & 50 deletions backend/common/tests/test_multitenancy.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,56 +301,6 @@ def test_switch_to_unauthorized_org_fails(self):
self.assertEqual(response.status_code, 403)


class TestLoginWithOrgContext(MultiTenancyBaseTestCase):
"""Test login endpoint includes org context"""

def test_login_returns_current_org(self):
"""Login should return current_org in response"""
response = self.client.post(
"/api/auth/login/", {"email": "user_a@test.com", "password": "testpass123"}
)

self.assertEqual(response.status_code, 200)
data = response.json()

self.assertIn("current_org", data)
self.assertEqual(data["current_org"]["id"], str(self.org_a.id))

def test_login_with_specific_org(self):
"""Login can specify which org to use"""
# Give user_a access to org_b
Profile.objects.create(
user=self.user_a, org=self.org_b, role="USER", is_active=True
)

response = self.client.post(
"/api/auth/login/",
{
"email": "user_a@test.com",
"password": "testpass123",
"org_id": str(self.org_b.id),
},
)

self.assertEqual(response.status_code, 200)
data = response.json()

self.assertEqual(data["current_org"]["id"], str(self.org_b.id))

def test_login_with_invalid_org_fails(self):
"""Login with org user doesn't have access to should fail"""
response = self.client.post(
"/api/auth/login/",
{
"email": "user_a@test.com",
"password": "testpass123",
"org_id": str(self.org_b.id), # user_a doesn't have access
},
)

self.assertEqual(response.status_code, 403)


class TestBaseOrgModel(TestCase):
"""Test BaseOrgModel validation"""

Expand Down
2 changes: 0 additions & 2 deletions backend/common/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from common.views.auth_views import (
GoogleIdTokenView,
GoogleOAuthCallbackView,
LoginView,
MagicLinkRequestView,
MagicLinkVerifyView,
MeView,
Expand Down Expand Up @@ -35,7 +34,6 @@
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(),
Expand Down
Loading
Loading