refactor: Remove email/password login and registration endpoints, and add disposable email validation for magic link requests.

This commit is contained in:
Ashwin 2026-02-24 00:07:34 +05:30
parent 6495f33c3d
commit 0dcaa9dcc4
9 changed files with 12 additions and 321 deletions

View file

@ -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/",

View file

@ -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/",

View file

@ -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."""

View file

@ -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/"""

View file

@ -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(),

View file

@ -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

View file

@ -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

View file

@ -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<any>} User data with tokens and current_org
*/
async login(email, password, orgId = undefined) {
/** @type {Record<string, string>} */
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

View file

@ -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
// ==========================================================================