refactor: Remove email/password login and registration endpoints, and add disposable email validation for magic link requests.
This commit is contained in:
parent
6495f33c3d
commit
0dcaa9dcc4
9 changed files with 12 additions and 321 deletions
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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/"""
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ==========================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue