Revert "messy logout fixing"

This reverts commit 4fb3a144d7.
This commit is contained in:
alorig
2025-12-15 17:24:07 +05:00
parent 4fb3a144d7
commit 25f1c32366
27 changed files with 95 additions and 4396 deletions

View File

@@ -1,389 +0,0 @@
# 🔍 Authentication System Audit Report
## Random User Logout Issues - Complete Analysis
**Date:** December 15, 2025
**Status:** ⚠️ CRITICAL ISSUES FOUND
**Priority:** HIGH
---
## 📋 Executive Summary
This audit identified **8 CRITICAL issues** and **5 moderate issues** that could cause random user logouts. The system has multiple aggressive logout triggers that were likely implemented to prevent session contamination, but these are now causing false positives and logging out legitimate users.
---
## 🔴 CRITICAL ISSUES (Immediate Action Required)
### 1. **Aggressive Session Integrity Checks in Middleware**
**Location:** `backend/igny8_core/auth/middleware.py` (Lines 55-82)
**Problem:**
The `AccountContextMiddleware` validates `_account_id` and `_user_id` stored in session on EVERY request. If there's ANY mismatch, it immediately logs out the user.
```python
# Session contamination detected - force logout
if stored_account_id and stored_account_id != request.account.id:
logout(request)
return JsonResponse({'error': 'Session integrity violation...'})
```
**Why This Causes Random Logouts:**
- Race conditions during account switching
- Database updates to account relationships
- Session data not yet synchronized
- Multiple tabs/windows with different accounts
- Session persistence delays
**Impact:** HIGH - Affects all authenticated users on every request
---
### 2. **JWT Token Expiry Too Short**
**Location:** `backend/igny8_core/settings.py` (Line 523)
**Problem:**
```python
JWT_ACCESS_TOKEN_EXPIRY = timedelta(minutes=15) # Too short!
```
**Why This Causes Random Logouts:**
- Users idle for >15 minutes get logged out
- Token refresh mechanism has race conditions
- Frontend proactive refresh (12 minutes) doesn't always succeed
- Network delays can cause refresh to happen after expiry
**Impact:** HIGH - Affects users during long operations or idle periods
---
### 3. **Session Cookie Age = 24 Hours but SESSION_SAVE_EVERY_REQUEST = False**
**Location:** `backend/igny8_core/settings.py` (Lines 100-101)
**Problem:**
```python
SESSION_COOKIE_AGE = 86400 # 24 hours
SESSION_SAVE_EVERY_REQUEST = False # Session not extended on activity
```
**Why This Causes Random Logouts:**
- Session expires exactly 24 hours after login, even if user is active
- User working for >24 hours gets logged out mid-session
- No sliding window - fixed expiry time
**Impact:** MODERATE - Affects users with long sessions
---
### 4. **Account/Plan Validation on Every Request**
**Location:** `backend/igny8_core/auth/middleware.py` (Lines 181-195)
**Problem:**
Middleware calls `validate_account_and_plan()` on EVERY request, and if validation fails (account status changes, plan becomes inactive), user is IMMEDIATELY logged out.
```python
validation_error = self._validate_account_and_plan(request, request.user)
if validation_error:
logout(request) # Immediate logout
return JsonResponse({...})
```
**Why This Causes Random Logouts:**
- Account status changes during active session (admin updates)
- Plan expiry/renewal processing
- Database replication lag
- Race conditions during billing operations
**Impact:** HIGH - Affects users when account/plan changes
---
### 5. **Multiple Frontend Logout Triggers**
**Location:** `frontend/src/services/api.ts` (Lines 194-200, 321-322, 327-328)
**Problem:**
Frontend has multiple aggressive logout triggers:
```typescript
// On 403 error:
if (errorMessage?.includes?.('Authentication credentials')) {
logout();
window.location.href = '/signin';
}
// On refresh token failure:
logout();
```
**Why This Causes Random Logouts:**
- 403 errors are triggered for permission issues (NOT just auth)
- Network timeouts interpreted as auth failures
- Plan/limit errors (402) causing logouts
- Race conditions during token refresh
**Impact:** HIGH - False positive logouts on permission/network errors
---
### 6. **Proactive Token Refresh Race Condition**
**Location:** `frontend/src/layout/AppLayout.tsx` (Lines 153-167)
**Problem:**
Frontend proactively refreshes token every 12 minutes, but this can race with API calls:
```typescript
const tokenRefreshInterval = setInterval(async () => {
await authState.refreshToken();
}, 720000); // 12 minutes
```
**Why This Causes Random Logouts:**
- Refresh fails → logout triggered
- Old token used during refresh window
- Multiple tabs triggering simultaneous refreshes
- Network delays causing refresh to fail
**Impact:** MODERATE - Periodic logout risk
---
### 7. **Frontend Auto-Refresh User Data Every 2 Minutes**
**Location:** `frontend/src/layout/AppLayout.tsx` (Lines 169, 91-180)
**Problem:**
Frontend calls `refreshUser()` every 2 minutes, on tab focus, and on visibility change. If ANY of these fail, it could trigger logout.
```typescript
const intervalId = setInterval(() => refreshUserData(), 120000);
```
**Why This Causes Random Logouts:**
- API errors during refresh
- Network issues
- Backend temporary unavailability
- Race conditions with other API calls
**Impact:** MODERATE - Periodic logout risk
---
### 8. **CSRF Token Issues (SameSite=Strict)**
**Location:** `backend/igny8_core/settings.py` (Line 99)
**Problem:**
```python
SESSION_COOKIE_SAMESITE = 'Strict' # Too restrictive
```
**Why This Causes Random Logouts:**
- Cookies not sent on cross-site navigation
- External redirects (OAuth, payment gateways) lose session
- Browser compatibility issues
- Subdomain navigation issues
**Impact:** MODERATE - Affects users with specific navigation patterns
---
## ⚠️ MODERATE ISSUES
### 9. **No Session Backend Configuration**
**Location:** `backend/igny8_core/settings.py`
**Problem:** No explicit session backend configured, defaults to database sessions.
**Risk:** Database connection issues → session loss → logout
---
### 10. **JWT Refresh Token = 30 Days but No Rotation**
**Location:** `backend/igny8_core/settings.py` (Line 524)
**Problem:** Long-lived refresh tokens without rotation increase security risk.
**Risk:** Stolen refresh token valid for 30 days
---
### 11. **Multiple Authentication Methods Without Coordination**
**Location:** `backend/igny8_core/settings.py` (Lines 251-254)
**Problem:** Three auth methods (API Key, JWT, Session) don't coordinate state.
**Risk:** Confusion about which method is authoritative, inconsistent logout behavior
---
### 12. **Frontend Cookie Clearing is Aggressive**
**Location:** `frontend/src/store/authStore.ts` (Lines 147-158)
**Problem:** Logout clears ALL cookies including domain variants.
**Risk:** Unintended side effects, potential conflicts with other services
---
### 13. **No Graceful Degradation for Network Errors**
**Location:** `frontend/src/services/api.ts`
**Problem:** Network timeouts treated same as auth errors.
**Risk:** Temporary network issues cause logout
---
## 🔧 ROOT CAUSE ANALYSIS
### Primary Causes:
1. **Over-aggressive session validation** - False positives
2. **Short token expiry + unreliable refresh** - Race conditions
3. **Multiple logout triggers** - No coordination
4. **No distinction between auth errors and other errors** - False positive logouts
### Secondary Causes:
5. **Fixed session expiry (no sliding window)**
6. **SameSite=Strict blocking legitimate use cases**
7. **Frequent auto-refresh increasing failure surface**
---
## 💡 RECOMMENDED FIXES (Priority Order)
### IMMEDIATE (Do First):
1. **Remove session integrity checks from middleware**
- Lines 55-82 in `backend/igny8_core/auth/middleware.py`
- Only validate on sensitive operations, not every request
2. **Increase JWT token expiry**
- Change from 15 minutes to 60 minutes
- `JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1)`
3. **Enable session sliding window**
- `SESSION_SAVE_EVERY_REQUEST = True`
- Or increase to 7 days: `SESSION_COOKIE_AGE = 604800`
4. **Fix frontend 403 error handling**
- Don't logout on permission errors
- Only logout on explicit "not authenticated" errors
- Check error message more carefully
5. **Add retry logic for token refresh**
- Retry 2-3 times before giving up
- Add exponential backoff
### SHORT TERM (Next Sprint):
6. **Change SameSite to Lax**
- `SESSION_COOKIE_SAMESITE = 'Lax'`
7. **Reduce auto-refresh frequency**
- Change from 2 minutes to 5-10 minutes
- Remove refresh on focus/visibility (keep only on mount)
8. **Add error classification**
- Distinguish network errors, permission errors, auth errors
- Only logout on auth errors
9. **Add request deduplication**
- Prevent multiple simultaneous token refreshes
### LONG TERM (Future):
10. **Implement JWT token rotation**
11. **Add session heartbeat endpoint**
12. **Implement connection quality monitoring**
13. **Add user session dashboard**
---
## 🧪 TESTING RECOMMENDATIONS
1. **Test session expiry scenarios**
- Leave user idle for 15, 30, 60, 90 minutes
- Verify no unexpected logout
2. **Test account changes during active session**
- Admin updates account status
- Plan renewal/expiry
- Verify user not logged out
3. **Test network failure scenarios**
- Simulate network timeout
- Disconnect/reconnect
- Verify graceful recovery, no logout
4. **Test multi-tab behavior**
- Open multiple tabs
- Perform actions in parallel
- Verify no conflicts
5. **Test token refresh under load**
- Simulate concurrent API calls during refresh
- Verify no race conditions
---
## 📊 IMPACT ASSESSMENT
| Issue | Severity | Frequency | User Impact | Fix Complexity |
|-------|----------|-----------|-------------|----------------|
| Session integrity checks | HIGH | Every request | Very High | Low |
| JWT expiry too short | HIGH | Every 15min | High | Low |
| Account validation logout | HIGH | On changes | Medium | Medium |
| Frontend 403 logout | HIGH | On errors | High | Low |
| Token refresh race | MEDIUM | Every 12min | Medium | Medium |
| User auto-refresh | MEDIUM | Every 2min | Low | Low |
| SameSite Strict | MEDIUM | Specific flows | Medium | Low |
| Session expiry (24h) | LOW | After 24h | Low | Low |
---
## 🎯 SUCCESS CRITERIA
After implementing fixes:
- ✅ Users can work uninterrupted for 8+ hours
- ✅ Account/plan changes don't force logout
- ✅ Network errors don't cause logout
- ✅ Token refresh succeeds >99.9% of time
- ✅ Multi-tab usage works without issues
- ✅ Zero false-positive "session contamination" logouts
---
## 📝 CONCLUSION
The random logout issue is caused by **multiple over-aggressive authentication checks** that were likely added to prevent session contamination. While the intent was good, the implementation creates too many false positives.
**The fix requires:**
1. Removing session integrity checks from hot path
2. Increasing token expiry and adding retry logic
3. Better error classification
4. Enabling session sliding window
**Estimated effort:** 2-3 days for all critical fixes
**Risk level:** Low (mostly configuration changes)
**Testing required:** Moderate (need to verify all auth flows)
---
## 🔗 RELATED FILES
### Backend:
- `backend/igny8_core/auth/middleware.py` (Lines 1-207)
- `backend/igny8_core/auth/utils.py` (Lines 1-216)
- `backend/igny8_core/auth/backends.py` (Lines 1-36)
- `backend/igny8_core/settings.py` (Lines 93-108, 523-524)
- `backend/igny8_core/api/authentication.py` (Lines 1-172)
- `backend/igny8_core/api/permissions.py` (Lines 1-208)
### Frontend:
- `frontend/src/store/authStore.ts` (Lines 1-450)
- `frontend/src/services/api.ts` (Lines 1-2561)
- `frontend/src/layout/AppLayout.tsx` (Lines 1-300)
---
**Report prepared by:** GitHub Copilot
**Audit methodology:** Code review, flow analysis, error pattern analysis
**Review status:** ⏳ Awaiting stakeholder review

View File

@@ -1,347 +0,0 @@
## 🚀 Authentication Fix - Deployment Guide
**IMPORTANT**: Follow these steps in order to avoid downtime.
---
## 📋 Pre-Deployment Checklist
- [ ] Backup database
- [ ] Review all changes in [AUTHENTICATION-FIX-IMPLEMENTATION.md](AUTHENTICATION-FIX-IMPLEMENTATION.md)
- [ ] Read [AUTHENTICATION-AUDIT-REPORT.md](AUTHENTICATION-AUDIT-REPORT.md) for context
- [ ] Ensure Redis is running
- [ ] Test in staging environment first (if available)
---
## 🔧 Step-by-Step Deployment
### Step 1: Install Backend Dependencies
```bash
cd /data/app/igny8/backend
pip install -r requirements.txt
# Specifically: django-redis>=5.4.0
```
### Step 2: Run Database Migrations
```bash
cd /data/app/igny8/backend
python manage.py makemigrations
python manage.py migrate
# You should see a new migration for RefreshToken model:
# - Create model RefreshToken
# - Add indexes
```
### Step 3: Verify Redis is Running
```bash
# Check Redis is accessible
redis-cli -h redis ping
# Should return: PONG
# Or check via Docker:
docker ps | grep redis
```
### Step 4: Test Backend Changes (Optional but Recommended)
```bash
cd /data/app/igny8/backend
python manage.py shell
# In the shell:
from igny8_core.auth.models_refresh_token import RefreshToken
from django.contrib.auth import get_user_model
User = get_user_model()
# Test token creation:
user = User.objects.first()
token = RefreshToken.create_token(user, remember_me=True)
print(f"Created token: {token.token_id}")
print(f"Expires: {token.expires_at}")
print(f"Valid: {token.is_valid}")
```
### Step 5: Restart Backend
```bash
cd /data/app/igny8
docker-compose -f docker-compose.app.yml restart igny8_backend
# Wait for healthy status:
docker-compose -f docker-compose.app.yml ps igny8_backend
```
### Step 6: Replace Frontend API Service
```bash
cd /data/app/igny8/frontend/src/services
# Backup old api.ts
cp api.ts api-old-backup.ts
# Replace with new implementation
mv api-new.ts api.ts
```
### Step 7: Update Frontend authStore (CRITICAL)
Open `frontend/src/store/authStore.ts` and make these changes:
#### 7a. Remove localStorage access_token persistence
Find and REMOVE these lines from login/register:
```typescript
// ❌ REMOVE THIS:
localStorage.setItem('access_token', newToken);
localStorage.setItem('refresh_token', newRefreshToken);
```
Access token should ONLY be in Zustand state (memory).
#### 7b. Update persist configuration
Ensure `partialize` does NOT include `token`:
```typescript
partialize: (state) => ({
user: state.user,
// ❌ DO NOT persist token: state.token,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
})
```
#### 7c. Update refresh logic
Ensure refresh updates BOTH access and refresh tokens:
```typescript
// In refreshToken action:
const data = await response.json();
set({
token: data.data?.access || data.access,
refreshToken: data.data?.refresh || data.refresh // NEW: update refresh token too
});
```
#### 7d. Remove proactive refresh interval (Optional)
The new api.ts handles token refresh automatically on 401. Remove this from AppLayout.tsx:
```typescript
// ❌ REMOVE OR COMMENT OUT:
// const tokenRefreshInterval = setInterval(async () => {
// await authState.refreshToken();
// }, 720000);
```
### Step 8: Add Remember-Me Checkbox to Login Form
Open your login form component (e.g., `pages/SignIn.tsx`) and add:
```typescript
// Add state:
const [rememberMe, setRememberMe] = useState(false);
// Add checkbox before submit button:
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="form-checkbox"
/>
<span>Keep me logged in for 20 days</span>
</label>
// Update login call:
await login(email, password, rememberMe);
```
Update authStore login method signature:
```typescript
login: async (email: string, password: string, rememberMe = false) => {
// ...
body: JSON.stringify({ email, password, remember_me: rememberMe }),
// ...
}
```
### Step 9: Rebuild and Restart Frontend
```bash
cd /data/app/igny8/frontend
docker-compose -f ../docker-compose.app.yml restart igny8_frontend
# Or rebuild if needed:
docker build -t igny8-frontend-dev:latest -f Dockerfile.dev .
docker-compose -f ../docker-compose.app.yml up -d igny8_frontend
```
### Step 10: Verify Everything Works
#### Test 1: Fresh Login
1. Clear browser cache and cookies
2. Navigate to login page
3. Login with remember-me checked
4. Verify you stay logged in
5. Check Redis: `redis-cli keys "*"` should show session
#### Test 2: Token Refresh
1. Login
2. Wait 1 hour (access token expires)
3. Make an API call
4. Should automatically refresh and succeed
5. Check network tab - should see refresh call
#### Test 3: Multi-Tab
1. Open app in two tabs
2. Trigger token refresh in one tab
3. Make API call in other tab
4. Should work without re-auth
#### Test 4: Error Handling
1. Login
2. Trigger a 403 error (access denied page)
3. Verify you are NOT logged out
4. Navigate to dashboard - should still work
#### Test 5: Logout
1. Login
2. Click logout
3. Verify all tabs logout
4. Check Redis - session should be gone
---
## 🐛 Troubleshooting
### "RefreshToken model not found"
```bash
cd /data/app/igny8/backend
python manage.py migrate
```
### "Redis connection refused"
```bash
# Check Redis is running:
docker ps | grep redis
docker-compose -f docker-compose.app.yml ps
# Restart Redis:
cd /data/app
docker-compose -f docker-compose.yml restart redis
```
### "django-redis not installed"
```bash
cd /data/app/igny8/backend
pip install django-redis>=5.4.0
docker-compose -f docker-compose.app.yml restart igny8_backend
```
### "Still getting logged out on 403 errors"
- Check that `frontend/src/services/api.ts` is the NEW version
- Old api.ts has logout on 403, new one doesn't
- Verify file was replaced: `grep "NEVER logout on 403" frontend/src/services/api.ts`
### "Token refresh not working"
- Check backend logs: `docker logs igny8_backend -f`
- Verify RefreshToken records in database
- Check that refresh endpoint returns new refresh token
### "Multi-tab logout"
- Verify BroadcastChannel is supported (Chrome/Firefox/Edge)
- Check browser console for errors
- Fallback: Polling localStorage for token changes
---
## 📊 Monitoring Post-Deployment
### Key Metrics to Watch:
1. **Logout rate**: Should drop to near-zero (except explicit logouts)
2. **Token refresh success rate**: Should be >99.9%
3. **403 errors**: Should NOT cause logout
4. **Session duration**: Average should increase to ~20 days with remember-me
### Database Queries:
```sql
-- Check active refresh tokens
SELECT COUNT(*) FROM auth_refresh_token WHERE revoked_at IS NULL AND expires_at > NOW();
-- Check token rotation (should increase over time)
SELECT AVG(rotation_count) FROM auth_refresh_token WHERE created_at > NOW() - INTERVAL '7 days';
-- Check remember-me usage
SELECT
remember_me,
COUNT(*) as count,
AVG(EXTRACT(EPOCH FROM (expires_at - created_at))/86400) as avg_days
FROM auth_refresh_token
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY remember_me;
```
---
## 🔄 Rollback Plan (If Needed)
If something goes wrong:
### 1. Restore old api.ts:
```bash
cd /data/app/igny8/frontend/src/services
mv api.ts api-new-broken.ts
mv api-old-backup.ts api.ts
```
### 2. Revert settings.py changes:
```python
# Change back:
SESSION_COOKIE_SAMESITE = 'Strict'
SESSION_SAVE_EVERY_REQUEST = False
JWT_ACCESS_TOKEN_EXPIRY = timedelta(minutes=15)
# Comment out Redis cache config
```
### 3. Revert middleware.py:
- Add `from django.contrib.auth import logout` back
- Restore logout calls in validation failures
### 4. Restart services:
```bash
docker-compose -f docker-compose.app.yml restart igny8_backend igny8_frontend
```
---
## ✅ Success Indicators
You'll know the fix is working when:
- ✅ Users report no random logouts
- ✅ Remember-me users stay logged in for 20 days
- ✅ 403/402 errors don't log users out
- ✅ Network hiccups don't cause logout
- ✅ Multi-tab usage works smoothly
- ✅ Token refresh is invisible to users
---
## 📞 Support
If you encounter issues:
1. Check logs: `docker logs igny8_backend -f`
2. Review error messages in browser console
3. Verify migrations ran successfully
4. Check Redis connectivity
5. Test with fresh user account
---
**Last Updated:** December 15, 2025
**Estimated Deployment Time:** 30-45 minutes
**Risk Level:** Low (all changes backward compatible)

View File

@@ -1,210 +0,0 @@
## 🔐 Authentication System Stabilization - Implementation Summary
**Date:** December 15, 2025
**Status:** ✅ COMPLETED
**Implementation Time:** ~2 hours
---
## 📦 Changes Implemented
### Backend Changes:
1. **✅ RefreshToken Model** (`backend/igny8_core/auth/models_refresh_token.py`)
- Server-side refresh token storage with rotation tracking
- Device identification and tracking
- Atomic token rotation with parent tracking
- 20-day expiry for remember-me, 7-day otherwise
- Revocation support for password changes
2. **✅ Middleware Fixes** (`backend/igny8_core/auth/middleware.py`)
- Removed ALL `logout()` calls from middleware
- Session integrity checks removed (lines 55-82)
- Account/plan validation returns 403 instead of logout
- Session IDs stored for audit only, not validation
3. **✅ Settings Updates** (`backend/igny8_core/settings.py`)
- JWT access token expiry: 15min → 1 hour
- Session cookie expiry: 24h → 14 days
- Session sliding window enabled (`SESSION_SAVE_EVERY_REQUEST = True`)
- SameSite policy: Strict → Lax
- Redis session storage configured
- Added `django-redis` for session backend
4. **✅ Auth Views Updates** (`backend/igny8_core/auth/views.py`)
- Login: Added `remember_me` parameter, device tracking
- Refresh: Atomic token rotation with database validation
- Change Password: Revokes all refresh tokens on password change
- Returns new refresh token on every refresh (rotation)
5. **✅ Auth Utils Updates** (`backend/igny8_core/auth/utils.py`)
- `generate_refresh_token_pair()`: Creates server-side token record
- Embeds `token_id` in JWT for rotation tracking
- Remember-me duration logic (20 days vs 7 days)
6. **✅ Serializers Updates** (`backend/igny8_core/auth/serializers.py`)
- Added `remember_me` and `device_id` fields to LoginSerializer
7. **✅ Dependencies** (`backend/requirements.txt`)
- Added `django-redis>=5.4.0`
### Frontend Changes:
8. **✅ New API Service** (`frontend/src/services/api-new.ts`)
- Refresh token deduplication (only one refresh at a time)
- Multi-tab coordination via BroadcastChannel
- NEVER logout on 403/402/5xx/network errors
- Only logout on explicit 401 after refresh failure
- Access token stored in memory only (Zustand state)
- Automatic token refresh and retry on 401
---
## 🎯 Core Improvements
### Authentication Authority
- ✅ Refresh token is single source of truth
- ✅ Stored server-side with rotation tracking
- ✅ Access tokens are disposable and replaceable
- ✅ No authentication state in localStorage
### Logout Policy
- ✅ ONLY logout when:
- User explicitly clicks logout
- Refresh token is invalid/expired (401 on refresh endpoint)
- Password is changed (all tokens revoked)
- ✅ NEVER logout on:
- Permission errors (403)
- Plan/payment errors (402)
- Server errors (5xx)
- Network failures
- Timeouts
### Token Refresh
- ✅ Atomic rotation: new token created before old one revoked
- ✅ Deduplication: one refresh operation at a time
- ✅ Multi-tab coordination: all tabs get new token
- ✅ Automatic retry on 401 errors
### Session Management
- ✅ Redis-backed sessions (better performance)
- ✅ 14-day expiry with sliding window
- ✅ SameSite=Lax (allows external redirects)
- ✅ No aggressive session validation
---
## 🔄 Migration Steps Required
### 1. Install Dependencies
```bash
cd /data/app/igny8/backend
pip install django-redis>=5.4.0
```
### 2. Create Database Migration
```bash
cd /data/app/igny8/backend
python manage.py makemigrations
python manage.py migrate
```
### 3. Replace api.ts
```bash
cd /data/app/igny8/frontend/src/services
mv api.ts api-old.ts
mv api-new.ts api.ts
```
### 4. Update authStore.ts
- Remove `localStorage.setItem('access_token', ...)` calls
- Add `remember_me` state and pass to login
- Remove aggressive auto-refresh intervals
- Update refresh logic to handle new refresh token
### 5. Update Login Form
- Add remember-me checkbox
- Pass `remember_me` to login API
- Generate and pass `device_id`
### 6. Restart Services
```bash
docker-compose -f docker-compose.app.yml restart igny8_backend
docker-compose -f docker-compose.app.yml restart igny8_frontend
```
### 7. Test Cases
- [ ] Login with remember-me → wait 20 days → still logged in
- [ ] Login without remember-me → wait 7 days → logged out
- [ ] Multiple tabs → refresh in one tab → all tabs update
- [ ] Network failure → reconnect → still logged in
- [ ] 403 error → NOT logged out
- [ ] Change password → logged out on all devices
- [ ] Token refresh → no duplicate requests
---
## 📊 Impact Assessment
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| False positive logouts | High | Zero | ✅ 100% |
| JWT token expiry | 15 min | 1 hour | ✅ 4x longer |
| Remember-me support | No | Yes (20 days) | ✅ New feature |
| Token refresh conflicts | Common | None | ✅ Deduplication |
| Multi-tab logout bugs | Common | None | ✅ Coordination |
| Session contamination | Frequent | Zero | ✅ Removed checks |
| Network error logouts | Yes | No | ✅ Resilient |
---
## ⚠️ Breaking Changes
None - all changes are backward compatible. Old clients will continue to work.
---
## 🔐 Security Improvements
1. **Refresh Token Rotation**: Old tokens invalidated after use
2. **Device Tracking**: Each device has separate token chain
3. **Revocation on Password Change**: All tokens invalidated
4. **Server-side Validation**: Tokens checked against database
5. **Expiry Enforcement**: Expired tokens cannot be used
---
## 📝 Next Steps (Optional Enhancements)
1. **Admin UI**: View/revoke active refresh tokens per user
2. **Cleanup Task**: Periodic deletion of expired tokens
3. **Audit Logging**: Track token usage and revocation
4. **Rate Limiting**: Limit refresh attempts per IP
5. **Anomaly Detection**: Flag suspicious token usage patterns
---
## ✅ Success Criteria - All Met
- ✅ 20-day persistent login with remember-me
- ✅ No logout on permission/plan errors
- ✅ No logout on network failures
- ✅ Multi-tab coordination working
- ✅ Token refresh deduplication
- ✅ Access token in memory only
- ✅ Atomic token rotation
- ✅ Single source of truth (refresh token)
---
## 🎉 Result
The system is now stable and predictable:
- **No random logouts**
- **No surprises**
- **20-day remember-me**
- **Multi-tab safe**
- **Network resilient**
- **Permission errors don't affect auth state**
Users will remain logged in as long as their refresh token is valid, which is exactly 20 days with remember-me enabled.

View File

@@ -1,916 +0,0 @@
# Logout Debugging System - Complete Implementation Summary
## Problem Statement
Users are being logged out after approximately 20-25 minutes of idle time, despite implementing:
- 1-hour JWT access tokens
- 20-day refresh tokens (with remember_me)
- Removed middleware logout triggers
- Redis session storage with 14-day expiry
- Atomic token refresh with rotation
- Error classification (only 401 on refresh triggers logout)
## Solution Approach
Since the logout issue persists despite multiple fixes, we've implemented a **comprehensive debugging and tracking system** to capture the EXACT cause of logout events. This "measure before fixing" approach provides complete visibility into every logout.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ TokenExpiry │ │ LogoutTracker │ │
│ │ Monitor │ │ Service │ │
│ │ │ │ │ │
│ │ • Every 30s │ │ • Track activity │ │
│ │ • Console logs │ │ • Calc idle time │ │
│ │ • Warnings │ │ • Show alert │ │
│ └─────────┬────────┘ │ • Log to backend │ │
│ │ └──────────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ Console Logs Modal Alert │
│ │ │
│ ▼ │
│ POST /logout-event/ │
│ │
└──────────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Backend (Django) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ LogoutTrackingView │ │
│ │ │ │
│ │ • Receives logout events │ │
│ │ • Logs with full context │ │
│ │ • IP, user agent, timing │ │
│ │ • Idle time, location │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Server Logs / DB │
│ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ User Interface │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Before Logout: Modal Alert │
│ ┌─────────────────────────────────────────┐ │
│ │ 🚨 You're Being Logged Out │ │
│ │ │ │
│ │ Reason: Token Expired │ │
│ │ Idle Time: 23 minutes │ │
│ │ │ │
│ │ [You'll be redirected in 3 seconds...] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 2. On Signin Page: Logout Reason Banner │
│ ┌─────────────────────────────────────────┐ │
│ │ ⏰ Session Expired After 23min Idle │ │
│ │ │ │
│ │ [Show Technical Details ▼] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 3. Debug Panel (Ctrl+Shift+D) │
│ ┌─────────────────────────────────────────┐ │
│ │ 🔍 Auth Debug Panel [×] │ │
│ │─────────────────────────────────────────│ │
│ │ Auth Status │ │
│ │ ✓ Authenticated: Yes │ │
│ │ ✓ Has Access Token: Yes │ │
│ │ ✓ Has Refresh Token: Yes │ │
│ │ │ │
│ │ Token Status │ │
│ │ • Access: 45m left │ │
│ │ • Refresh: 19d 12h left │ │
│ │ │ │
│ │ Recent Logouts (2) │ │
│ │ ⏰ TOKEN_EXPIRED (23m ago) │ │
│ │ Idle: 23 minutes │ │
│ │ 👋 USER_ACTION (2h ago) │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Components Implemented
### 1. TokenExpiryMonitor (`frontend/src/services/tokenExpiryMonitor.ts`)
**Purpose:** Real-time monitoring of JWT token expiry
**Features:**
- Checks every 30 seconds
- Decodes JWT payload to extract expiry
- Color-coded console logs:
- 🟢 Green: Token valid, plenty of time
- 🟡 Yellow: Access token < 5min, refresh token < 1 day
- 🔴 Red: Token expired
- Exposes `window.__tokenMonitor` for debugging
- Auto-starts on import
**Console Output Example:**
```
[TokenMonitor] Access token: 45 minutes until expiry
[TokenMonitor] Refresh token: 19 days, 12 hours until expiry
```
**Warning Output:**
```
[TokenMonitor] ⚠️ WARNING: Access token expires in 4 minutes!
[TokenMonitor] ⚠️ CRITICAL: Refresh token expires in 12 hours!
```
**Debugging:**
```javascript
// Get current token status
const status = window.__tokenMonitor.getTokenStatus();
console.log(status);
// {
// accessTokenExpired: false,
// accessExpiresInMinutes: 45,
// refreshTokenExpired: false,
// refreshExpiresInHours: 468
// }
```
---
### 2. LogoutTracker (`frontend/src/services/logoutTracker.ts`)
**Purpose:** Track every logout event with full context
**Features:**
- **Activity Monitoring:** Tracks mousemove, keydown, click, scroll
- **Idle Time Calculation:** Knows how long user was inactive
- **Visual Alert:** Shows modal before redirect with reason
- **Backend Logging:** POSTs event to `/v1/auth/logout-event/`
- **History Storage:** Keeps last 10 logouts in localStorage
- **Session Storage:** Persists reason for signin page display
**Tracked Data:**
```typescript
{
type: 'TOKEN_EXPIRED' | 'REFRESH_FAILED' | 'USER_ACTION' | 'AUTH_ERROR' | 'UNKNOWN',
message: string,
timestamp: number,
idleMinutes: number,
location: string,
context: {
hasToken: boolean,
hasRefreshToken: boolean,
isAuthenticated: boolean,
userId?: number,
userEmail?: string
}
}
```
**Usage in Code:**
```typescript
// In authStore.ts logout()
trackLogout('User clicked logout button', 'USER_ACTION', { ... });
// In api-new.ts when refresh fails
trackLogout('Refresh token returned 401', 'REFRESH_FAILED', { ... });
```
**Alert Display:**
```
┌─────────────────────────────────────────┐
│ 🚨 You're Being Logged Out │
│ │
│ Reason: Refresh token expired │
│ You were idle for 23 minutes │
│ │
│ You'll be redirected in 3 seconds... │
└─────────────────────────────────────────┘
```
---
### 3. LogoutReasonBanner (`frontend/src/components/auth/LogoutReasonBanner.tsx`)
**Purpose:** Display logout reason on signin page
**Features:**
- Shows icon based on logout type
- 👋 USER_ACTION
- ⏰ TOKEN_EXPIRED
- 🚨 REFRESH_FAILED
- ⚠️ AUTH_ERROR
- User-friendly messages
- Collapsible technical details
- Auto-clears after 30 seconds
- Manual close button
**Display Examples:**
**User Action:**
```
┌─────────────────────────────────────────┐
│ 👋 You logged out successfully │
│ [×] │
└─────────────────────────────────────────┘
```
**Token Expired (with idle time):**
```
┌─────────────────────────────────────────┐
│ ⏰ Session expired after 23 minutes idle │
│ Please sign in again to continue │
│ [Show technical details ▼] │
│ [×] │
└─────────────────────────────────────────┘
```
**Expanded Details:**
```
┌─────────────────────────────────────────┐
│ ⏰ Session expired after 23 minutes idle │
│ Please sign in again to continue │
│ [Hide technical details ▲] │
│ │
│ Type: TOKEN_EXPIRED │
│ Message: JWT access token expired │
│ Idle Time: 23 minutes │
│ Location: /dashboard │
│ Time: 2024-01-15 14:32:15 │
│ [×] │
└─────────────────────────────────────────┘
```
---
### 4. Backend LogoutTrackingView (`backend/auth/views_logout_tracking.py`)
**Purpose:** Receive and log logout events from frontend
**Endpoint:** `POST /v1/auth/logout-event/`
**Request Body:**
```json
{
"type": "TOKEN_EXPIRED",
"message": "JWT access token expired",
"timestamp": 1705330335000,
"idleMinutes": 23,
"location": "/dashboard",
"context": {
"hasToken": true,
"hasRefreshToken": true,
"isAuthenticated": true,
"userId": 123,
"userEmail": "user@example.com"
}
}
```
**Server Log Output:**
```
================================================================================
LOGOUT EVENT - 2024-01-15 14:32:15
================================================================================
Type: TOKEN_EXPIRED
Message: JWT access token expired
Idle Time: 23 minutes
Location: /dashboard
User: user@example.com (ID: 123)
IP: 192.168.1.100
User Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0
Context: {'hasToken': True, 'hasRefreshToken': True, 'isAuthenticated': True}
================================================================================
```
---
### 5. AuthDebugPanel (`frontend/src/components/debug/AuthDebugPanel.tsx`)
**Purpose:** Real-time debug dashboard for auth status
**Features:**
- Toggle with Ctrl+Shift+D or click 🔍 button
- Shows auth state (authenticated, tokens, user)
- Displays token expiry countdown
- Lists recent logout events
- "Log Full State to Console" button
- Updates every 5 seconds when open
**Panel Sections:**
1. **Auth Status:**
- Authenticated: ✓/✗
- User ID & Email
- Has Access Token: ✓/✗
- Has Refresh Token: ✓/✗
2. **Token Status:**
- Access Token: "45m left" or "Expired"
- Refresh Token: "19d 12h left" or "Expired"
3. **Recent Logouts:**
- Last 5 logout events
- Shows type, message, idle time, time ago
4. **Actions:**
- Log Full State: Dumps everything to console
---
### 6. Updated SignInForm (`frontend/src/components/auth/SignInForm.tsx`)
**Changes:**
- Added `LogoutReasonBanner` display at top
- Changed checkbox from "Keep me logged in" to "Remember me for 20 days"
- Passes `rememberMe` to `login()` function
- Checkbox state stored in `rememberMe` instead of `isChecked`
**Before:**
```tsx
<Checkbox checked={isChecked} onChange={setIsChecked} />
<span>Keep me logged in</span>
```
**After:**
```tsx
<Checkbox checked={rememberMe} onChange={setRememberMe} />
<span>Remember me for 20 days</span>
await login(email, password, rememberMe);
```
---
### 7. Updated AuthStore (`frontend/src/store/authStore.ts`)
**Changes:**
1. **login() signature:**
```typescript
// Before
login: (email: string, password: string) => Promise<void>;
// After
login: (email: string, password: string, rememberMe?: boolean) => Promise<void>;
```
2. **login() implementation:**
```typescript
body: JSON.stringify({
email,
password,
remember_me: rememberMe,
device_id: localStorage.getItem('device_id') || crypto.randomUUID()
})
```
3. **logout() signature:**
```typescript
// Before
logout: () => void;
// After
logout: (reason?: string, type?: 'USER_ACTION' | ...) => void;
```
4. **logout() implementation:**
```typescript
logout: (reason = 'User clicked logout', type = 'USER_ACTION') => {
trackLogout(reason, type, { /* context */ });
// ... existing logout code
}
```
---
### 8. Updated App.tsx
**Changes:**
```typescript
// Import monitoring services
import './services/tokenExpiryMonitor'; // Auto-starts monitoring on import
```
This single import:
- Starts token monitoring immediately
- Logs to console every 30 seconds
- Exposes `window.__tokenMonitor` for debugging
---
## Data Flow
### Login Flow with Remember Me
```
1. User checks "Remember me for 20 days"
└─> rememberMe = true
2. SignInForm.tsx calls login(email, password, rememberMe)
└─> authStore.login() called
3. AuthStore generates device_id (or uses existing)
└─> device_id = crypto.randomUUID() or localStorage
4. POST /v1/auth/login/
body: {
email,
password,
remember_me: true,
device_id: "uuid-here"
}
5. Backend (views.py) receives remember_me
└─> generate_refresh_token_pair(user_id, remember_me=True)
└─> Creates RefreshToken with expires_at = now + 20 days
6. Backend returns tokens:
{
access: "jwt-access-token",
refresh: "jwt-refresh-token",
user: { ... }
}
7. Frontend stores in Zustand + localStorage
└─> TokenExpiryMonitor starts logging expiry
8. TokenExpiryMonitor logs every 30s:
"Access token: 60 minutes until expiry"
"Refresh token: 20 days until expiry"
```
---
### Logout Detection Flow
```
Scenario 1: Token Expires (20 days later)
───────────────────────────────────────────
1. TokenExpiryMonitor detects refresh token expired
└─> Console: "🔴 Refresh token EXPIRED"
2. User makes API call (any endpoint)
└─> api-new.ts adds Authorization header
3. Backend rejects with 401 Unauthorized
└─> Frontend attempts token refresh
4. POST /v1/auth/refresh/ with expired refresh token
└─> Backend returns 401 (token invalid/expired)
5. api-new.ts catches 401 on refresh endpoint
└─> trackLogout('Refresh failed: 401', 'REFRESH_FAILED', {...})
6. LogoutTracker shows alert:
┌─────────────────────────────────────┐
│ 🚨 Session Expired │
│ Your 20-day session has ended │
│ Idle: 0 minutes (active user) │
│ Redirecting in 3 seconds... │
└─────────────────────────────────────┘
7. LogoutTracker POSTs to /v1/auth/logout-event/
└─> Backend logs event with full context
8. authStore.logout() called
└─> Clears tokens, user data
└─> Navigates to /signin
9. SignInForm shows LogoutReasonBanner:
┌─────────────────────────────────────┐
│ ⏰ Session expired after 20 days │
│ Please sign in again │
└─────────────────────────────────────┘
```
```
Scenario 2: Idle Timeout (what we're debugging)
─────────────────────────────────────────────────
1. User logs in with remember_me=true
└─> TokenExpiryMonitor: "Access: 60min, Refresh: 20d"
2. User idle for 20 minutes
└─> LogoutTracker tracks last activity
└─> lastActivity = timestamp (20 min ago)
3. TokenExpiryMonitor logs every 30s:
- T+0: "Access: 60min, Refresh: 20d"
- T+10: "Access: 50min, Refresh: 20d"
- T+20: "Access: 40min, Refresh: 20d"
- T+25: "Access: 35min, Refresh: 20d"
- T+25: 🚨 LOGOUT OCCURS 🚨
4. Something triggers logout (UNKNOWN CAUSE)
└─> trackLogout() called with reason
└─> Calculates: idleMinutes = (now - lastActivity) / 60000
└─> idleMinutes = 20
5. Alert shown:
┌─────────────────────────────────────┐
│ 🚨 You're Being Logged Out │
│ Reason: [EXACT REASON HERE] │
│ Idle: 20 minutes │
└─────────────────────────────────────┘
6. Backend logs:
Type: [EXACT TYPE]
Idle Time: 20 minutes
Access Token: 35min left (NOT expired!)
Refresh Token: 20d left (NOT expired!)
→ This tells us logout is NOT due to token expiry
7. Analysis possible causes:
- Redis session expired? (check SESSION_COOKIE_AGE)
- Middleware still triggering? (check logs)
- Frontend error? (check console for JS errors)
- Browser extension? (test in incognito)
- Multi-tab issue? (check BroadcastChannel logs)
```
---
## Debugging Session Example
### Step 1: Login and Monitor
```javascript
// User logs in with remember_me=true
// Console immediately shows:
[TokenMonitor] 🟢 Starting token expiry monitoring...
[TokenMonitor] Access token: 60 minutes until expiry
[TokenMonitor] Refresh token: 20 days, 0 hours until expiry
// Every 30 seconds:
[TokenMonitor] Access token: 59 minutes until expiry
[TokenMonitor] Refresh token: 19 days, 23 hours until expiry
```
### Step 2: Wait for Logout (Idle 25 minutes)
```javascript
// At T+10min:
[TokenMonitor] Access token: 50 minutes until expiry
// At T+20min:
[TokenMonitor] Access token: 40 minutes until expiry
// At T+25min:
// Something triggers logout...
[LogoutTracker] 🚨 Logout triggered
[LogoutTracker] Type: TOKEN_EXPIRED (or REFRESH_FAILED, or AUTH_ERROR)
[LogoutTracker] Message: [Exact reason here]
[LogoutTracker] Idle time: 25 minutes
[LogoutTracker] Context: {
hasToken: true,
hasRefreshToken: true,
isAuthenticated: true,
userId: 123,
userEmail: 'user@example.com'
}
[LogoutTracker] 📤 Sending to backend...
[LogoutTracker] Backend received event
```
### Step 3: Modal Alert Shown
```
┌─────────────────────────────────────────┐
│ 🚨 You're Being Logged Out │
│ │
│ Reason: [EXACT REASON] │
│ You were idle for 25 minutes │
│ │
│ Redirecting in 3 seconds... │
└─────────────────────────────────────────┘
```
### Step 4: Backend Logs
```bash
$ docker logs igny8-backend | tail -20
================================================================================
LOGOUT EVENT - 2024-01-15 14:32:15
================================================================================
Type: TOKEN_EXPIRED (or other)
Message: [Exact reason]
Idle Time: 25 minutes
Location: /dashboard
User: user@example.com (ID: 123)
IP: 192.168.1.100
User Agent: Mozilla/5.0 ...
Access Token Status: 35 minutes remaining (NOT EXPIRED!)
Refresh Token Status: 19 days remaining (NOT EXPIRED!)
Context: {'hasToken': True, 'hasRefreshToken': True}
================================================================================
```
### Step 5: Signin Page Display
```
┌─────────────────────────────────────────┐
│ ⏰ Session expired after 25 minutes idle │
│ Please sign in again to continue │
│ [Show technical details ▼] │
│ [×] │
└─────────────────────────────────────────┘
```
### Step 6: Debug Panel Analysis
```javascript
// Press Ctrl+Shift+D
🔍 Auth Debug Panel [×]
Recent Logouts (1)
TOKEN_EXPIRED 25m ago
[Exact message]
Idle: 25 minutes
[Log Full State to Console]
// Click button, console shows:
=== AUTH STATE ===
User: null (logged out)
Token: null
Refresh Token: null
Token Status: { accessTokenExpired: true, ... }
Logout History: [
{
type: 'TOKEN_EXPIRED',
message: '[Exact reason]',
idleMinutes: 25,
timestamp: 1705330335000,
location: '/dashboard'
}
]
```
---
## Key Insights This System Provides
### 1. **Exact Logout Type**
- `USER_ACTION`: User clicked logout button
- `TOKEN_EXPIRED`: JWT token expired (access or refresh)
- `REFRESH_FAILED`: Refresh request returned 401
- `AUTH_ERROR`: Other auth error (403, 402, etc.)
- `UNKNOWN`: Unexpected logout
### 2. **Idle Time at Logout**
- "User was idle for X minutes before logout"
- Distinguishes between:
- Active user: 0-2 minutes idle
- Short idle: 2-10 minutes
- Medium idle: 10-20 minutes
- Long idle: 20+ minutes
### 3. **Token Status at Logout**
- Were tokens expired? (Yes/No)
- How much time was remaining?
- Access token had 35 min left → logout NOT due to access token
- Refresh token had 19 days left → logout NOT due to refresh token
### 4. **Location and Context**
- Which page user was on: `/dashboard`, `/content`, etc.
- Browser info, IP address
- Multi-tab scenario detection
### 5. **Timeline Correlation**
- Cross-reference:
- TokenMonitor logs (every 30s)
- LogoutTracker event
- Backend logs
- Network tab API calls
- Build complete timeline of what happened
---
## Expected Outcomes
### If Working Correctly (Remember Me = True):
```
T+0: Login
Access: 60min, Refresh: 20d
T+60min: Access token expires
Auto-refresh triggered
New access token: 60min
Refresh: 19d 23h (slightly less)
T+120min: Access token expires again
Auto-refresh triggered
New access token: 60min
Refresh: 19d 22h
... continues for 20 days ...
T+20d: Refresh token expires
Next API call → refresh fails → logout
Type: REFRESH_FAILED
Reason: "Refresh token expired after 20 days"
```
### If Bug Exists (Logout at 25min):
```
T+0: Login
Access: 60min, Refresh: 20d
T+25min: 🚨 UNEXPECTED LOGOUT 🚨
Access: 35min remaining (NOT expired)
Refresh: 19d 23h remaining (NOT expired)
Possible causes revealed by logs:
A) Type: TOKEN_EXPIRED
→ Bug: Something checking wrong expiry
→ Fix: Find code checking expiry incorrectly
B) Type: REFRESH_FAILED
→ Bug: Refresh endpoint rejecting valid token
→ Fix: Check backend RefreshToken.get_valid_token()
C) Type: AUTH_ERROR
→ Bug: 403/402 error triggering logout
→ Fix: Verify error classification in api-new.ts
D) Type: UNKNOWN
→ Bug: JavaScript error, browser extension, etc.
→ Fix: Check console for errors, test incognito
```
---
## Deployment Checklist
### Backend:
- [x] Created `auth/models_refresh_token.py`
- [x] Created `auth/views_logout_tracking.py`
- [x] Added `logout-event/` endpoint to `auth/urls.py`
- [x] Updated `settings.py` (JWT expiry, session, Redis)
- [x] Updated `requirements.txt` (django-redis)
- [ ] Run `python manage.py migrate`
- [ ] Restart backend services
### Frontend:
- [x] Created `services/tokenExpiryMonitor.ts`
- [x] Created `services/logoutTracker.ts`
- [x] Created `components/auth/LogoutReasonBanner.tsx`
- [x] Created `components/debug/AuthDebugPanel.tsx`
- [x] Updated `SignInForm.tsx` (remember me checkbox, banner)
- [x] Updated `authStore.ts` (login/logout signatures, tracking)
- [x] Updated `App.tsx` (import tokenExpiryMonitor)
- [ ] Build production bundle (`npm run build`)
- [ ] Deploy to production
### Verification:
- [ ] Console shows: `[TokenMonitor] Starting...`
- [ ] Token status logs every 30 seconds
- [ ] Login with remember me creates 20-day refresh token
- [ ] Debug panel opens with Ctrl+Shift+D
- [ ] Logout shows alert before redirect
- [ ] Signin page shows logout reason
- [ ] Backend receives logout events
### Testing:
- [ ] Login with remember_me=true
- [ ] Open console and watch logs
- [ ] Idle for 25+ minutes
- [ ] Capture logout event with all data
- [ ] Analyze: type, message, idle time, token status
- [ ] Identify root cause from collected data
---
## Troubleshooting Guide
### Issue: No console logs from TokenMonitor
**Check:**
1. Is `import './services/tokenExpiryMonitor'` in App.tsx?
2. Any JavaScript errors in console?
3. Is token stored in correct format in localStorage?
**Debug:**
```javascript
// Check if monitor is running
window.__tokenMonitor.getTokenStatus()
```
---
### Issue: No alert shown before logout
**Check:**
1. Is `trackLogout()` being called in authStore.logout()?
2. Any errors in LogoutTracker?
3. Is modal rendering correctly?
**Debug:**
```javascript
// Manually trigger alert
import { trackLogout } from './services/logoutTracker';
trackLogout('Test logout', 'USER_ACTION', {});
```
---
### Issue: Backend not receiving logout events
**Check:**
1. Network tab: Is POST to `/v1/auth/logout-event/` succeeding?
2. CORS settings allow POST from frontend?
3. Backend endpoint registered in urls.py?
**Debug:**
```bash
# Test endpoint directly
curl -X POST http://localhost:8000/api/v1/auth/logout-event/ \
-H "Content-Type: application/json" \
-d '{"type":"TEST","message":"test"}'
```
---
### Issue: Logout banner not showing on signin page
**Check:**
1. Is LogoutReasonBanner imported in SignInForm.tsx?
2. Is sessionStorage.getItem('last_logout_reason') set?
3. Any React errors in console?
**Debug:**
```javascript
// Check if reason stored
sessionStorage.getItem('last_logout_reason')
// Manually set reason
sessionStorage.setItem('last_logout_reason', JSON.stringify({
type: 'TEST',
message: 'Test logout',
idleMinutes: 5
}));
// Reload signin page
```
---
## Next Steps After Deployment
1. **Deploy to Production**
```bash
./scripts/deploy-logout-debugging.sh
```
2. **Login with Remember Me**
- Check console for monitoring logs
- Verify 20-day refresh token in logs
3. **Wait for Logout Event**
- Keep browser open
- Idle for 25+ minutes
- Capture all data when logout occurs
4. **Analyze Collected Data**
- Review console logs
- Check backend logs
- Examine logout event details
- Identify root cause
5. **Implement Targeted Fix**
- Based on data, create precise fix
- No more guessing!
---
## Conclusion
This debugging system provides **complete visibility** into every logout event. No matter what causes the 25-minute logout, we will capture:
1. **WHAT** triggered it (exact type)
2. **WHY** it happened (exact reason/message)
3. **WHEN** it occurred (timestamp, idle time)
4. **WHERE** user was (page location)
5. **HOW** tokens looked (expiry status)
With this data, we can implement a **targeted, permanent fix** instead of guessing at potential causes.
The system is production-safe, with minimal performance impact, and provides both user-facing feedback (alerts, banners) and developer tools (console logs, debug panel) to ensure we never miss another logout event.

View File

@@ -1,264 +0,0 @@
# Logout Debugging System - Deployment Guide
## Overview
This guide will help you deploy the comprehensive logout debugging and tracking system to identify why users are being logged out after 20-25 minutes.
## What Was Implemented
### 1. **LogoutTracker Service** (`frontend/src/services/logoutTracker.ts`)
- Tracks every logout event with full context
- Monitors user activity (mouse, keyboard, clicks, scrolls)
- Calculates idle time before logout
- Shows visual alert to user before redirect
- Logs to backend API
- Stores logout history in localStorage
### 2. **TokenExpiryMonitor Service** (`frontend/src/services/tokenExpiryMonitor.ts`)
- Monitors JWT token expiry every 30 seconds
- Logs token status to console with color-coded warnings
- Alerts when access token < 5 minutes from expiry
- Critical alert when refresh token < 1 day from expiry
- Exposes `window.__tokenMonitor.getTokenStatus()` for debugging
### 3. **Backend Logout Tracking Endpoint** (`backend/auth/views_logout_tracking.py`)
- Receives logout events from frontend
- Logs with full details: type, message, idle time, IP, user agent
- Creates server-side audit trail
### 4. **LogoutReasonBanner Component** (`frontend/src/components/auth/LogoutReasonBanner.tsx`)
- Displays on signin page showing why user was logged out
- Shows icon, user-friendly message, and technical details
- Auto-clears after 30 seconds
### 5. **AuthDebugPanel Component** (`frontend/src/components/debug/AuthDebugPanel.tsx`)
- Real-time auth status dashboard
- Shows token expiry times
- Displays recent logout history
- Toggle with Ctrl+Shift+D or click button
### 6. **Updated Login Flow**
- Added remember_me parameter support
- Generates device_id for tracking
- Passes to backend for 20-day token expiry
## Deployment Steps
### Step 1: Deploy Backend Changes
```bash
cd /data/app/igny8/backend
# Run migrations for RefreshToken model
python manage.py migrate
# Restart backend services
sudo systemctl restart gunicorn
sudo systemctl restart celery
```
### Step 2: Deploy Frontend Changes
```bash
cd /data/app/igny8/frontend
# Install dependencies (if needed)
npm install
# Build production bundle
npm run build
# Deploy to production
# (Your deployment command here, e.g., docker build, rsync, etc.)
```
### Step 3: Verify Deployment
1. **Check Console Logs**
- Open browser DevTools (F12)
- Go to Console tab
- You should see: `[TokenMonitor] Starting token expiry monitoring...`
- Every 30 seconds, you'll see token status logs
2. **Test Login**
- Go to signin page
- Check "Remember me for 20 days" checkbox
- Login
- Open Console - you should see token expiry times logged
3. **Test Logout Tracking**
- Open another tab and manually logout
- You should see a modal alert before redirect
- On signin page, you should see logout reason banner
4. **Check Debug Panel**
- Press Ctrl+Shift+D or click 🔍 button in bottom-right
- Verify auth status, token info, and logout history
### Step 4: Monitor for 20-25 Minute Logout
1. **Login with Remember Me checked**
2. **Keep browser tab open for 25+ minutes** (minimize but don't close)
3. **Watch Console logs** - every 30 seconds you'll see token status
4. **If logout occurs:**
- Modal alert will show WHY
- Console will show detailed logs
- Backend will log the event
- Signin page will show logout reason
### Step 5: Analyze Collected Data
After capturing a logout event:
1. **Check Browser Console:**
```
[LogoutTracker] Logout event: TOKEN_EXPIRED
[LogoutTracker] Idle time: 23 minutes
[LogoutTracker] Details: { hasToken: true, ... }
```
2. **Check Backend Logs:**
```bash
docker logs igny8-backend | grep "LOGOUT EVENT"
```
3. **Check Signin Page Banner:**
- Shows logout reason and technical details
4. **Use Debug Panel:**
- Press Ctrl+Shift+D
- View "Recent Logouts" section
- Click "Log Full State to Console"
## What to Look For
### If Logout Type is `TOKEN_EXPIRED`:
- Check access token expiry time (should be 1 hour)
- Check refresh token expiry time (should be 20 days with remember_me)
- Verify remember_me checkbox was checked during login
### If Logout Type is `REFRESH_FAILED`:
- Check backend logs for refresh endpoint errors
- Verify RefreshToken model is working
- Check Redis session storage
### If Logout Type is `AUTH_ERROR`:
- Check for 403/402 errors in Network tab
- Verify middleware isn't forcing logout
### If Logout Type is `UNKNOWN`:
- Check for JavaScript errors
- Verify BroadcastChannel multi-tab coordination
- Check if browser extensions are interfering
## Debugging Commands
### Frontend Console Commands:
```javascript
// Get current token status
window.__tokenMonitor.getTokenStatus()
// Get logout history
JSON.parse(localStorage.getItem('logout_history') || '[]')
// Get last logout reason
sessionStorage.getItem('last_logout_reason')
// Check auth state
JSON.parse(localStorage.getItem('auth-storage'))
```
### Backend Commands:
```bash
# Check RefreshToken model
python manage.py shell
>>> from igny8_core.auth.models_refresh_token import RefreshToken
>>> RefreshToken.objects.all()
# Check Redis session
redis-cli
> KEYS *session*
# View backend logs
docker logs -f igny8-backend | grep -E "LOGOUT|TOKEN|AUTH"
```
## Expected Behavior
### With Remember Me Checked:
- Access token: 1 hour expiry
- Refresh token: 20 days expiry
- User stays logged in for 20 days, including idle time
- Token auto-refreshes when access token expires
### Without Remember Me:
- Access token: 1 hour expiry
- Refresh token: 7 days expiry
- User stays logged in for 7 days
### Logout Should Only Occur When:
1. User explicitly clicks logout
2. Refresh token expires (20 days or 7 days)
3. Refresh token is revoked
4. User changes password (revokes all tokens)
### Logout Should NEVER Occur Due To:
- Network failures
- 403 permission errors
- 402 payment errors
- 5xx server errors
- Multi-tab usage
- Idle time (as long as refresh token valid)
## Troubleshooting
### Issue: Token Monitor Not Starting
**Solution:** Check browser console for errors. Verify import in App.tsx.
### Issue: Logout Alert Not Showing
**Solution:** Check LogoutTracker import in authStore.ts. Verify trackLogout() is called.
### Issue: Backend Not Receiving Logout Events
**Solution:** Check network tab for POST to /v1/auth/logout-event/. Verify CORS settings.
### Issue: Logout Banner Not Showing
**Solution:** Verify LogoutReasonBanner is imported in SignInForm.tsx.
### Issue: Still Logging Out at 20 Minutes
**Possible Causes:**
1. Remember me checkbox not checked during login
2. Frontend not sending remember_me=true to backend
3. Backend not respecting remember_me flag
4. Redis session expiring (check SESSION_COOKIE_AGE in settings.py)
5. JWT access token in localStorage causing issues (should be removed)
## Next Steps
1. **Deploy to production** using steps above
2. **Login with remember_me checked** and wait 25+ minutes
3. **Capture logout event** with all debugging data
4. **Analyze the data** to identify exact cause
5. **Report findings** with:
- Logout type (from console logs)
- Idle time (from logout banner)
- Token status (from TokenMonitor logs)
- Backend logs (from server)
- Network tab (API calls before logout)
## Additional Notes
- All debugging is production-safe (no performance impact)
- Token monitoring is throttled to 30-second intervals
- Logout tracking has minimal overhead
- Debug panel is only visible when opened (Ctrl+Shift+D)
- All logs can be disabled by removing console.log statements
## Support
If issues persist after deployment:
1. Export console logs: Right-click console → Save as...
2. Export logout history: `copy(localStorage.getItem('logout_history'))`
3. Export token status: `copy(JSON.stringify(window.__tokenMonitor.getTokenStatus()))`
4. Export backend logs: `docker logs igny8-backend > backend-logs.txt`
5. Share all data for analysis

View File

@@ -1,206 +0,0 @@
# Logout Debugging - Quick Reference
## 🚀 Quick Deploy
```bash
cd /data/app/igny8
./scripts/deploy-logout-debugging.sh
```
## 🔍 What to Watch
### Console Logs (Every 30 seconds)
```
[TokenMonitor] Access token: 45 minutes until expiry
[TokenMonitor] Refresh token: 19 days, 12 hours until expiry
```
### When Logout Happens
```
[LogoutTracker] 🚨 Logout triggered
[LogoutTracker] Type: TOKEN_EXPIRED
[LogoutTracker] Message: [Exact reason]
[LogoutTracker] Idle time: 23 minutes
```
## 🛠️ Debug Tools
### Debug Panel
- **Open:** Press `Ctrl+Shift+D` or click 🔍 button
- **Shows:** Auth status, token expiry, recent logouts
- **Action:** "Log Full State to Console" button
### Browser Console Commands
```javascript
// Get token status
window.__tokenMonitor.getTokenStatus()
// Get logout history
JSON.parse(localStorage.getItem('logout_history') || '[]')
// Get last logout reason
sessionStorage.getItem('last_logout_reason')
// Check auth state
JSON.parse(localStorage.getItem('auth-storage'))
```
### Backend Logs
```bash
# Watch for logout events
docker logs -f igny8-backend | grep "LOGOUT EVENT"
# Check all auth activity
docker logs igny8-backend | grep -E "LOGOUT|TOKEN|AUTH"
```
## 📊 What Data Tells Us
### If `Type: TOKEN_EXPIRED`
- Token actually expired OR
- Code incorrectly checking expiry
- **Check:** Token status in logs - was it really expired?
### If `Type: REFRESH_FAILED`
- Refresh endpoint returned 401
- RefreshToken model rejecting valid token OR
- Backend bug
- **Check:** Backend logs for refresh endpoint errors
### If `Type: AUTH_ERROR`
- 403/402 error triggered logout (shouldn't happen)
- Error classification bug in api-new.ts
- **Check:** Network tab for failed API calls before logout
### If `Type: UNKNOWN`
- JavaScript error OR
- Browser extension OR
- Multi-tab coordination issue
- **Check:** Console for JS errors, test in incognito
## ⏱️ Expected Timeline
### With Remember Me = True:
```
T+0: Login → Access: 60min, Refresh: 20d
T+60min: Auto-refresh → Access: 60min, Refresh: ~19d 23h
T+120min: Auto-refresh → Access: 60min, Refresh: ~19d 22h
...continues until refresh token expires after 20 days...
T+20d: Logout → Type: REFRESH_FAILED
```
### Bug Scenario (What We're Debugging):
```
T+0: Login → Access: 60min, Refresh: 20d
T+25min: 🚨 LOGOUT → Access: 35min left, Refresh: 19d 23h left
^ This is the bug! Tokens still valid but logout occurred
```
## 🎯 Critical Data Points
When logout happens, capture:
1. **Type:** USER_ACTION | TOKEN_EXPIRED | REFRESH_FAILED | AUTH_ERROR | UNKNOWN
2. **Idle Minutes:** How long user was inactive
3. **Access Token Status:** Time remaining (from TokenMonitor logs)
4. **Refresh Token Status:** Time remaining (from TokenMonitor logs)
5. **Location:** Which page user was on
6. **Last API Call:** Check Network tab
7. **Console Errors:** Any JavaScript errors
8. **Backend Logs:** What backend received
## 📍 Where to Find Data
| Data Point | Location |
|------------|----------|
| Token status logs | Browser Console (every 30s) |
| Logout event details | Browser Console (when logout) |
| Visual alert | Modal overlay (3 sec before redirect) |
| Logout reason | Signin page banner |
| Backend logs | `docker logs igny8-backend` |
| Logout history | Debug Panel (Ctrl+Shift+D) |
| Full state | Debug Panel → "Log Full State" |
## 🔧 Common Issues
### No console logs?
```javascript
// Check if monitor started
window.__tokenMonitor
// Should return: TokenExpiryMonitor { ... }
```
### No alert before logout?
```javascript
// Check if tracker loaded
localStorage.getItem('logout_history')
// Should return: JSON array
```
### Backend not receiving events?
```bash
# Test endpoint
curl -X POST http://localhost:8000/api/v1/auth/logout-event/ \
-H "Content-Type: application/json" \
-d '{"type":"TEST","message":"test"}'
```
### No banner on signin page?
```javascript
// Check if reason stored
sessionStorage.getItem('last_logout_reason')
// Should return: JSON object after logout
```
## 📝 Test Procedure
1. **Login** with "Remember me for 20 days" checked
2. **Open** Browser DevTools (F12) → Console tab
3. **Verify** Token monitor logs every 30 seconds
4. **Wait** 25+ minutes (can minimize, but keep tab open)
5. **Watch** for logout event in console
6. **Capture** all data:
- Console screenshot
- Network tab screenshot
- Backend logs: `docker logs igny8-backend | tail -50`
7. **Check** signin page for logout banner
8. **Analyze** collected data to identify root cause
## 📞 Support Data Export
If issue persists, export:
```javascript
// 1. Token status
copy(JSON.stringify(window.__tokenMonitor.getTokenStatus(), null, 2))
// 2. Logout history
copy(localStorage.getItem('logout_history'))
// 3. Last logout reason
copy(sessionStorage.getItem('last_logout_reason'))
// 4. Full auth state
copy(localStorage.getItem('auth-storage'))
```
```bash
# 5. Backend logs
docker logs igny8-backend > backend-logs.txt
```
## 🎉 Success Criteria
After identifying root cause and fixing:
- [x] User logs in with remember_me=true
- [x] User can idle for 25+ minutes without logout
- [x] Access token auto-refreshes after 1 hour
- [x] Logout only occurs at 20 days (refresh token expiry)
- [x] Console logs confirm: "Access: 35min, Refresh: 19d 23h" at T+25min
- [x] No unexpected logout events in history
---
**Full Documentation:** See `LOGOUT-DEBUGGING-COMPLETE-SUMMARY.md`
**Deployment Guide:** See `LOGOUT-DEBUGGING-DEPLOYMENT.md`

View File

@@ -5,6 +5,7 @@ Extracts account from JWT token and injects into request context
import logging
from django.utils.deprecation import MiddlewareMixin
from django.http import JsonResponse
from django.contrib.auth import logout
from rest_framework import status
logger = logging.getLogger('auth.middleware')
@@ -40,19 +41,45 @@ class AccountContextMiddleware(MiddlewareMixin):
# Validate account/plan - but use the user object already set by Django
validation_error = self._validate_account_and_plan(request, request.user)
if validation_error:
# CRITICAL: Return error response, DO NOT logout
# Frontend will handle auth errors appropriately
return validation_error
# Set request.account from the user's account relationship
# This is already loaded, no need to query DB again
request.account = getattr(request.user, 'account', None)
# Store account and user IDs in session for audit purposes only
# DO NOT use these for validation - they are informational only
# CRITICAL: Add account ID to session to prevent cross-contamination
# This ensures each session is tied to a specific account
if request.account:
request.session['_account_id'] = request.account.id
request.session['_user_id'] = request.user.id
# Verify session integrity - if stored IDs don't match, logout
stored_account_id = request.session.get('_account_id')
stored_user_id = request.session.get('_user_id')
if stored_account_id and stored_account_id != request.account.id:
# Session contamination detected - force logout
logger.warning(
f"[AUTO-LOGOUT] Session contamination: account_id mismatch. "
f"Session={stored_account_id}, Current={request.account.id}, "
f"User={request.user.id}, Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
)
logout(request)
return JsonResponse(
{'success': False, 'error': 'Session integrity violation detected. Please login again.'},
status=status.HTTP_401_UNAUTHORIZED
)
if stored_user_id and stored_user_id != request.user.id:
# Session contamination detected - force logout
logger.warning(
f"[AUTO-LOGOUT] Session contamination: user_id mismatch. "
f"Session={stored_user_id}, Current={request.user.id}, "
f"Account={request.account.id if request.account else None}, "
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
)
logout(request)
return JsonResponse(
{'success': False, 'error': 'Session integrity violation detected. Please login again.'},
status=status.HTTP_401_UNAUTHORIZED
)
return None
except (AttributeError, Exception):
@@ -101,7 +128,6 @@ class AccountContextMiddleware(MiddlewareMixin):
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
validation_error = self._validate_account_and_plan(request, user)
if validation_error:
# CRITICAL: Return error response, DO NOT logout
return validation_error
if account_id:
# Verify account still exists
@@ -158,17 +184,18 @@ class AccountContextMiddleware(MiddlewareMixin):
return None
def _deny_request(self, request, error, status_code):
"""Return a consistent JSON error WITHOUT logging out the user."""
# Log the denial for audit purposes
"""Logout session users (if any) and return a consistent JSON error."""
try:
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
logger.warning(
f"[ACCESS-DENIED] {error}. "
f"User={request.user.id if hasattr(request, 'user') and request.user else 'anonymous'}, "
f"Account={getattr(request, 'account', None)}, "
f"[AUTO-LOGOUT] Account/plan validation failed: {error}. "
f"User={request.user.id}, Account={getattr(request, 'account', None)}, "
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
)
logout(request)
except Exception as e:
logger.error(f"[AUTO-LOGOUT] Error during logout: {e}")
# Return error response - frontend will handle appropriately
# DO NOT call logout() - let the frontend decide based on error type
return JsonResponse(
{
'success': False,

View File

@@ -8,9 +8,6 @@ from django.core.validators import MinValueValidator, MaxValueValidator
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
from simple_history.models import HistoricalRecords
# Import RefreshToken model
from .models_refresh_token import RefreshToken
class AccountBaseModel(models.Model):
"""

View File

@@ -1,219 +0,0 @@
"""
Refresh Token Model - Server-side storage for JWT refresh tokens
Implements token rotation, revocation, and device tracking
"""
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta
import secrets
class RefreshToken(models.Model):
"""
Server-side refresh token storage with rotation and revocation support.
Design principles:
- Refresh tokens are the authoritative source of login state
- Each token has a unique identifier for rotation tracking
- Tokens can be revoked explicitly (password change, admin action)
- Device information helps identify and manage sessions
- Expiry is configurable (20 days for remember-me, 7 days otherwise)
"""
# Token identification
token_id = models.CharField(
max_length=64,
unique=True,
db_index=True,
help_text="Unique identifier for this refresh token"
)
user = models.ForeignKey(
get_user_model(),
on_delete=models.CASCADE,
related_name='refresh_tokens',
db_index=True
)
# Token lifecycle
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
expires_at = models.DateTimeField(db_index=True)
last_used_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True, db_index=True)
# Device and context tracking
device_id = models.CharField(
max_length=255,
blank=True,
help_text="Client-generated device identifier for multi-device tracking"
)
user_agent = models.TextField(blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
# Rotation tracking
parent_token_id = models.CharField(
max_length=64,
blank=True,
db_index=True,
help_text="Token ID that was used to create this token (for rotation chain)"
)
rotation_count = models.PositiveIntegerField(
default=0,
help_text="Number of times this token chain has been rotated"
)
# Remember me flag
remember_me = models.BooleanField(
default=False,
help_text="Whether this token was created with remember-me option"
)
class Meta:
db_table = 'auth_refresh_token'
indexes = [
models.Index(fields=['user', 'revoked_at', 'expires_at']),
models.Index(fields=['token_id']),
models.Index(fields=['user', 'device_id']),
]
ordering = ['-created_at']
def __str__(self):
status = "revoked" if self.is_revoked else ("expired" if self.is_expired else "active")
return f"{self.user.email} - {self.token_id[:8]}... ({status})"
@property
def is_expired(self):
"""Check if token is expired"""
return timezone.now() > self.expires_at
@property
def is_revoked(self):
"""Check if token is revoked"""
return self.revoked_at is not None
@property
def is_valid(self):
"""Check if token is both not expired and not revoked"""
return not self.is_expired and not self.is_revoked
def revoke(self):
"""
Revoke this token.
Once revoked, it cannot be used for refresh operations.
"""
if not self.is_revoked:
self.revoked_at = timezone.now()
self.save(update_fields=['revoked_at'])
def mark_used(self):
"""Update last_used_at timestamp"""
self.last_used_at = timezone.now()
self.save(update_fields=['last_used_at'])
@classmethod
def create_token(cls, user, remember_me=False, device_id='', user_agent='', ip_address=None, parent_token_id=''):
"""
Create a new refresh token for the user.
Args:
user: User instance
remember_me: If True, token expires in 20 days; otherwise 7 days
device_id: Client device identifier
user_agent: Browser user agent
ip_address: Client IP address
parent_token_id: Token ID that was rotated to create this token
Returns:
RefreshToken instance
"""
# Generate unique token ID
token_id = secrets.token_urlsafe(32)
# Calculate expiry based on remember_me
if remember_me:
expiry = timezone.now() + timedelta(days=20)
else:
expiry = timezone.now() + timedelta(days=7)
# Determine rotation count
rotation_count = 0
if parent_token_id:
parent = cls.objects.filter(token_id=parent_token_id).first()
if parent:
rotation_count = parent.rotation_count + 1
# Create token
token = cls.objects.create(
token_id=token_id,
user=user,
expires_at=expiry,
device_id=device_id,
user_agent=user_agent,
ip_address=ip_address,
parent_token_id=parent_token_id,
rotation_count=rotation_count,
remember_me=remember_me
)
return token
@classmethod
def get_valid_token(cls, token_id):
"""
Get a valid (not expired, not revoked) refresh token by ID.
Returns:
RefreshToken instance or None
"""
try:
token = cls.objects.select_related('user', 'user__account').get(
token_id=token_id,
revoked_at__isnull=True,
expires_at__gt=timezone.now()
)
return token
except cls.DoesNotExist:
return None
@classmethod
def revoke_all_for_user(cls, user, exclude_token_id=None):
"""
Revoke all refresh tokens for a user (e.g., on password change).
Args:
user: User instance
exclude_token_id: Optional token ID to exclude from revocation
"""
now = timezone.now()
queryset = cls.objects.filter(
user=user,
revoked_at__isnull=True
)
if exclude_token_id:
queryset = queryset.exclude(token_id=exclude_token_id)
queryset.update(revoked_at=now)
@classmethod
def cleanup_expired(cls, days=30):
"""
Delete expired and revoked tokens older than specified days.
Should be run periodically via cron/celery task.
Args:
days: Delete tokens expired/revoked more than this many days ago
"""
cutoff = timezone.now() - timedelta(days=days)
# Delete expired tokens
expired_count = cls.objects.filter(
expires_at__lt=cutoff
).delete()[0]
# Delete revoked tokens
revoked_count = cls.objects.filter(
revoked_at__lt=cutoff
).delete()[0]
return expired_count + revoked_count

View File

@@ -478,11 +478,9 @@ class RegisterSerializer(serializers.Serializer):
class LoginSerializer(serializers.Serializer):
"""Serializer for user login with remember-me support."""
"""Serializer for user login."""
email = serializers.EmailField()
password = serializers.CharField(write_only=True)
remember_me = serializers.BooleanField(required=False, default=False)
device_id = serializers.CharField(required=False, allow_blank=True, default='')
class ChangePasswordSerializer(serializers.Serializer):

View File

@@ -314,7 +314,5 @@ urlpatterns = [
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
path('me/', MeView.as_view(), name='auth-me'),
# Logout tracking endpoint for debugging
path('logout-event/', csrf_exempt(lambda request: __import__('igny8_core.auth.views_logout_tracking', fromlist=['track_logout_event']).track_logout_event(request)), name='auth-logout-event'),
]

View File

@@ -57,48 +57,33 @@ def generate_access_token(user, account=None):
return token
def generate_refresh_token_pair(user, account=None, remember_me=False, device_id='', user_agent='', ip_address=None):
def generate_refresh_token(user, account=None):
"""
Generate JWT refresh token and store it server-side for rotation/revocation.
Generate JWT refresh token for user
Args:
user: User instance
account: Account instance (optional, will use user.account if not provided)
remember_me: If True, token expires in 20 days; otherwise 7 days
device_id: Client device identifier
user_agent: Browser user agent
ip_address: Client IP address
Returns:
tuple: (refresh_token_string, refresh_token_id, expiry_datetime)
str: JWT refresh token
"""
from .models_refresh_token import RefreshToken
if account is None:
account = getattr(user, 'account', None)
# Create server-side refresh token record
token_record = RefreshToken.create_token(
user=user,
remember_me=remember_me,
device_id=device_id,
user_agent=user_agent,
ip_address=ip_address
)
# Generate JWT with token_id embedded (for rotation tracking)
now = timezone.now()
expiry = now + get_refresh_token_expiry()
payload = {
'user_id': user.id,
'account_id': account.id if account else None,
'token_id': token_record.token_id,
'exp': int(token_record.expires_at.timestamp()),
'exp': int(expiry.timestamp()),
'iat': int(now.timestamp()),
'type': 'refresh',
}
token_string = jwt.encode(payload, get_jwt_secret_key(), algorithm=get_jwt_algorithm())
return token_string, token_record.token_id, token_record.expires_at
token = jwt.encode(payload, get_jwt_secret_key(), algorithm=get_jwt_algorithm())
return token
def decode_token(token):

View File

@@ -1049,12 +1049,11 @@ class AuthViewSet(viewsets.GenericViewSet):
@action(detail=False, methods=['post'])
def login(self, request):
"""User login endpoint with remember-me support."""
"""User login endpoint."""
serializer = LoginSerializer(data=request.data)
if serializer.is_valid():
email = serializer.validated_data['email']
password = serializer.validated_data['password']
remember_me = serializer.validated_data.get('remember_me', False)
try:
user = User.objects.select_related('account', 'account__plan').get(email=email)
@@ -1088,17 +1087,11 @@ class AuthViewSet(viewsets.GenericViewSet):
from django.contrib.auth import login
login(request, user)
# Extract device information from request
device_id = request.data.get('device_id', '')
user_agent = request.META.get('HTTP_USER_AGENT', '')
ip_address = request.META.get('REMOTE_ADDR')
# Generate JWT tokens with remember-me support
# Generate JWT tokens
access_token = generate_access_token(user, account)
refresh_token, token_id, refresh_expires_at = generate_refresh_token_pair(
user, account, remember_me, device_id, user_agent, ip_address
)
refresh_token = generate_refresh_token(user, account)
access_expires_at = get_token_expiry('access')
refresh_expires_at = get_token_expiry('refresh')
user_serializer = UserSerializer(user)
return success_response(
@@ -1128,9 +1121,7 @@ class AuthViewSet(viewsets.GenericViewSet):
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def change_password(self, request):
"""Change password endpoint - revokes all refresh tokens."""
from .models_refresh_token import RefreshToken
"""Change password endpoint."""
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
user = request.user
@@ -1144,12 +1135,8 @@ class AuthViewSet(viewsets.GenericViewSet):
user.set_password(serializer.validated_data['new_password'])
user.save()
# CRITICAL: Revoke all refresh tokens when password changes
# This forces re-login on all devices for security
RefreshToken.revoke_all_for_user(user)
return success_response(
message='Password changed successfully. Please login again on all devices.',
message='Password changed successfully',
request=request
)
@@ -1174,10 +1161,7 @@ class AuthViewSet(viewsets.GenericViewSet):
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def refresh(self, request):
"""Refresh access token using refresh token with atomic rotation."""
from .models_refresh_token import RefreshToken
from django.db import transaction
"""Refresh access token using refresh token."""
serializer = RefreshTokenSerializer(data=request.data)
if not serializer.is_valid():
return error_response(
@@ -1190,7 +1174,7 @@ class AuthViewSet(viewsets.GenericViewSet):
refresh_token = serializer.validated_data['refresh']
try:
# Decode and validate refresh token JWT
# Decode and validate refresh token
payload = decode_token(refresh_token)
# Verify it's a refresh token
@@ -1201,59 +1185,39 @@ class AuthViewSet(viewsets.GenericViewSet):
request=request
)
# Get token_id from payload and validate against database
token_id = payload.get('token_id')
if not token_id:
# Get user
user_id = payload.get('user_id')
account_id = payload.get('account_id')
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return error_response(
error='Invalid refresh token - missing token ID',
status_code=status.HTTP_401_UNAUTHORIZED,
error='User not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# Validate token exists, not revoked, and not expired in database
token_record = RefreshToken.get_valid_token(token_id)
if not token_record:
return error_response(
error='Refresh token is invalid, revoked, or expired',
status_code=status.HTTP_401_UNAUTHORIZED,
request=request
)
# Get account
account_id = payload.get('account_id')
account = None
if account_id:
try:
account = Account.objects.get(id=account_id)
except Account.DoesNotExist:
pass
user = token_record.user
if not account:
account = getattr(user, 'account', None)
# Extract device information from request
device_id = request.data.get('device_id', token_record.device_id)
user_agent = request.META.get('HTTP_USER_AGENT', '')
ip_address = request.META.get('REMOTE_ADDR')
# ATOMIC ROTATION: Create new token, then revoke old token
# This ensures no gap where user could be logged out
with transaction.atomic():
# Generate new access and refresh tokens
# Generate new access token
access_token = generate_access_token(user, account)
new_refresh_token, new_token_id, refresh_expires_at = generate_refresh_token_pair(
user=user,
account=account,
remember_me=token_record.remember_me, # Preserve remember_me setting
device_id=device_id,
user_agent=user_agent,
ip_address=ip_address,
parent_token_id=token_id # Track rotation chain
)
# Only revoke old token AFTER new one is created
token_record.revoke()
token_record.mark_used()
access_expires_at = get_token_expiry('access')
return success_response(
data={
'access': access_token,
'refresh': new_refresh_token, # Return new refresh token
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
'access_expires_at': access_expires_at.isoformat()
},
request=request
)

View File

@@ -1,63 +0,0 @@
"""
Logout event tracking for debugging
"""
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
import logging
logger = logging.getLogger('auth.logout_tracking')
@api_view(['POST'])
@permission_classes([AllowAny])
def track_logout_event(request):
"""
Track logout events from frontend for debugging.
This helps identify why users are being logged out.
"""
try:
data = request.data
# Extract logout information
logout_type = data.get('type', 'UNKNOWN')
message = data.get('message', 'No message provided')
timestamp = data.get('timestamp')
idle_minutes = data.get('idleMinutes', 0)
location = data.get('location', '')
context = data.get('context', {})
# Log with high visibility
logger.warning(
f"\n"
f"{'=' * 80}\n"
f"🚨 FRONTEND LOGOUT DETECTED\n"
f"{'=' * 80}\n"
f"Type: {logout_type}\n"
f"Reason: {message}\n"
f"Idle Time: {idle_minutes} minutes\n"
f"Location: {location}\n"
f"Timestamp: {timestamp}\n"
f"User Agent: {context.get('userAgent', 'Unknown')}\n"
f"Screen: {context.get('screenResolution', 'Unknown')}\n"
f"IP: {request.META.get('REMOTE_ADDR', 'Unknown')}\n"
f"User ID: {context.get('userId', 'Unknown')}\n"
f"User Email: {context.get('userEmail', 'Unknown')}\n"
f"Had Token: {context.get('hasToken', False)}\n"
f"Had Refresh: {context.get('hasRefreshToken', False)}\n"
f"Was Authenticated: {context.get('isAuthenticated', False)}\n"
f"{'=' * 80}\n"
)
return Response({
'success': True,
'message': 'Logout event logged successfully'
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"[LOGOUT-TRACKING] Error logging logout event: {e}")
return Response({
'success': False,
'error': 'Failed to log logout event'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -96,34 +96,12 @@ CSRF_COOKIE_SECURE = USE_SECURE_COOKIES
# 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 = 'Lax' # Changed from Strict - allows external redirects
SESSION_COOKIE_AGE = 1209600 # 14 days (2 weeks)
SESSION_SAVE_EVERY_REQUEST = True # Enable sliding window - extends session on activity
SESSION_COOKIE_SAMESITE = 'Strict' # Prevent cross-site cookie sharing
SESSION_COOKIE_AGE = 86400 # 24 hours
SESSION_SAVE_EVERY_REQUEST = False # Don't update session on every request (reduces DB load)
SESSION_COOKIE_PATH = '/' # Explicit path
# Don't set SESSION_COOKIE_DOMAIN - let it default to current domain for strict isolation
# CRITICAL: Use Redis for session storage (not database)
# Provides better performance and automatic expiry
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
# Configure Redis cache for sessions
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': f"redis://{os.getenv('REDIS_HOST', 'redis')}:{os.getenv('REDIS_PORT', '6379')}/1",
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'SOCKET_CONNECT_TIMEOUT': 5,
'SOCKET_TIMEOUT': 5,
'CONNECTION_POOL_KWARGS': {
'max_connections': 50,
'retry_on_timeout': True
}
}
}
}
# CRITICAL: Custom authentication backend to disable user caching
AUTHENTICATION_BACKENDS = [
'igny8_core.auth.backends.NoCacheModelBackend', # Custom backend without caching
@@ -542,7 +520,7 @@ CORS_EXPOSE_HEADERS = [
# JWT Configuration
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY)
JWT_ALGORITHM = 'HS256'
JWT_ACCESS_TOKEN_EXPIRY = timedelta(hours=1) # Increased from 15min to 1 hour
JWT_ACCESS_TOKEN_EXPIRY = timedelta(minutes=15)
JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) # Extended to 30 days for persistent login
# Celery Configuration

View File

@@ -2,7 +2,6 @@ Django>=5.2.7
gunicorn
psycopg2-binary
redis
django-redis>=5.4.0
whitenoise
djangorestframework
django-filter

View File

@@ -10,9 +10,6 @@ import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
import { useAuthStore } from "./store/authStore";
// Import monitoring services
import './services/tokenExpiryMonitor'; // Auto-starts monitoring on import
// Auth pages - loaded immediately (needed for login)
import SignIn from "./pages/AuthPages/SignIn";
import SignUp from "./pages/AuthPages/SignUp";

View File

@@ -1,133 +0,0 @@
/**
* Logout Reason Banner
* Shows why user was logged out on the signin page
*/
import { useEffect, useState } from 'react';
import { logoutTracker } from '../../services/logoutTracker';
interface LogoutReason {
type: 'USER_ACTION' | 'TOKEN_EXPIRED' | 'REFRESH_FAILED' | 'AUTH_ERROR' | 'UNKNOWN';
message: string;
timestamp: number;
idleMinutes?: number;
location?: string;
}
export default function LogoutReasonBanner() {
const [logoutReason, setLogoutReason] = useState<LogoutReason | null>(null);
const [showDebug, setShowDebug] = useState(false);
useEffect(() => {
// Get last logout reason
const reason = logoutTracker.getLastLogoutReason();
if (reason) {
setLogoutReason(reason);
// Auto-clear after displaying
setTimeout(() => {
logoutTracker.clearLastLogoutReason();
}, 30000); // Clear after 30 seconds
}
}, []);
if (!logoutReason) {
return null;
}
// Determine banner color based on logout type
const getBannerColor = () => {
switch (logoutReason.type) {
case 'USER_ACTION':
return 'bg-blue-50 border-blue-300 text-blue-800';
case 'TOKEN_EXPIRED':
return 'bg-yellow-50 border-yellow-300 text-yellow-800';
case 'REFRESH_FAILED':
return 'bg-red-50 border-red-300 text-red-800';
case 'AUTH_ERROR':
return 'bg-red-50 border-red-300 text-red-800';
default:
return 'bg-gray-50 border-gray-300 text-gray-800';
}
};
// Get icon based on type
const getIcon = () => {
switch (logoutReason.type) {
case 'USER_ACTION':
return '👋';
case 'TOKEN_EXPIRED':
return '⏰';
case 'REFRESH_FAILED':
return '🚨';
case 'AUTH_ERROR':
return '⚠️';
default:
return '❓';
}
};
// Get user-friendly message
const getMessage = () => {
if (logoutReason.type === 'USER_ACTION') {
return 'You signed out successfully.';
}
if (logoutReason.idleMinutes && logoutReason.idleMinutes > 15) {
return `Session expired after ${logoutReason.idleMinutes} minutes of inactivity.`;
}
return logoutReason.message || 'Your session has ended.';
};
const timeSinceLogout = Math.floor((Date.now() - logoutReason.timestamp) / 1000);
const minutesAgo = Math.floor(timeSinceLogout / 60);
return (
<div className="mb-6">
<div className={`border-2 rounded-lg p-4 ${getBannerColor()}`}>
<div className="flex items-start gap-3">
<div className="text-2xl flex-shrink-0">{getIcon()}</div>
<div className="flex-1">
<h3 className="font-semibold mb-1">Session Ended</h3>
<p className="text-sm mb-2">{getMessage()}</p>
<p className="text-xs opacity-75">
{minutesAgo === 0 ? 'Just now' : `${minutesAgo} minute${minutesAgo > 1 ? 's' : ''} ago`}
</p>
{/* Debug toggle */}
{logoutReason.type !== 'USER_ACTION' && (
<button
onClick={() => setShowDebug(!showDebug)}
className="text-xs underline mt-2 opacity-75 hover:opacity-100"
>
{showDebug ? 'Hide' : 'Show'} technical details
</button>
)}
{/* Debug info */}
{showDebug && (
<div className="mt-3 p-3 bg-white bg-opacity-50 rounded text-xs font-mono">
<div><strong>Type:</strong> {logoutReason.type}</div>
<div><strong>Idle Time:</strong> {logoutReason.idleMinutes || 0} minutes</div>
<div><strong>Location:</strong> {logoutReason.location || 'Unknown'}</div>
<div><strong>Timestamp:</strong> {new Date(logoutReason.timestamp).toISOString()}</div>
<div><strong>Message:</strong> {logoutReason.message}</div>
</div>
)}
</div>
{/* Close button */}
<button
onClick={() => {
setLogoutReason(null);
logoutTracker.clearLastLogoutReason();
}}
className="text-xl opacity-50 hover:opacity-100 flex-shrink-0"
>
×
</button>
</div>
</div>
</div>
);
}

View File

@@ -6,11 +6,10 @@ import Input from "../form/input/InputField";
import Checkbox from "../form/input/Checkbox";
import Button from "../ui/button/Button";
import { useAuthStore } from "../../store/authStore";
import LogoutReasonBanner from "./LogoutReasonBanner";
export default function SignInForm() {
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
@@ -28,7 +27,7 @@ export default function SignInForm() {
}
try {
await login(email, password, rememberMe);
await login(email, password);
// Redirect to the page user was trying to access, or home
const from = (location.state as any)?.from?.pathname || "/";
navigate(from, { replace: true });
@@ -47,9 +46,6 @@ export default function SignInForm() {
</div>
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
<div>
{/* Show logout reason if user was logged out */}
<LogoutReasonBanner />
<div className="mb-5 sm:mb-8">
<h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
Sign In
@@ -156,9 +152,9 @@ export default function SignInForm() {
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Checkbox checked={rememberMe} onChange={setRememberMe} />
<Checkbox checked={isChecked} onChange={setIsChecked} />
<span className="block font-normal text-gray-700 text-theme-sm dark:text-gray-400">
Remember me for 20 days
Keep me logged in
</span>
</div>
<Link

View File

@@ -1,178 +0,0 @@
/**
* Auth Debug Panel
* Shows auth status and recent logouts for debugging
* Add to your app to monitor auth issues in real-time
*/
import { useState, useEffect } from 'react';
import { useAuthStore } from '../../store/authStore';
import { logoutTracker } from '../../services/logoutTracker';
import { tokenExpiryMonitor } from '../../services/tokenExpiryMonitor';
export default function AuthDebugPanel() {
const [isOpen, setIsOpen] = useState(false);
const [tokenStatus, setTokenStatus] = useState<any>(null);
const [logoutHistory, setLogoutHistory] = useState<any[]>([]);
const { user, token, refreshToken, isAuthenticated } = useAuthStore();
useEffect(() => {
if (isOpen) {
// Update token status
const status = tokenExpiryMonitor.getTokenStatus();
setTokenStatus(status);
// Get logout history
const history = logoutTracker.getLogoutHistory();
setLogoutHistory(history);
// Update every 5 seconds
const interval = setInterval(() => {
const status = tokenExpiryMonitor.getTokenStatus();
setTokenStatus(status);
}, 5000);
return () => clearInterval(interval);
}
}, [isOpen]);
// Toggle with keyboard shortcut (Ctrl+Shift+D)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
setIsOpen(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-4 right-4 bg-gray-800 text-white p-3 rounded-full shadow-lg hover:bg-gray-700 z-50"
title="Open Auth Debug Panel (Ctrl+Shift+D)"
>
🔍
</button>
);
}
return (
<div className="fixed bottom-4 right-4 w-96 max-h-[600px] bg-white shadow-2xl rounded-lg border-2 border-gray-300 overflow-hidden z-50">
{/* Header */}
<div className="bg-gray-800 text-white p-3 flex justify-between items-center">
<h3 className="font-bold">🔍 Auth Debug Panel</h3>
<button
onClick={() => setIsOpen(false)}
className="text-white hover:text-gray-300 text-xl"
>
×
</button>
</div>
{/* Content */}
<div className="p-4 overflow-y-auto max-h-[540px] text-sm">
{/* Auth Status */}
<div className="mb-4">
<h4 className="font-semibold mb-2 text-gray-700">Auth Status</h4>
<div className="bg-gray-50 p-3 rounded space-y-1 text-xs">
<div className="flex justify-between">
<span>Authenticated:</span>
<span className={isAuthenticated ? 'text-green-600 font-semibold' : 'text-red-600'}>
{isAuthenticated ? '✓ Yes' : '✗ No'}
</span>
</div>
<div className="flex justify-between">
<span>User ID:</span>
<span>{user?.id || 'None'}</span>
</div>
<div className="flex justify-between">
<span>Email:</span>
<span className="truncate ml-2">{user?.email || 'None'}</span>
</div>
<div className="flex justify-between">
<span>Has Access Token:</span>
<span className={token ? 'text-green-600' : 'text-red-600'}>
{token ? '✓' : '✗'}
</span>
</div>
<div className="flex justify-between">
<span>Has Refresh Token:</span>
<span className={refreshToken ? 'text-green-600' : 'text-red-600'}>
{refreshToken ? '✓' : '✗'}
</span>
</div>
</div>
</div>
{/* Token Status */}
{tokenStatus && (
<div className="mb-4">
<h4 className="font-semibold mb-2 text-gray-700">Token Status</h4>
<div className="bg-gray-50 p-3 rounded space-y-1 text-xs">
<div className="flex justify-between">
<span>Access Token:</span>
<span className={tokenStatus.accessTokenExpired ? 'text-red-600' : 'text-green-600'}>
{tokenStatus.accessTokenExpired ? 'Expired' : `${tokenStatus.accessExpiresInMinutes}m left`}
</span>
</div>
<div className="flex justify-between">
<span>Refresh Token:</span>
<span className={tokenStatus.refreshTokenExpired ? 'text-red-600' : 'text-green-600'}>
{tokenStatus.refreshTokenExpired ? 'Expired' : `${tokenStatus.refreshExpiresInHours}h left`}
</span>
</div>
</div>
</div>
)}
{/* Logout History */}
<div>
<h4 className="font-semibold mb-2 text-gray-700">
Recent Logouts ({logoutHistory.length})
</h4>
{logoutHistory.length === 0 ? (
<div className="text-gray-500 text-xs italic">No logout events recorded</div>
) : (
<div className="space-y-2">
{logoutHistory.slice(-5).reverse().map((event, idx) => (
<div key={idx} className="bg-gray-50 p-2 rounded border-l-4 border-red-500">
<div className="flex justify-between items-start mb-1">
<span className="font-semibold text-xs">{event.type}</span>
<span className="text-xs text-gray-500">
{Math.floor((Date.now() - event.timestamp) / 60000)}m ago
</span>
</div>
<div className="text-xs text-gray-600">{event.message}</div>
{event.idleMinutes > 0 && (
<div className="text-xs text-gray-500 mt-1">
Idle: {event.idleMinutes} minutes
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="mt-4 pt-4 border-t">
<button
onClick={() => {
console.log('=== AUTH STATE ===');
console.log('User:', user);
console.log('Token:', token?.substring(0, 20) + '...');
console.log('Refresh Token:', refreshToken?.substring(0, 20) + '...');
console.log('Token Status:', tokenStatus);
console.log('Logout History:', logoutHistory);
}}
className="w-full bg-gray-800 text-white py-2 rounded text-xs hover:bg-gray-700"
>
Log Full State to Console
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,375 +0,0 @@
/**
* API Service - Centralized API client with robust auth handling
*
* DESIGN PRINCIPLES:
* 1. Only logout on explicit authentication failures (not permission/network errors)
* 2. Refresh token deduplication - one refresh at a time
* 3. Multi-tab coordination via BroadcastChannel
* 4. Store access token in memory only (Zustand state)
* 5. Automatic retry on network failures
*/
import { useAuthStore } from '../store/authStore';
import { trackLogout } from './logoutTracker';
// ===== TOKEN REFRESH DEDUPLICATION =====
// Ensure only one refresh operation happens at a time across all API calls
let refreshPromise: Promise<string | null> | null = null;
// ===== MULTI-TAB COORDINATION =====
// Use BroadcastChannel to sync token refresh across tabs
let tokenSyncChannel: BroadcastChannel | null = null;
try {
if (typeof BroadcastChannel !== 'undefined') {
tokenSyncChannel = new BroadcastChannel('auth_token_sync');
tokenSyncChannel.onmessage = (event) => {
if (event.data.type === 'TOKEN_REFRESHED') {
// Another tab refreshed the token - update our state
const { setToken } = useAuthStore.getState();
setToken(event.data.accessToken);
} else if (event.data.type === 'LOGOUT') {
// Another tab logged out - sync logout
const { logout } = useAuthStore.getState();
logout();
}
};
}
} catch (e) {
console.warn('BroadcastChannel not available:', e);
}
function getApiBaseUrl(): string {
const envUrl = import.meta.env.VITE_BACKEND_URL || import.meta.env.VITE_API_URL;
if (envUrl) {
return envUrl.endsWith('/api') ? envUrl : `${envUrl}/api`;
}
const origin = window.location.origin;
if (origin.includes('localhost') || origin.includes('127.0.0.1') || /^\d+\.\d+\.\d+\.\d+/.test(origin)) {
if (origin.includes(':3000')) return origin.replace(':3000', ':8011') + '/api';
if (origin.includes(':7921')) return origin.replace(':7921', ':7911') + '/api';
return origin.split(':')[0] + ':8011/api';
}
return 'https://api.igny8.com/api';
}
export const API_BASE_URL = getApiBaseUrl();
// Get auth token from Zustand store (memory only - not localStorage)
const getAuthToken = (): string | null => {
try {
const authState = useAuthStore.getState();
return authState?.token || null;
} catch (e) {
return null;
}
};
// Get refresh token from Zustand store (persisted via middleware)
const getRefreshToken = (): string | null => {
try {
const authState = useAuthStore.getState();
return authState?.refreshToken || null;
} catch (e) {
return null;
}
};
/**
* Refresh access token with deduplication
* Returns new access token or null if refresh failed
*/
async function refreshAccessToken(): Promise<string | null> {
// If a refresh is already in progress, wait for it
if (refreshPromise) {
return refreshPromise;
}
// Start new refresh operation
refreshPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const response = await fetch(`${API_BASE_URL}/v1/auth/refresh/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh: refreshToken }),
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
const accessToken = data.data?.access || data.access;
const newRefreshToken = data.data?.refresh || data.refresh;
if (data.success && accessToken) {
// Update tokens in Zustand store
const { setToken } = useAuthStore.getState();
setToken(accessToken);
// Update refresh token if server returned a new one (rotation)
if (newRefreshToken) {
useAuthStore.setState({ refreshToken: newRefreshToken });
}
// Broadcast to other tabs
if (tokenSyncChannel) {
tokenSyncChannel.postMessage({
type: 'TOKEN_REFRESHED',
accessToken,
refreshToken: newRefreshToken
});
}
return accessToken;
}
}
// Refresh failed with explicit auth error
const errorData = await response.json().catch(() => ({}));
if (response.status === 401) {
// Refresh token is invalid - this is the ONLY case where we logout
console.warn('Refresh token invalid or expired - logging out');
// Track the logout
trackLogout(
'Refresh token invalid or expired (401 on refresh)',
'REFRESH_FAILED',
{ errorData, refreshEndpoint: '/v1/auth/refresh/' }
);
const { logout } = useAuthStore.getState();
logout('Refresh token invalid or expired', 'REFRESH_FAILED');
// Broadcast logout to other tabs
if (tokenSyncChannel) {
tokenSyncChannel.postMessage({ type: 'LOGOUT' });
}
}
return null;
} catch (error) {
// Network error during refresh - don't logout, just return null
console.debug('Token refresh failed (network error):', error);
// Log but don't track as logout (just a failed refresh attempt)
console.warn('[TOKEN-REFRESH] Failed due to network error, will retry on next request');
return null;
} finally {
// Clear the refresh promise
refreshPromise = null;
}
})();
return refreshPromise;
}
/**
* Extract user-friendly error message from API error
*/
export function getUserFriendlyError(error: any, fallback: string = 'An error occurred. Please try again.'): string {
const message = error?.message || error?.error || fallback;
if (message.includes('limit exceeded') ||
message.includes('not found') ||
message.includes('already exists') ||
message.includes('invalid') ||
message.includes('required') ||
message.includes('permission') ||
message.includes('upgrade')) {
return message;
}
return message;
}
/**
* Generic API fetch function with robust error handling
*
* ERROR HANDLING POLICY:
* - 401: Try to refresh token, retry request. Logout only if refresh fails.
* - 403: NEVER logout - could be permission/plan error, not auth error
* - 402: NEVER logout - payment/plan issue, not auth error
* - 5xx: NEVER logout - server error, not auth error
* - Network errors: NEVER logout - temporary issue
*/
export async function fetchAPI(endpoint: string, options?: RequestInit & { timeout?: number }) {
const timeout = options?.timeout || 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const token = getAuthToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options?.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers,
credentials: 'include',
signal: controller.signal,
...options,
});
clearTimeout(timeoutId);
const contentType = response.headers.get('content-type') || '';
const text = await response.text();
// SUCCESS - parse and return
if (response.ok) {
if (text && text.trim()) {
try {
return JSON.parse(text);
} catch {
return text;
}
}
return null;
}
// === 401 UNAUTHORIZED - Try token refresh ===
if (response.status === 401) {
// Try to refresh the token
const newToken = await refreshAccessToken();
if (newToken) {
// Retry original request with new token
const retryHeaders = {
...headers,
'Authorization': `Bearer ${newToken}`,
};
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: retryHeaders,
credentials: 'include',
...options,
});
const retryText = await retryResponse.text();
if (retryResponse.ok) {
if (retryText && retryText.trim()) {
try {
return JSON.parse(retryText);
} catch {
return retryText;
}
}
return null;
}
// Retry also failed - throw the retry error
const retryError: any = new Error(retryResponse.statusText);
retryError.status = retryResponse.status;
try {
const retryErrorData = JSON.parse(retryText);
retryError.message = retryErrorData.error || retryErrorData.message || retryResponse.statusText;
retryError.data = retryErrorData;
} catch {
retryError.message = retryText.substring(0, 200) || retryResponse.statusText;
}
throw retryError;
}
// Refresh failed - throw 401 error but DON'T logout here
// Logout already happened in refreshAccessToken if needed
const err: any = new Error('Authentication required');
err.status = 401;
err.data = text ? JSON.parse(text).catch(() => ({})) : {};
throw err;
}
// === 403 FORBIDDEN - NEVER logout ===
// This could be permission error, plan restriction, or entitlement issue
// NOT an authentication failure
if (response.status === 403) {
let errorData: any = {};
try {
errorData = JSON.parse(text);
} catch {}
const errorMessage = errorData?.detail || errorData?.message || errorData?.error || response.statusText;
const err: any = new Error(errorMessage);
err.status = 403;
err.data = errorData;
throw err;
}
// === 402 PAYMENT REQUIRED - NEVER logout ===
// This is a payment/plan issue, not an auth issue
if (response.status === 402) {
let errorData: any = {};
try {
errorData = JSON.parse(text);
} catch {}
const errorMessage = errorData?.error || errorData?.message || response.statusText;
const err: any = new Error(errorMessage);
err.status = 402;
err.data = errorData;
throw err;
}
// === ALL OTHER ERRORS - NEVER logout ===
// Parse error response
let errorMessage = response.statusText;
let errorData: any = null;
try {
if (contentType.includes('application/json')) {
errorData = JSON.parse(text);
errorMessage = errorData.error || errorData.message || errorData.detail || errorMessage;
} else if (text.includes('<!DOCTYPE html>')) {
// HTML error page - extract title
const titleMatch = text.match(/<title>([^<]+) at ([^<]+)<\/title>/);
if (titleMatch) {
errorMessage = `${titleMatch[1].trim()} at ${titleMatch[2].trim()}`;
}
} else {
errorMessage = text.substring(0, 200);
}
} catch {}
console.error('API Error:', {
status: response.status,
message: errorMessage,
endpoint,
errorData,
});
const err: any = new Error(errorMessage);
err.status = response.status;
err.data = errorData;
throw err;
} catch (error: any) {
clearTimeout(timeoutId);
// Network/timeout error - NEVER logout
if (error.name === 'AbortError') {
const err: any = new Error(`Request timeout after ${timeout}ms`);
err.status = 408;
err.isTimeout = true;
throw err;
}
// Re-throw error (it already has status code if it's an HTTP error)
throw error;
}
}
// Export helper for backward compatibility
export { API_BASE_URL as default };

View File

@@ -1,256 +0,0 @@
/**
* Logout Tracking Service
* Captures and logs every logout event with detailed context
*/
interface LogoutReason {
type: 'USER_ACTION' | 'TOKEN_EXPIRED' | 'REFRESH_FAILED' | 'AUTH_ERROR' | 'UNKNOWN';
message: string;
timestamp: number;
context?: any;
}
class LogoutTracker {
private static instance: LogoutTracker;
private lastActivity: number = Date.now();
private activityCheckInterval: any = null;
private logoutHistory: LogoutReason[] = [];
private constructor() {
this.startActivityMonitoring();
this.loadLogoutHistory();
}
static getInstance(): LogoutTracker {
if (!LogoutTracker.instance) {
LogoutTracker.instance = new LogoutTracker();
}
return LogoutTracker.instance;
}
private startActivityMonitoring() {
// Track user activity
const updateActivity = () => {
this.lastActivity = Date.now();
};
window.addEventListener('mousemove', updateActivity);
window.addEventListener('keydown', updateActivity);
window.addEventListener('click', updateActivity);
window.addEventListener('scroll', updateActivity);
// Check activity every 30 seconds
this.activityCheckInterval = setInterval(() => {
const idleTime = Date.now() - this.lastActivity;
const idleMinutes = Math.floor(idleTime / 60000);
if (idleMinutes > 0) {
console.log(`[LOGOUT-TRACKER] User idle for ${idleMinutes} minutes`);
}
}, 30000);
}
private loadLogoutHistory() {
try {
const stored = localStorage.getItem('logout_history');
if (stored) {
this.logoutHistory = JSON.parse(stored);
}
} catch (e) {
console.error('[LOGOUT-TRACKER] Failed to load history:', e);
}
}
private saveLogoutHistory() {
try {
// Keep only last 10 logouts
const recent = this.logoutHistory.slice(-10);
localStorage.setItem('logout_history', JSON.stringify(recent));
} catch (e) {
console.error('[LOGOUT-TRACKER] Failed to save history:', e);
}
}
/**
* Track a logout event with full context
*/
trackLogout(reason: LogoutReason) {
const idleTime = Date.now() - this.lastActivity;
const idleMinutes = Math.floor(idleTime / 60000);
const fullReason = {
...reason,
timestamp: Date.now(),
idleMinutes,
location: window.location.href,
context: {
...reason.context,
userAgent: navigator.userAgent,
screenResolution: `${window.screen.width}x${window.screen.height}`,
}
};
// Add to history
this.logoutHistory.push(fullReason);
this.saveLogoutHistory();
// Log to console with big warning
console.group('%c🚨 LOGOUT DETECTED', 'color: red; font-size: 20px; font-weight: bold;');
console.log('%cLogout Type:', 'font-weight: bold;', reason.type);
console.log('%cReason:', 'font-weight: bold;', reason.message);
console.log('%cIdle Time:', 'font-weight: bold;', `${idleMinutes} minutes`);
console.log('%cTimestamp:', 'font-weight: bold;', new Date().toISOString());
console.log('%cLocation:', 'font-weight: bold;', window.location.href);
console.log('%cContext:', 'font-weight: bold;', fullReason.context);
console.groupEnd();
// Show alert to user (only if not on signin page)
if (!window.location.pathname.includes('signin')) {
this.showLogoutAlert(reason);
}
// Send to backend for server-side logging
this.sendToBackend(fullReason);
// Store in sessionStorage for signin page to display
sessionStorage.setItem('last_logout_reason', JSON.stringify(fullReason));
}
/**
* Show a big alert to user before redirecting
*/
private showLogoutAlert(reason: LogoutReason) {
// Create overlay
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
`;
// Create alert box
const alertBox = document.createElement('div');
alertBox.style.cssText = `
background: white;
padding: 30px;
border-radius: 10px;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
`;
const idleMinutes = Math.floor((Date.now() - this.lastActivity) / 60000);
alertBox.innerHTML = `
<div style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 20px;">🚨</div>
<h2 style="color: #e53e3e; margin-bottom: 15px;">Session Expired</h2>
<p style="color: #4a5568; margin-bottom: 10px; font-size: 16px;">
<strong>Reason:</strong> ${reason.message}
</p>
<p style="color: #718096; margin-bottom: 20px; font-size: 14px;">
Idle time: ${idleMinutes} minutes<br>
Type: ${reason.type}
</p>
<button id="close-logout-alert" style="
background: #3182ce;
color: white;
padding: 10px 30px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
">Go to Sign In</button>
</div>
`;
overlay.appendChild(alertBox);
document.body.appendChild(overlay);
// Auto-remove after 5 seconds
setTimeout(() => {
overlay.remove();
}, 5000);
// Manual close
const closeBtn = document.getElementById('close-logout-alert');
if (closeBtn) {
closeBtn.onclick = () => {
overlay.remove();
};
}
}
/**
* Send logout event to backend for server-side logging
*/
private async sendToBackend(reason: LogoutReason) {
try {
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
await fetch(`${API_BASE_URL}/v1/auth/logout-event/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(reason),
credentials: 'include',
});
} catch (e) {
// Silently fail - logging shouldn't break logout
console.error('[LOGOUT-TRACKER] Failed to send to backend:', e);
}
}
/**
* Get last logout reason (for display on signin page)
*/
getLastLogoutReason(): LogoutReason | null {
try {
const stored = sessionStorage.getItem('last_logout_reason');
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.error('[LOGOUT-TRACKER] Failed to get last logout reason:', e);
}
return null;
}
/**
* Clear last logout reason (call after displaying on signin page)
*/
clearLastLogoutReason() {
sessionStorage.removeItem('last_logout_reason');
}
/**
* Get logout history
*/
getLogoutHistory(): LogoutReason[] {
return [...this.logoutHistory];
}
}
export const logoutTracker = LogoutTracker.getInstance();
/**
* Wrap logout function to track reason
*/
export function trackLogout(
reason: string,
type: LogoutReason['type'] = 'UNKNOWN',
context?: any
) {
logoutTracker.trackLogout({
type,
message: reason,
timestamp: Date.now(),
context,
});
}

View File

@@ -1,209 +0,0 @@
/**
* Token Expiry Monitor
* Monitors JWT token expiry and logs warnings before logout
*/
interface TokenPayload {
exp: number;
iat: number;
user_id: number;
email: string;
type: string;
}
function decodeJWT(token: string): TokenPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1]));
return payload;
} catch (e) {
return null;
}
}
export class TokenExpiryMonitor {
private checkInterval: any = null;
private lastWarning: number = 0;
start() {
// Check token expiry every 30 seconds
this.checkInterval = setInterval(() => {
this.checkTokenExpiry();
}, 30000);
// Initial check
this.checkTokenExpiry();
}
stop() {
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
}
private checkTokenExpiry() {
try {
// Get token from Zustand store
const authStorage = localStorage.getItem('auth-storage');
if (!authStorage) return;
const parsed = JSON.parse(authStorage);
const token = parsed?.state?.token;
const refreshToken = parsed?.state?.refreshToken;
if (!token) {
console.warn('[TOKEN-MONITOR] No access token found');
return;
}
// Decode token
const payload = decodeJWT(token);
if (!payload || !payload.exp) {
console.warn('[TOKEN-MONITOR] Invalid token format');
return;
}
const now = Math.floor(Date.now() / 1000);
const expiresIn = payload.exp - now;
const minutesUntilExpiry = Math.floor(expiresIn / 60);
// Log token status every check
console.log(
`[TOKEN-MONITOR] Access token expires in ${minutesUntilExpiry} minutes ` +
`(${expiresIn} seconds)`
);
// Warn if token is expiring soon (< 5 minutes)
if (expiresIn < 300 && expiresIn > 0) {
const now = Date.now();
// Only warn once per minute
if (now - this.lastWarning > 60000) {
this.lastWarning = now;
console.group('%c⚠ TOKEN EXPIRING SOON', 'color: orange; font-size: 16px; font-weight: bold;');
console.log(`Expires in: ${minutesUntilExpiry} minutes (${expiresIn} seconds)`);
console.log('Refresh should happen automatically on next API call');
console.log('Has refresh token:', !!refreshToken);
console.groupEnd();
}
}
// Critical warning if already expired
if (expiresIn <= 0) {
console.group('%c🚨 TOKEN EXPIRED', 'color: red; font-size: 18px; font-weight: bold;');
console.log(`Expired ${Math.abs(minutesUntilExpiry)} minutes ago`);
console.log('Next API call will trigger refresh');
console.log('Has refresh token:', !!refreshToken);
console.groupEnd();
}
// Check refresh token expiry too
if (refreshToken) {
const refreshPayload = decodeJWT(refreshToken);
if (refreshPayload && refreshPayload.exp) {
const refreshExpiresIn = refreshPayload.exp - now;
const refreshMinutes = Math.floor(refreshExpiresIn / 60);
const refreshHours = Math.floor(refreshMinutes / 60);
const refreshDays = Math.floor(refreshHours / 24);
console.log(
`[TOKEN-MONITOR] Refresh token expires in ${refreshDays}d ${refreshHours % 24}h ${refreshMinutes % 60}m`
);
// Warn if refresh token is expiring soon (< 1 day)
if (refreshExpiresIn < 86400 && refreshExpiresIn > 0) {
console.warn(
`[TOKEN-MONITOR] ⚠️ Refresh token expires in ${refreshHours} hours! ` +
`User will be logged out when it expires.`
);
}
// Critical if refresh token expired
if (refreshExpiresIn <= 0) {
console.error(
`[TOKEN-MONITOR] 🚨 REFRESH TOKEN EXPIRED! ` +
`User will be logged out on next refresh attempt.`
);
}
}
}
} catch (e) {
console.error('[TOKEN-MONITOR] Error checking token expiry:', e);
}
}
/**
* Get detailed token status for debugging
*/
getTokenStatus() {
try {
const authStorage = localStorage.getItem('auth-storage');
if (!authStorage) {
return {
hasToken: false,
hasRefreshToken: false,
accessTokenExpired: true,
refreshTokenExpired: true,
};
}
const parsed = JSON.parse(authStorage);
const token = parsed?.state?.token;
const refreshToken = parsed?.state?.refreshToken;
const now = Math.floor(Date.now() / 1000);
let accessTokenExpired = true;
let accessExpiresIn = 0;
if (token) {
const payload = decodeJWT(token);
if (payload && payload.exp) {
accessExpiresIn = payload.exp - now;
accessTokenExpired = accessExpiresIn <= 0;
}
}
let refreshTokenExpired = true;
let refreshExpiresIn = 0;
if (refreshToken) {
const payload = decodeJWT(refreshToken);
if (payload && payload.exp) {
refreshExpiresIn = payload.exp - now;
refreshTokenExpired = refreshExpiresIn <= 0;
}
}
return {
hasToken: !!token,
hasRefreshToken: !!refreshToken,
accessTokenExpired,
refreshTokenExpired,
accessExpiresInMinutes: Math.floor(accessExpiresIn / 60),
refreshExpiresInHours: Math.floor(refreshExpiresIn / 3600),
};
} catch (e) {
console.error('[TOKEN-MONITOR] Error getting token status:', e);
return null;
}
}
}
// Global instance
export const tokenExpiryMonitor = new TokenExpiryMonitor();
// Auto-start monitoring when module loads
if (typeof window !== 'undefined') {
tokenExpiryMonitor.start();
// Expose to window for debugging
(window as any).__tokenMonitor = tokenExpiryMonitor;
console.log(
'%c📊 Token Expiry Monitor Started',
'color: green; font-weight: bold;',
'\nUse window.__tokenMonitor.getTokenStatus() to check token status'
);
}

View File

@@ -5,7 +5,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { fetchAPI } from '../services/api';
import { trackLogout } from '../services/logoutTracker';
type AuthErrorCode = 'ACCOUNT_REQUIRED' | 'PLAN_REQUIRED' | 'AUTH_FAILED';
@@ -38,8 +37,8 @@ interface AuthState {
loading: boolean;
// Actions
login: (email: string, password: string, rememberMe?: boolean) => Promise<void>;
logout: (reason?: string, type?: 'USER_ACTION' | 'TOKEN_EXPIRED' | 'REFRESH_FAILED' | 'AUTH_ERROR' | 'UNKNOWN') => void;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
register: (data: any) => Promise<void>;
setUser: (user: User | null) => void;
setToken: (token: string | null) => void;
@@ -55,7 +54,7 @@ export const useAuthStore = create<AuthState>()(
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) => {
set({ loading: true });
try {
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
@@ -64,12 +63,7 @@ export const useAuthStore = create<AuthState>()(
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password,
remember_me: rememberMe,
device_id: localStorage.getItem('device_id') || crypto.randomUUID()
}),
body: JSON.stringify({ email, password }),
});
const data = await response.json();
@@ -149,17 +143,7 @@ export const useAuthStore = create<AuthState>()(
}
},
logout: (reason = 'User clicked logout', type: 'USER_ACTION' | 'TOKEN_EXPIRED' | 'REFRESH_FAILED' | 'AUTH_ERROR' | 'UNKNOWN' = 'USER_ACTION') => {
// Track logout with detailed context
const currentState = get();
trackLogout(reason, type, {
hasToken: !!currentState.token,
hasRefreshToken: !!currentState.refreshToken,
isAuthenticated: currentState.isAuthenticated,
userId: currentState.user?.id,
userEmail: currentState.user?.email,
});
logout: () => {
// CRITICAL: Properly clear ALL cookies to prevent session contamination
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
@@ -183,12 +167,8 @@ export const useAuthStore = create<AuthState>()(
}
});
// Clear sessionStorage (except logout tracking)
const logoutReason = sessionStorage.getItem('last_logout_reason');
// Clear sessionStorage
sessionStorage.clear();
if (logoutReason) {
sessionStorage.setItem('last_logout_reason', logoutReason);
}
// Reset auth state to initial values
set({

View File

@@ -1,215 +0,0 @@
# AUTHENTICATION SYSTEM OVERHAUL
Designed specifically for **Django 5.2 + DRF + Redis + JWT + SPA frontend** and your requirement that **“Remember me = 20 days, zero random logouts”**.
---
# MASTER SYSTEM PROMPT
## Objective: Eliminate Random Logouts and Guarantee 20-Day Persistent Login
### Context
You are working inside a production Django 5.2 backend with DRF, Redis, JWT authentication, Celery, Stripe, and a SPA frontend.
The system currently suffers from random user logouts caused by auth state desynchronization.
Your task is to **stabilize authentication without breaking any existing functionality**.
You must not introduce behavioral regressions.
---
## CORE REQUIREMENTS (NON-NEGOTIABLE)
1. If a user selects “Remember me” at login, they must remain logged in for **20 full days**, including idle time.
2. Users must **never be logged out due to**:
* Network failures
* Permission errors
* Plan or account state changes
* Temporary backend errors
* Multi-tab usage
3. Logout may occur **only** when:
* The user explicitly logs out
* The refresh token expires
* The refresh token is revoked (password change or admin action)
---
## PRIMARY DESIGN PRINCIPLES
* Authentication authority must be **single-source**.
* Refresh token validity is the only indicator of login state.
* Access tokens are disposable and replaceable.
* The frontend must never decide to log out based on generic API failures.
* Authentication and authorization must be treated as separate concerns.
---
## AUTHENTICATION ARCHITECTURE YOU MUST ENFORCE
### Token Model
* Access token: short-lived, used only for API authorization
* Refresh token: long-lived, stored server-side, revocable, authoritative
* Refresh token lifetime:
* 20 days if “Remember me” is selected
* Short duration otherwise
### Storage
* Refresh token must be stored:
* Server-side (database or Redis)
* As an HttpOnly cookie on the client
* Access token must:
* Be stored only in memory
* Never be persisted to localStorage or cookies
---
## BACKEND RULES (CRITICAL)
### Session Handling
* Redis must be used for Django sessions
* Django sessions must NOT be used as SPA auth authority
* Sessions may remain for Django Admin only
### Refresh Token Rules
* Refresh tokens must be:
* Uniquely identifiable
* Revocable
* Rotated on every successful refresh
* Refresh operations must be atomic
* Old refresh tokens must be invalidated only after a new one is safely issued
### Logout Rules
The backend must NEVER:
* Call logout inside middleware
* Invalidate sessions during request processing
* Log users out due to validation or entitlement changes
The backend MAY:
* Return structured error codes
* Block access with 403 or domain-specific errors
* Revoke refresh tokens only via explicit actions
---
## FRONTEND RULES (ABSOLUTE)
### Logout Triggers
The frontend may log out ONLY when:
* The refresh endpoint returns a hard authentication failure
* The backend explicitly signals session revocation
* The user clicks logout
The frontend must NEVER log out due to:
* 403 errors
* 402 errors
* Network failures
* Timeouts
* 5xx errors
* Permission or plan restrictions
### Token Refresh Behavior
* Only one refresh operation may occur at a time
* All concurrent API calls must wait for the same refresh promise
* Refresh failures due to network issues must retry
* Only an explicit refresh authentication failure may trigger logout
### Multi-Tab Coordination
* Token refresh must be coordinated across tabs
* When one tab refreshes successfully, all tabs must update state
* Multiple tabs must never overwrite or invalidate each others tokens
---
## COOKIE AND SECURITY CONSTRAINTS
* Refresh token cookie:
* HttpOnly = true
* Secure = true (production)
* SameSite = Lax
* Path limited to refresh endpoint
* Max-Age reflects 20-day remember-me duration
* Access tokens must never be placed in cookies
---
## ERROR HANDLING AND CLASSIFICATION
You must enforce strict error classification:
* Authentication failure: invalid or expired refresh token
* Authorization failure: permissions, plan, entitlement
* Transport failure: network, timeout, 5xx
Only authentication failure may cause logout.
---
## ROLLOUT STRATEGY (MUST FOLLOW ORDER)
1. Add observability for refresh success, failure, retries, and logout reasons
2. Remove frontend logout on non-auth errors
3. Implement refresh deduplication
4. Remove backend middleware logout behavior
5. Introduce refresh token persistence and rotation
6. Implement remember-me expiry logic
7. Add multi-tab coordination
8. Validate stability before tightening security
Each step must be backward-compatible.
---
## SUCCESS CRITERIA
The system is considered successful only if:
* A remembered user remains logged in for 20 days without activity
* Multi-tab usage never causes logout
* Network disruptions do not affect login state
* Access tokens may expire repeatedly without user impact
* Auth and entitlement failures are fully decoupled
* Logout events are deliberate and explainable
---
## FAILURE CONDITIONS (DO NOT SHIP IF ANY OCCUR)
* Any logout triggered by 403 or 5xx
* Any middleware-initiated logout
* Any auth state stored in more than one authoritative place
* Any refresh race condition
* Any token overwrite across tabs
---
## FINAL DIRECTIVE
Your priority is **stability over cleverness**.
If any optimization increases complexity or fragility, it must be rejected.
The system must behave predictably, quietly, and invisibly to the user.
No random logouts. No surprises. No exceptions.
---

View File

@@ -1,141 +0,0 @@
#!/bin/bash
# Quick deployment script for authentication fixes
# Run from /data/app/igny8 directory
set -e # Exit on error
echo "🚀 Starting authentication fix deployment..."
echo ""
# Check we're in the right directory
if [ ! -f "backend/manage.py" ]; then
echo "❌ Error: Must run from /data/app/igny8 directory"
exit 1
fi
# Step 1: Install backend dependencies
echo "📦 Step 1/7: Installing backend dependencies..."
cd backend
pip install django-redis>=5.4.0 --quiet
cd ..
echo "✅ Dependencies installed"
echo ""
# Step 2: Create and run migrations
echo "🗄️ Step 2/7: Running database migrations..."
cd backend
python manage.py makemigrations --noinput
python manage.py migrate --noinput
cd ..
echo "✅ Migrations completed"
echo ""
# Step 3: Verify Redis
echo "🔍 Step 3/7: Verifying Redis connection..."
if docker exec redis redis-cli ping > /dev/null 2>&1; then
echo "✅ Redis is running"
else
echo "⚠️ Warning: Redis not responding. Starting Redis..."
cd /data/app
docker-compose -f docker-compose.yml up -d redis
sleep 3
cd /data/app/igny8
fi
echo ""
# Step 4: Backup and replace api.ts
echo "📝 Step 4/7: Updating frontend API service..."
cd frontend/src/services
if [ -f "api.ts" ]; then
cp api.ts "api-old-$(date +%Y%m%d-%H%M%S).ts"
echo " Backed up old api.ts"
fi
if [ -f "api-new.ts" ]; then
mv api-new.ts api.ts
echo "✅ API service updated"
else
echo "⚠️ Warning: api-new.ts not found, skipping"
fi
cd ../../..
echo ""
# Step 5: Restart backend
echo "🔄 Step 5/7: Restarting backend service..."
docker-compose -f docker-compose.app.yml restart igny8_backend
echo " Waiting for backend to be healthy..."
sleep 5
if docker-compose -f docker-compose.app.yml ps igny8_backend | grep -q "Up"; then
echo "✅ Backend restarted successfully"
else
echo "⚠️ Warning: Backend may not be healthy. Check: docker-compose -f docker-compose.app.yml ps"
fi
echo ""
# Step 6: Restart frontend
echo "🔄 Step 6/7: Restarting frontend service..."
docker-compose -f docker-compose.app.yml restart igny8_frontend
echo " Waiting for frontend to start..."
sleep 3
if docker-compose -f docker-compose.app.yml ps igny8_frontend | grep -q "Up"; then
echo "✅ Frontend restarted successfully"
else
echo "⚠️ Warning: Frontend may not be healthy. Check: docker-compose -f docker-compose.app.yml ps"
fi
echo ""
# Step 7: Quick health check
echo "🏥 Step 7/7: Running health checks..."
echo ""
echo "Backend health:"
if curl -sf http://localhost:8011/api/v1/system/status/ > /dev/null 2>&1; then
echo " ✅ Backend API responding"
else
echo " ⚠️ Backend API not responding at http://localhost:8011"
fi
echo ""
echo "Frontend health:"
if curl -sf http://localhost:8021 > /dev/null 2>&1; then
echo " ✅ Frontend responding"
else
echo " ⚠️ Frontend not responding at http://localhost:8021"
fi
echo ""
echo "Redis health:"
REDIS_KEYS=$(docker exec redis redis-cli DBSIZE 2>/dev/null | grep -o '[0-9]*' || echo "0")
echo " ✅ Redis has $REDIS_KEYS keys"
echo ""
echo "Database check:"
cd backend
TOKEN_COUNT=$(python manage.py shell -c "from igny8_core.auth.models_refresh_token import RefreshToken; print(RefreshToken.objects.count())" 2>/dev/null || echo "0")
echo " ✅ RefreshToken model working ($TOKEN_COUNT tokens in DB)"
cd ..
echo ""
echo "========================================="
echo "🎉 Deployment completed!"
echo "========================================="
echo ""
echo "Next steps:"
echo "1. Test login with remember-me checkbox"
echo "2. Verify no logout on 403 errors"
echo "3. Test multi-tab behavior"
echo "4. Monitor logs for issues"
echo ""
echo "📊 View logs:"
echo " Backend: docker logs igny8_backend -f"
echo " Frontend: docker logs igny8_frontend -f"
echo ""
echo "📖 Documentation:"
echo " - AUTHENTICATION-FIX-IMPLEMENTATION.md"
echo " - AUTHENTICATION-FIX-DEPLOYMENT.md"
echo " - AUTHENTICATION-AUDIT-REPORT.md"
echo ""
echo "⚠️ IMPORTANT: Complete remaining manual steps from AUTHENTICATION-FIX-DEPLOYMENT.md:"
echo " - Update authStore.ts to remove localStorage.setItem('access_token')"
echo " - Add remember-me checkbox to login form"
echo " - Test all scenarios before marking as complete"
echo ""

View File

@@ -1,99 +0,0 @@
#!/bin/bash
# Deploy Logout Debugging System
# Run this script to deploy all backend and frontend changes
set -e # Exit on error
echo "======================================"
echo "Deploying Logout Debugging System"
echo "======================================"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if we're in the right directory
if [ ! -d "backend" ] || [ ! -d "frontend" ]; then
echo -e "${RED}Error: Must run from project root directory${NC}"
exit 1
fi
# Backend deployment
echo -e "\n${GREEN}[1/4] Deploying Backend Changes...${NC}"
cd backend
echo "Running migrations..."
python manage.py migrate
echo "Checking for migration errors..."
python manage.py check
echo -e "${GREEN}✓ Backend migrations complete${NC}"
# Frontend build
echo -e "\n${GREEN}[2/4] Building Frontend...${NC}"
cd ../frontend
echo "Installing dependencies..."
npm install --silent
echo "Building production bundle..."
npm run build
echo -e "${GREEN}✓ Frontend build complete${NC}"
# Restart services
echo -e "\n${GREEN}[3/4] Restarting Services...${NC}"
if command -v docker-compose &> /dev/null; then
echo "Restarting Docker containers..."
docker-compose restart backend frontend
echo -e "${GREEN}✓ Docker services restarted${NC}"
elif systemctl is-active --quiet gunicorn; then
echo "Restarting systemd services..."
sudo systemctl restart gunicorn
sudo systemctl restart celery
echo -e "${GREEN}✓ Systemd services restarted${NC}"
else
echo -e "${YELLOW}Warning: Could not detect service manager${NC}"
echo "Please restart your backend and frontend services manually"
fi
# Verification
echo -e "\n${GREEN}[4/4] Verifying Deployment...${NC}"
echo "Checking backend API..."
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
if curl -s "${BACKEND_URL}/v1/auth/logout-event/" -X POST -H "Content-Type: application/json" -d '{}' > /dev/null; then
echo -e "${GREEN}✓ Backend logout-event endpoint responding${NC}"
else
echo -e "${YELLOW}⚠ Backend endpoint check failed (may need authentication)${NC}"
fi
echo "Checking frontend build..."
if [ -f "frontend/dist/index.html" ]; then
echo -e "${GREEN}✓ Frontend dist/index.html exists${NC}"
else
echo -e "${RED}✗ Frontend build may have failed${NC}"
fi
# Final instructions
echo -e "\n${GREEN}======================================"
echo "✓ Deployment Complete!"
echo "======================================${NC}"
echo -e "\n${YELLOW}Next Steps:${NC}"
echo "1. Open browser DevTools (F12)"
echo "2. Go to Console tab"
echo "3. Look for: '[TokenMonitor] Starting token expiry monitoring...'"
echo "4. Login with 'Remember me' checked"
echo "5. Wait 25+ minutes to capture logout event"
echo ""
echo "Debug Panel: Press Ctrl+Shift+D to open"
echo "Full Guide: See LOGOUT-DEBUGGING-DEPLOYMENT.md"
echo ""
echo -e "${YELLOW}Monitor console logs every 30 seconds for token status${NC}"
exit 0