diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index fd83cba9..f722c374 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -746,7 +746,7 @@ class User(AbstractUser): if not self.account: return Site.objects.none() - base_sites = Site.objects.filter(account=self.account, is_active=True) + base_sites = Site.objects.filter(account=self.account) if self.role in ['owner', 'admin', 'developer'] or self.is_superuser or self.is_system_account_user(): return base_sites diff --git a/backend/igny8_core/auth/urls.py b/backend/igny8_core/auth/urls.py index a320702a..21f9def4 100644 --- a/backend/igny8_core/auth/urls.py +++ b/backend/igny8_core/auth/urls.py @@ -46,14 +46,56 @@ class RegisterView(APIView): permission_classes = [permissions.AllowAny] def post(self, request): - from .utils import generate_access_token, generate_refresh_token, get_token_expiry - from django.contrib.auth import login + from .utils import generate_access_token, generate_refresh_token, get_access_token_expiry, get_refresh_token_expiry + from django.contrib.auth import login, logout from django.utils import timezone + force_logout = request.data.get('force_logout', False) + serializer = RegisterSerializer(data=request.data) if serializer.is_valid(): user = serializer.save() + # SECURITY: Check for session contamination before login + # If there's an existing session from a different user, handle it + if request.session.session_key: + existing_user_id = request.session.get('_auth_user_id') + if existing_user_id and str(existing_user_id) != str(user.id): + # Get existing user details + try: + existing_user = User.objects.get(id=existing_user_id) + existing_email = existing_user.email + existing_username = existing_user.username or existing_email.split('@')[0] + except User.DoesNotExist: + existing_email = 'Unknown user' + existing_username = 'Unknown' + + # If not forcing logout, return conflict info + if not force_logout: + return Response( + { + 'status': 'error', + 'error': 'session_conflict', + 'message': f'You have an active session for another account ({existing_email}). Please logout first or choose to continue.', + 'existing_user': { + 'email': existing_email, + 'username': existing_username, + 'id': existing_user_id + }, + 'requested_user': { + 'email': user.email, + 'username': user.username or user.email.split('@')[0], + 'id': user.id + } + }, + status=status.HTTP_409_CONFLICT + ) + + # Force logout - clean existing session completely + logout(request) + # Clear all session data + request.session.flush() + # Log the user in (create session for session authentication) login(request, user) @@ -63,8 +105,8 @@ class RegisterView(APIView): # Generate JWT tokens access_token = generate_access_token(user, account) refresh_token = generate_refresh_token(user, account) - access_expires_at = timezone.now() + get_token_expiry('access') - refresh_expires_at = timezone.now() + get_token_expiry('refresh') + access_expires_at = timezone.now() + get_access_token_expiry() + refresh_expires_at = timezone.now() + get_refresh_token_expiry() user_serializer = UserSerializer(user) return success_response( @@ -104,6 +146,7 @@ class LoginView(APIView): email = serializer.validated_data['email'] password = serializer.validated_data['password'] remember_me = serializer.validated_data.get('remember_me', False) + force_logout = request.data.get('force_logout', False) try: user = User.objects.select_related('account', 'account__plan').get(email=email) @@ -115,6 +158,47 @@ class LoginView(APIView): ) if user.check_password(password): + # SECURITY: Check for session contamination before login + # If user has a session cookie from a different user, handle it + if request.session.session_key: + existing_user_id = request.session.get('_auth_user_id') + if existing_user_id and str(existing_user_id) != str(user.id): + # Get existing user details + try: + existing_user = User.objects.get(id=existing_user_id) + existing_email = existing_user.email + existing_username = existing_user.username or existing_email.split('@')[0] + except User.DoesNotExist: + existing_email = 'Unknown user' + existing_username = 'Unknown' + + # If not forcing logout, return conflict info + if not force_logout: + return Response( + { + 'status': 'error', + 'error': 'session_conflict', + 'message': f'You have an active session for another account ({existing_email}). Please logout first or choose to continue.', + 'existing_user': { + 'email': existing_email, + 'username': existing_username, + 'id': existing_user_id + }, + 'requested_user': { + 'email': user.email, + 'username': user.username or user.email.split('@')[0], + 'id': user.id + } + }, + status=status.HTTP_409_CONFLICT + ) + + # Force logout - clean existing session completely + from django.contrib.auth import logout + logout(request) + # Clear all session data + request.session.flush() + # Log the user in (create session for session authentication) from django.contrib.auth import login login(request, user) @@ -123,12 +207,12 @@ class LoginView(APIView): account = getattr(user, 'account', None) # Generate JWT tokens - from .utils import generate_access_token, generate_refresh_token, get_access_token_expiry, get_token_expiry + from .utils import generate_access_token, generate_refresh_token, get_access_token_expiry, get_refresh_token_expiry from django.utils import timezone access_token = generate_access_token(user, account, remember_me=remember_me) refresh_token = generate_refresh_token(user, account) access_expires_at = timezone.now() + get_access_token_expiry(remember_me=remember_me) - refresh_expires_at = timezone.now() + get_token_expiry('refresh') + refresh_expires_at = timezone.now() + get_refresh_token_expiry() # Serialize user data safely, handling missing account relationship try: @@ -274,6 +358,7 @@ class RefreshTokenView(APIView): account = getattr(user, 'account', None) # Generate new access token + from .utils import get_token_expiry access_token = generate_access_token(user, account) access_expires_at = get_token_expiry('access') diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 8e84c532..f278b125 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -92,15 +92,17 @@ CSRF_TRUSTED_ORIGINS = [ USE_SECURE_COOKIES = os.getenv('USE_SECURE_COOKIES', 'False').lower() == 'true' SESSION_COOKIE_SECURE = USE_SECURE_COOKIES CSRF_COOKIE_SECURE = USE_SECURE_COOKIES +CSRF_COOKIE_SAMESITE = 'Lax' # Match session cookie setting +CSRF_COOKIE_DOMAIN = '.igny8.com' # Share CSRF cookie across subdomains # CRITICAL: Session isolation to prevent contamination SESSION_COOKIE_NAME = 'igny8_sessionid' # Custom name to avoid conflicts SESSION_COOKIE_HTTPONLY = True # Prevent JavaScript access -SESSION_COOKIE_SAMESITE = 'Strict' # Prevent cross-site cookie sharing +SESSION_COOKIE_SAMESITE = 'Lax' # Changed from Strict to Lax - allows same-site top-level navigation SESSION_COOKIE_AGE = 3600 # 1 hour - extends on every request due to SESSION_SAVE_EVERY_REQUEST SESSION_SAVE_EVERY_REQUEST = True # CRITICAL: Update session on every request to prevent idle timeout SESSION_COOKIE_PATH = '/' # Explicit path -# Don't set SESSION_COOKIE_DOMAIN - let it default to current domain for strict isolation +SESSION_COOKIE_DOMAIN = '.igny8.com' # CRITICAL: Share cookie across subdomains (app.igny8.com and api.igny8.com) # CRITICAL: Custom authentication backend to disable user caching AUTHENTICATION_BACKENDS = [ diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 57e40245..c6fafe6f 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -24,6 +24,12 @@ export default function SignInForm() { const [error, setError] = useState(""); const [logoutReason, setLogoutReason] = useState(null); const [showLogoutDetails, setShowLogoutDetails] = useState(false); + const [sessionConflict, setSessionConflict] = useState<{ + message: string; + existingUser: { email: string; username: string; id: number }; + requestedUser: { email: string; username: string; id: number }; + } | null>(null); + const navigate = useNavigate(); const location = useLocation(); const { login, loading } = useAuthStore(); @@ -52,9 +58,10 @@ export default function SignInForm() { } }, []); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent, forceLogout = false) => { e.preventDefault(); setError(""); + setSessionConflict(null); if (!email || !password) { setError("Please enter both email and password"); @@ -62,11 +69,21 @@ export default function SignInForm() { } try { - await login(email, password, isChecked); + await login(email, password, isChecked, forceLogout); // Redirect to the page user was trying to access, or home const from = (location.state as any)?.from?.pathname || "/"; navigate(from, { replace: true }); } catch (err: any) { + // Handle session conflict + if (err?.type === 'SESSION_CONFLICT') { + setSessionConflict({ + message: err.message, + existingUser: err.existingUser, + requestedUser: err.requestedUser, + }); + return; + } + if (err?.code === 'PLAN_REQUIRED') { window.location.href = 'https://igny8.com/pricing'; return; @@ -74,6 +91,12 @@ export default function SignInForm() { setError(err.message || "Login failed. Please check your credentials."); } }; + + const handleForceLogout = async (e: React.FormEvent) => { + e.preventDefault(); + await handleSubmit(e, true); + }; + return (
@@ -202,6 +225,46 @@ export default function SignInForm() {
)} + {/* Session Conflict Alert */} + {sessionConflict && ( +
+
+ + + +

+ Active Session Detected +

+
+

+ You have an active session for: +

+

+ {sessionConflict.existingUser.email} +

+

+ You're trying to login as: {sessionConflict.requestedUser.email} +

+
+ + +
+
+ )} + {error && (
{error} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 2ce7fc29..51ad45f3 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -54,7 +54,7 @@ export const useAuthStore = create()( isAuthenticated: false, loading: false, // Always start with loading false - will be set true only during login/register - login: async (email, password, rememberMe = false) => { + login: async (email, password, rememberMe = false, forceLogout = false) => { set({ loading: true }); try { const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api'; @@ -63,11 +63,23 @@ export const useAuthStore = create()( headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ email, password, remember_me: rememberMe }), + credentials: 'include', // Include cookies + body: JSON.stringify({ email, password, remember_me: rememberMe, force_logout: forceLogout }), }); const data = await response.json(); + // Handle session conflict (409) + if (response.status === 409 && data.error === 'session_conflict') { + set({ loading: false }); + throw { + type: 'SESSION_CONFLICT', + message: data.message, + existingUser: data.existing_user, + requestedUser: data.requested_user, + }; + } + if (!response.ok || !data.success) { const message = data.error || data.message || 'Login failed'; if (response.status === 402) {