REFACTOR API gateway fix 3
This commit is contained in:
149
check.sh
Executable file
149
check.sh
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
# Environment Variable Diagnostic Script
|
||||
# This script helps identify where environment variables are getting lost
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Environment Variable Diagnostic Script"
|
||||
echo "=========================================="
|
||||
|
||||
# Check if .env file exists and contains JWT settings
|
||||
echo "📋 Step 1: Checking .env file..."
|
||||
if [ -f ".env" ]; then
|
||||
echo "✅ .env file exists"
|
||||
if grep -q "JWT_SECRET_KEY" .env; then
|
||||
JWT_VALUE=$(grep "JWT_SECRET_KEY" .env | cut -d'=' -f2-)
|
||||
echo "✅ JWT_SECRET_KEY found in .env: ${JWT_VALUE:0:30}..."
|
||||
else
|
||||
echo "❌ JWT_SECRET_KEY not found in .env"
|
||||
fi
|
||||
else
|
||||
echo "❌ .env file does not exist"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Check docker-compose.yml configuration
|
||||
echo "📋 Step 2: Checking docker-compose.yml..."
|
||||
if grep -A 20 "auth-service:" docker-compose.yml | grep -q "JWT_SECRET_KEY"; then
|
||||
echo "✅ auth-service has JWT_SECRET_KEY in docker-compose.yml"
|
||||
else
|
||||
echo "❌ auth-service missing JWT_SECRET_KEY in docker-compose.yml"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Check if services are running
|
||||
echo "📋 Step 3: Checking running services..."
|
||||
AUTH_RUNNING=$(docker-compose ps auth-service | grep -c "Up" || echo "0")
|
||||
GATEWAY_RUNNING=$(docker-compose ps gateway | grep -c "Up" || echo "0")
|
||||
|
||||
echo "Auth Service Running: $AUTH_RUNNING"
|
||||
echo "Gateway Running: $GATEWAY_RUNNING"
|
||||
|
||||
echo ""
|
||||
|
||||
# Check environment at container startup
|
||||
echo "📋 Step 4: Checking container environment at startup..."
|
||||
echo "Starting fresh auth-service container..."
|
||||
|
||||
# Stop and start auth service
|
||||
docker-compose stop auth-service
|
||||
sleep 2
|
||||
|
||||
# Start auth service and immediately check environment
|
||||
echo "Starting auth-service..."
|
||||
docker-compose up -d auth-service
|
||||
|
||||
# Wait a moment for startup
|
||||
sleep 3
|
||||
|
||||
echo "Checking environment immediately after startup..."
|
||||
STARTUP_ENV=$(docker-compose exec -T auth-service env | grep JWT_SECRET_KEY || echo "NOT_SET")
|
||||
echo "Startup Environment: $STARTUP_ENV"
|
||||
|
||||
# Wait a bit more and check again
|
||||
sleep 5
|
||||
echo "Checking environment after 5 seconds..."
|
||||
AFTER_STARTUP=$(docker-compose exec -T auth-service env | grep JWT_SECRET_KEY || echo "NOT_SET")
|
||||
echo "After Startup: $AFTER_STARTUP"
|
||||
|
||||
echo ""
|
||||
|
||||
# Check if the application is overriding environment variables
|
||||
echo "📋 Step 5: Checking application configuration loading..."
|
||||
|
||||
# Create a temporary script to check Python environment loading
|
||||
cat > /tmp/check_env.py << 'EOF'
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
print("=== Python Environment Check ===")
|
||||
print(f"JWT_SECRET_KEY from os.getenv: {os.getenv('JWT_SECRET_KEY', 'NOT_SET')}")
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
print(f"JWT_SECRET_KEY from settings: {getattr(settings, 'JWT_SECRET_KEY', 'NOT_SET')}")
|
||||
|
||||
# Check if settings inherits from base
|
||||
try:
|
||||
from shared.config.base import BaseServiceSettings
|
||||
base_settings = BaseServiceSettings()
|
||||
print(f"JWT_SECRET_KEY from base settings: {getattr(base_settings, 'JWT_SECRET_KEY', 'NOT_SET')}")
|
||||
except Exception as e:
|
||||
print(f"Error loading base settings: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading auth settings: {e}")
|
||||
|
||||
print("=== Raw Environment Variables ===")
|
||||
for key, value in sorted(os.environ.items()):
|
||||
if 'JWT' in key or 'SECRET' in key:
|
||||
print(f"{key}={value[:30]}..." if len(value) > 30 else f"{key}={value}")
|
||||
EOF
|
||||
|
||||
# Copy the script into the container and run it
|
||||
docker cp /tmp/check_env.py "$(docker-compose ps -q auth-service):/tmp/check_env.py"
|
||||
echo "Running Python environment check..."
|
||||
docker-compose exec -T auth-service python /tmp/check_env.py
|
||||
|
||||
echo ""
|
||||
|
||||
# Check if there are any startup scripts or entrypoints modifying environment
|
||||
echo "📋 Step 6: Checking container startup process..."
|
||||
echo "Container entrypoint:"
|
||||
docker-compose exec -T auth-service cat /usr/local/bin/docker-entrypoint.sh 2>/dev/null || echo "No entrypoint script found"
|
||||
|
||||
echo ""
|
||||
echo "Docker image environment:"
|
||||
docker-compose exec -T auth-service printenv | grep JWT || echo "No JWT variables in container environment"
|
||||
|
||||
echo ""
|
||||
|
||||
# Check application logs for any environment variable issues
|
||||
echo "📋 Step 7: Checking application logs for environment issues..."
|
||||
echo "Recent auth-service logs:"
|
||||
docker-compose logs --tail=20 auth-service | grep -i -E "(jwt|secret|env|config)" || echo "No relevant logs found"
|
||||
|
||||
echo ""
|
||||
|
||||
# Final diagnosis
|
||||
echo "📋 Step 8: Diagnosis Summary"
|
||||
echo "=============================="
|
||||
|
||||
if [ "$STARTUP_ENV" = "NOT_SET" ]; then
|
||||
echo "❌ ISSUE: JWT_SECRET_KEY is not being passed to the container"
|
||||
echo "🔧 SOLUTION: Fix docker-compose.yml environment variables"
|
||||
elif [ "$STARTUP_ENV" != "NOT_SET" ] && [ "$AFTER_STARTUP" = "NOT_SET" ]; then
|
||||
echo "❌ ISSUE: JWT_SECRET_KEY is present at startup but disappears"
|
||||
echo "🔧 SOLUTION: Check application configuration loading"
|
||||
else
|
||||
echo "✅ Environment variables appear to be stable"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Diagnostic complete!"
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/check_env.py
|
||||
@@ -1,6 +1,7 @@
|
||||
# gateway/app/middleware/auth.py
|
||||
"""
|
||||
Enhanced Authentication Middleware for API Gateway with Tenant Access Control
|
||||
FIXED VERSION - Proper JWT verification and token structure handling
|
||||
"""
|
||||
|
||||
import structlog
|
||||
@@ -9,6 +10,8 @@ from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
from typing import Optional, Dict, Any
|
||||
import httpx
|
||||
import json
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.auth.jwt_handler import JWTHandler
|
||||
@@ -16,7 +19,7 @@ from shared.auth.tenant_access import tenant_access_manager, extract_tenant_id_f
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# JWT handler for local token validation
|
||||
# JWT handler for local token validation - using SAME configuration as auth service
|
||||
jwt_handler = JWTHandler(settings.JWT_SECRET_KEY, settings.JWT_ALGORITHM)
|
||||
|
||||
# Routes that don't require authentication
|
||||
@@ -63,13 +66,12 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
|
||||
# ✅ STEP 2: Verify token and get user context
|
||||
# Pass self.redis_client to _verify_token to enable caching
|
||||
user_context = await self._verify_token(token)
|
||||
if not user_context:
|
||||
logger.warning(f"Invalid token for route: {request.url.path}")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Invalid or expired token"}
|
||||
content={"detail": "User not authenticated"}
|
||||
)
|
||||
|
||||
# ✅ STEP 3: Extract tenant context from URL using shared utility
|
||||
@@ -78,11 +80,10 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
# ✅ STEP 4: Verify tenant access if this is a tenant-scoped route
|
||||
if tenant_id and is_tenant_scoped_path(request.url.path):
|
||||
# Use TenantAccessManager for gateway-level verification with caching
|
||||
# Ensure tenant_access_manager uses the redis_client from the middleware
|
||||
if self.redis_client and tenant_access_manager.redis_client is None:
|
||||
tenant_access_manager.redis_client = self.redis_client
|
||||
|
||||
has_access = await tenant_access_manager.verify_basic_tenant_access( # Corrected method call
|
||||
has_access = await tenant_access_manager.verify_basic_tenant_access(
|
||||
user_context["user_id"],
|
||||
tenant_id
|
||||
)
|
||||
@@ -129,18 +130,22 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
return None
|
||||
|
||||
async def _verify_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify JWT token with fallback strategy"""
|
||||
"""
|
||||
Verify JWT token with improved fallback strategy
|
||||
FIXED: Better error handling and token structure validation
|
||||
"""
|
||||
|
||||
# Try local JWT validation first (fast)
|
||||
# Strategy 1: Try local JWT validation first (fast)
|
||||
try:
|
||||
payload = jwt_handler.verify_token(token)
|
||||
if payload and self._validate_token_payload(payload):
|
||||
logger.debug("Token validated locally")
|
||||
return payload
|
||||
# Convert JWT payload to user context format
|
||||
return self._jwt_payload_to_user_context(payload)
|
||||
except Exception as e:
|
||||
logger.debug(f"Local token validation failed: {e}")
|
||||
|
||||
# Check cache for recently validated tokens
|
||||
# Strategy 2: Check cache for recently validated tokens
|
||||
if self.redis_client:
|
||||
try:
|
||||
cached_user = await self._get_cached_user(token)
|
||||
@@ -150,7 +155,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache lookup failed: {e}")
|
||||
|
||||
# Verify with auth service (authoritative)
|
||||
# Strategy 3: Verify with auth service (authoritative)
|
||||
try:
|
||||
user_context = await self._verify_with_auth_service(token)
|
||||
if user_context:
|
||||
@@ -165,92 +170,134 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
return None
|
||||
|
||||
def _validate_token_payload(self, payload: Dict[str, Any]) -> bool:
|
||||
"""Validate JWT payload has required fields"""
|
||||
required_fields = ["user_id", "email", "exp"]
|
||||
return all(field in payload for field in required_fields)
|
||||
"""
|
||||
Validate JWT payload has required fields
|
||||
FIXED: Updated to match actual token structure from auth service
|
||||
"""
|
||||
required_fields = ["user_id", "email", "exp", "type"]
|
||||
missing_fields = [field for field in required_fields if field not in payload]
|
||||
|
||||
if missing_fields:
|
||||
logger.warning(f"Token payload missing fields: {missing_fields}")
|
||||
return False
|
||||
|
||||
# Validate token type
|
||||
if payload.get("type") != "access":
|
||||
logger.warning(f"Invalid token type: {payload.get('type')}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _jwt_payload_to_user_context(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert JWT payload to user context format
|
||||
FIXED: Proper mapping between JWT structure and user context
|
||||
"""
|
||||
return {
|
||||
"user_id": payload["user_id"],
|
||||
"email": payload["email"],
|
||||
"exp": payload["exp"],
|
||||
"valid": True
|
||||
}
|
||||
|
||||
async def _verify_with_auth_service(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify token with auth service"""
|
||||
"""
|
||||
Verify token with auth service
|
||||
FIXED: Improved error handling and response parsing
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.AUTH_SERVICE_URL}/api/v1/auth/verify",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
auth_response = response.json()
|
||||
|
||||
# Validate auth service response structure
|
||||
if auth_response.get("valid") and auth_response.get("user_id"):
|
||||
return {
|
||||
"user_id": auth_response["user_id"],
|
||||
"email": auth_response["email"],
|
||||
"exp": auth_response.get("exp"),
|
||||
"valid": True
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Auth service returned invalid response: {auth_response}")
|
||||
return None
|
||||
else:
|
||||
logger.warning(f"Auth service returned {response.status_code}")
|
||||
logger.warning(f"Auth service returned {response.status_code}: {response.text}")
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Auth service timeout during token verification")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Auth service error: {e}")
|
||||
return None
|
||||
|
||||
async def _get_cached_user(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get user context from cache"""
|
||||
"""
|
||||
Get user context from cache
|
||||
FIXED: Better error handling and JSON parsing
|
||||
"""
|
||||
if not self.redis_client:
|
||||
return None
|
||||
|
||||
cache_key = f"auth:token:{hash(token)}"
|
||||
cache_key = f"auth:token:{hash(token) % 1000000}" # Use modulo for shorter keys
|
||||
try:
|
||||
cached_data = await self.redis_client.get(cache_key)
|
||||
if cached_data:
|
||||
import json
|
||||
if isinstance(cached_data, bytes):
|
||||
cached_data = cached_data.decode()
|
||||
return json.loads(cached_data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse cached user data: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache get failed: {e}")
|
||||
logger.warning(f"Cache lookup error: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def _cache_user(self, token: str, user_context: Dict[str, Any], ttl: int = 300):
|
||||
"""Cache user context for 5 minutes"""
|
||||
async def _cache_user(self, token: str, user_context: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Cache user context
|
||||
FIXED: Better error handling and expiration
|
||||
"""
|
||||
if not self.redis_client:
|
||||
return
|
||||
|
||||
cache_key = f"auth:token:{hash(token)}"
|
||||
cache_key = f"auth:token:{hash(token) % 1000000}"
|
||||
try:
|
||||
import json
|
||||
await self.redis_client.setex(cache_key, ttl, json.dumps(user_context))
|
||||
# Cache for 5 minutes (shorter than token expiry)
|
||||
await self.redis_client.setex(
|
||||
cache_key,
|
||||
300, # 5 minutes
|
||||
json.dumps(user_context)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache set failed: {e}")
|
||||
logger.warning(f"Failed to cache user context: {e}")
|
||||
|
||||
def _inject_context_headers(self, request: Request, user_context: Dict[str, Any], tenant_id: Optional[str]):
|
||||
"""Inject authentication and tenant headers for downstream services"""
|
||||
def _inject_context_headers(self, request: Request, user_context: Dict[str, Any], tenant_id: Optional[str] = None):
|
||||
"""
|
||||
Inject user and tenant context headers for downstream services
|
||||
FIXED: Proper header injection
|
||||
"""
|
||||
# Add user context headers
|
||||
request.headers.__dict__["_list"].append((
|
||||
b"x-user-id", user_context["user_id"].encode()
|
||||
))
|
||||
request.headers.__dict__["_list"].append((
|
||||
b"x-user-email", user_context["email"].encode()
|
||||
))
|
||||
|
||||
# Remove any existing auth headers to prevent spoofing
|
||||
headers_to_remove = [
|
||||
"x-user-id", "x-user-email", "x-user-role",
|
||||
"x-tenant-id", "x-tenant-verified", "x-authenticated"
|
||||
]
|
||||
|
||||
for header in headers_to_remove:
|
||||
request.headers.__dict__["_list"] = [
|
||||
(k, v) for k, v in request.headers.raw
|
||||
if k.lower() != header.lower()
|
||||
]
|
||||
|
||||
# Inject new headers
|
||||
new_headers = [
|
||||
(b"x-authenticated", b"true"),
|
||||
(b"x-user-id", str(user_context.get("user_id", "")).encode()),
|
||||
(b"x-user-email", str(user_context.get("email", "")).encode()),
|
||||
(b"x-user-role", str(user_context.get("role", "user")).encode()),
|
||||
]
|
||||
|
||||
# Add tenant context if verified
|
||||
# Add tenant context if available
|
||||
if tenant_id:
|
||||
new_headers.extend([
|
||||
(b"x-tenant-id", tenant_id.encode()),
|
||||
(b"x-tenant-verified", b"true")
|
||||
])
|
||||
|
||||
# Add headers to request
|
||||
request.headers.__dict__["_list"].extend(new_headers)
|
||||
|
||||
logger.debug(f"Injected context headers",
|
||||
user_id=user_context.get("user_id"),
|
||||
tenant_id=tenant_id)
|
||||
request.headers.__dict__["_list"].append((
|
||||
b"x-tenant-id", tenant_id.encode()
|
||||
))
|
||||
|
||||
# Add gateway identification
|
||||
request.headers.__dict__["_list"].append((
|
||||
b"x-forwarded-by", b"bakery-gateway"
|
||||
))
|
||||
230
jwt_debug_script.sh
Executable file
230
jwt_debug_script.sh
Executable file
@@ -0,0 +1,230 @@
|
||||
#!/bin/bash
|
||||
# JWT Debug and Verification Script
|
||||
# This script helps debug JWT token issues between gateway and auth service
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
API_BASE="http://localhost:8000"
|
||||
AUTH_BASE="http://localhost:8001"
|
||||
EMAIL="test@bakery.com"
|
||||
PASSWORD="TestPassword123!"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
echo -e "${BLUE}🔄 $1${NC}"
|
||||
}
|
||||
|
||||
echo "🔍 JWT Token Debug and Verification Script"
|
||||
echo "==========================================="
|
||||
|
||||
# Step 1: Check JWT Configuration
|
||||
log_step "Step 1: Checking JWT Configuration Consistency"
|
||||
|
||||
log_info "Checking Gateway JWT Settings..."
|
||||
GATEWAY_JWT_SECRET=$(docker-compose exec -T gateway env 2>/dev/null | grep JWT_SECRET_KEY || echo "NOT_SET")
|
||||
GATEWAY_JWT_ALGO=$(docker-compose exec -T gateway env 2>/dev/null | grep JWT_ALGORITHM || echo "NOT_SET")
|
||||
|
||||
log_info "Checking Auth Service JWT Settings..."
|
||||
AUTH_JWT_SECRET=$(docker-compose exec -T auth-service env 2>/dev/null | grep JWT_SECRET_KEY || echo "NOT_SET")
|
||||
AUTH_JWT_ALGO=$(docker-compose exec -T auth-service env 2>/dev/null | grep JWT_ALGORITHM || echo "NOT_SET")
|
||||
|
||||
echo "Gateway JWT Secret: $GATEWAY_JWT_SECRET"
|
||||
echo "Auth Service JWT Secret: $AUTH_JWT_SECRET"
|
||||
echo "Gateway JWT Algorithm: $GATEWAY_JWT_ALGO"
|
||||
echo "Auth Service JWT Algorithm: $AUTH_JWT_ALGO"
|
||||
|
||||
if [ "$GATEWAY_JWT_SECRET" = "$AUTH_JWT_SECRET" ]; then
|
||||
log_success "JWT Secret Keys match!"
|
||||
else
|
||||
log_error "JWT Secret Keys DO NOT match!"
|
||||
log_warning "This is likely the cause of the authentication issue"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 2: Test Direct Auth Service
|
||||
log_step "Step 2: Testing Direct Auth Service"
|
||||
|
||||
# Login directly to auth service
|
||||
log_info "Logging in directly to auth service..."
|
||||
AUTH_LOGIN_RESPONSE=$(curl -s -X POST "$AUTH_BASE/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"email\": \"$EMAIL\",
|
||||
\"password\": \"$PASSWORD\"
|
||||
}")
|
||||
|
||||
echo "Direct Auth Login Response:"
|
||||
echo "$AUTH_LOGIN_RESPONSE" | jq '.' 2>/dev/null || echo "$AUTH_LOGIN_RESPONSE"
|
||||
|
||||
# Extract token
|
||||
DIRECT_TOKEN=$(echo "$AUTH_LOGIN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
||||
|
||||
if [ "$DIRECT_TOKEN" != "null" ] && [ "$DIRECT_TOKEN" != "" ]; then
|
||||
log_success "Direct auth login successful!"
|
||||
echo "Token: ${DIRECT_TOKEN:0:50}..."
|
||||
|
||||
# Test direct auth verification
|
||||
log_info "Testing direct auth token verification..."
|
||||
AUTH_VERIFY_RESPONSE=$(curl -s -X POST "$AUTH_BASE/api/v1/auth/verify" \
|
||||
-H "Authorization: Bearer $DIRECT_TOKEN")
|
||||
|
||||
echo "Direct Auth Verify Response:"
|
||||
echo "$AUTH_VERIFY_RESPONSE" | jq '.' 2>/dev/null || echo "$AUTH_VERIFY_RESPONSE"
|
||||
|
||||
# Test direct auth /users/me
|
||||
log_info "Testing direct auth /users/me endpoint..."
|
||||
AUTH_ME_RESPONSE=$(curl -s -X GET "$AUTH_BASE/api/v1/users/me" \
|
||||
-H "Authorization: Bearer $DIRECT_TOKEN")
|
||||
|
||||
echo "Direct Auth /users/me Response:"
|
||||
echo "$AUTH_ME_RESPONSE" | jq '.' 2>/dev/null || echo "$AUTH_ME_RESPONSE"
|
||||
|
||||
else
|
||||
log_error "Direct auth login failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 3: Test Gateway Login
|
||||
log_step "Step 3: Testing Gateway Login"
|
||||
|
||||
GATEWAY_LOGIN_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"email\": \"$EMAIL\",
|
||||
\"password\": \"$PASSWORD\"
|
||||
}")
|
||||
|
||||
echo "Gateway Login Response:"
|
||||
echo "$GATEWAY_LOGIN_RESPONSE" | jq '.' 2>/dev/null || echo "$GATEWAY_LOGIN_RESPONSE"
|
||||
|
||||
GATEWAY_TOKEN=$(echo "$GATEWAY_LOGIN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
||||
|
||||
if [ "$GATEWAY_TOKEN" != "null" ] && [ "$GATEWAY_TOKEN" != "" ]; then
|
||||
log_success "Gateway login successful!"
|
||||
echo "Token: ${GATEWAY_TOKEN:0:50}..."
|
||||
else
|
||||
log_error "Gateway login failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 4: Compare Tokens
|
||||
log_step "Step 4: Comparing Tokens"
|
||||
|
||||
if [ "$DIRECT_TOKEN" = "$GATEWAY_TOKEN" ]; then
|
||||
log_success "Tokens are identical (expected)"
|
||||
else
|
||||
log_warning "Tokens are different (unexpected if same login)"
|
||||
fi
|
||||
|
||||
# Decode tokens for comparison
|
||||
log_info "Decoding direct auth token payload..."
|
||||
DIRECT_PAYLOAD=$(echo "$DIRECT_TOKEN" | cut -d'.' -f2)
|
||||
# Add padding if needed
|
||||
while [ $((${#DIRECT_PAYLOAD} % 4)) -ne 0 ]; do
|
||||
DIRECT_PAYLOAD="${DIRECT_PAYLOAD}="
|
||||
done
|
||||
echo "$DIRECT_PAYLOAD" | base64 -d 2>/dev/null | jq '.' || echo "Failed to decode"
|
||||
|
||||
log_info "Decoding gateway token payload..."
|
||||
GATEWAY_PAYLOAD=$(echo "$GATEWAY_TOKEN" | cut -d'.' -f2)
|
||||
# Add padding if needed
|
||||
while [ $((${#GATEWAY_PAYLOAD} % 4)) -ne 0 ]; do
|
||||
GATEWAY_PAYLOAD="${GATEWAY_PAYLOAD}="
|
||||
done
|
||||
echo "$GATEWAY_PAYLOAD" | base64 -d 2>/dev/null | jq '.' || echo "Failed to decode"
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 5: Test Gateway Authentication
|
||||
log_step "Step 5: Testing Gateway Authentication Middleware"
|
||||
|
||||
# Test gateway token verification
|
||||
log_info "Testing gateway token verification..."
|
||||
GATEWAY_VERIFY_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/verify" \
|
||||
-H "Authorization: Bearer $GATEWAY_TOKEN")
|
||||
|
||||
echo "Gateway Verify Response:"
|
||||
echo "$GATEWAY_VERIFY_RESPONSE" | jq '.' 2>/dev/null || echo "$GATEWAY_VERIFY_RESPONSE"
|
||||
|
||||
# Test gateway /users/me (this is where the issue occurs)
|
||||
log_info "Testing gateway /users/me endpoint (THE FAILING ENDPOINT)..."
|
||||
GATEWAY_ME_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}\n" -X GET "$API_BASE/api/v1/users/me" \
|
||||
-H "Authorization: Bearer $GATEWAY_TOKEN")
|
||||
|
||||
echo "Gateway /users/me Response:"
|
||||
echo "$GATEWAY_ME_RESPONSE"
|
||||
|
||||
# Check if successful
|
||||
if echo "$GATEWAY_ME_RESPONSE" | grep -q "HTTP_CODE:200"; then
|
||||
log_success "Gateway /users/me endpoint working!"
|
||||
elif echo "$GATEWAY_ME_RESPONSE" | grep -q "HTTP_CODE:401"; then
|
||||
log_error "Gateway /users/me endpoint returning 401 Unauthorized"
|
||||
log_warning "This confirms the JWT middleware issue"
|
||||
else
|
||||
log_warning "Gateway /users/me endpoint returned unexpected response"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 6: Test with verbose curl to see middleware behavior
|
||||
log_step "Step 6: Verbose Gateway Request Analysis"
|
||||
|
||||
log_info "Making verbose request to gateway /users/me..."
|
||||
curl -v -X GET "$API_BASE/api/v1/users/me" \
|
||||
-H "Authorization: Bearer $GATEWAY_TOKEN" 2>&1 | head -20
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 7: Recommendations
|
||||
log_step "Step 7: Recommendations"
|
||||
|
||||
if [ "$GATEWAY_JWT_SECRET" != "$AUTH_JWT_SECRET" ]; then
|
||||
log_error "CRITICAL: JWT secrets don't match between services"
|
||||
echo "Fix: Update your .env file and restart services:"
|
||||
echo "export JWT_SECRET_KEY='your-super-secret-jwt-key-change-in-production-min-32-characters-long'"
|
||||
echo "docker-compose down && docker-compose up -d"
|
||||
elif echo "$GATEWAY_ME_RESPONSE" | grep -q "HTTP_CODE:401"; then
|
||||
log_warning "JWT secrets match but gateway middleware is still failing"
|
||||
echo "Possible causes:"
|
||||
echo "1. Gateway middleware token validation logic issue"
|
||||
echo "2. Token payload structure mismatch"
|
||||
echo "3. Gateway not using updated shared JWT handler"
|
||||
echo ""
|
||||
echo "Recommended fixes:"
|
||||
echo "1. Apply the fixed gateway auth middleware"
|
||||
echo "2. Apply the fixed shared JWT handler"
|
||||
echo "3. Restart gateway service: docker-compose restart gateway"
|
||||
else
|
||||
log_success "All tests passed! JWT authentication is working correctly."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Debug script completed!"
|
||||
@@ -1,36 +1,89 @@
|
||||
# services/auth/app/api/auth.py - UPDATED TO RETURN TOKENS FROM REGISTRATION
|
||||
# services/auth/app/api/auth.py - Fixed Login Method
|
||||
"""
|
||||
Authentication API routes - Updated to return tokens directly from registration
|
||||
Following industry best practices with unified token response format
|
||||
Authentication API endpoints - FIXED VERSION
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.auth import (
|
||||
UserRegistration, UserLogin, TokenResponse,
|
||||
RefreshTokenRequest, UserResponse, PasswordChange,
|
||||
PasswordReset, TokenVerification
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
from app.core.security import SecurityManager
|
||||
from app.services.auth_service import AuthService
|
||||
from app.schemas.auth import PasswordReset, UserRegistration, UserLogin, TokenResponse, RefreshTokenRequest, PasswordChange
|
||||
from shared.monitoring.decorators import track_execution_time
|
||||
from shared.monitoring.metrics import get_metrics_collector
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(tags=["authentication"])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
def get_metrics_collector(request: Request):
|
||||
"""Get metrics collector from app state"""
|
||||
return getattr(request.app.state, 'metrics_collector', None)
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
# ================================================================
|
||||
# AUTHENTICATION ENDPOINTS
|
||||
# ================================================================
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@track_execution_time("login_duration_seconds", "auth-service")
|
||||
async def login(
|
||||
login_data: UserLogin,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Login user and return tokens
|
||||
FIXED: Proper error handling and login attempt tracking
|
||||
"""
|
||||
metrics = get_metrics_collector(request)
|
||||
|
||||
try:
|
||||
# Check if account is locked due to too many failed attempts
|
||||
can_attempt = await SecurityManager.check_login_attempts(login_data.email)
|
||||
if not can_attempt:
|
||||
if metrics:
|
||||
metrics.increment_counter("login_failure_total", labels={"reason": "rate_limited"})
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many login attempts. Please try again in {SecurityManager.settings.LOCKOUT_DURATION_MINUTES} minutes."
|
||||
)
|
||||
|
||||
# Attempt login through AuthService
|
||||
result = await AuthService.login(login_data.email, login_data.password, db)
|
||||
|
||||
# Clear login attempts on successful login
|
||||
await SecurityManager.clear_login_attempts(login_data.email)
|
||||
|
||||
# Record successful login
|
||||
if metrics:
|
||||
metrics.increment_counter("login_success_total")
|
||||
|
||||
logger.info(f"Login successful for {login_data.email}")
|
||||
return TokenResponse(**result)
|
||||
|
||||
except HTTPException as e:
|
||||
# Don't increment attempts for rate limiting errors (already handled above)
|
||||
if e.status_code != status.HTTP_429_TOO_MANY_REQUESTS:
|
||||
# Increment login attempts on authentication failure
|
||||
await SecurityManager.increment_login_attempts(login_data.email)
|
||||
|
||||
# Record failed login
|
||||
if metrics:
|
||||
reason = "rate_limited" if e.status_code == 429 else "auth_failed"
|
||||
metrics.increment_counter("login_failure_total", labels={"reason": reason})
|
||||
|
||||
logger.warning(f"Login failed for {login_data.email}: {e.detail}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
# Increment login attempts on any other error
|
||||
await SecurityManager.increment_login_attempts(login_data.email)
|
||||
|
||||
# Record login error
|
||||
if metrics:
|
||||
metrics.increment_counter("login_failure_total", labels={"reason": "error"})
|
||||
|
||||
logger.error(f"Login error for {login_data.email}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Login failed"
|
||||
)
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
@track_execution_time("registration_duration_seconds", "auth-service")
|
||||
@@ -39,31 +92,17 @@ async def register(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Register a new user and return tokens directly"""
|
||||
"""Register new user"""
|
||||
metrics = get_metrics_collector(request)
|
||||
|
||||
try:
|
||||
# Validate password strength
|
||||
if not SecurityManager.validate_password(user_data.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password does not meet security requirements"
|
||||
)
|
||||
|
||||
# Create user and generate tokens in one operation
|
||||
result = await AuthService.register_user_with_tokens(
|
||||
email=user_data.email,
|
||||
password=user_data.password,
|
||||
full_name=user_data.full_name,
|
||||
db=db
|
||||
)
|
||||
result = await AuthService.register(user_data, db)
|
||||
|
||||
# Record successful registration
|
||||
if metrics:
|
||||
metrics.increment_counter("registration_total", labels={"status": "success"})
|
||||
|
||||
logger.info(f"User registration with tokens successful: {user_data.email}")
|
||||
|
||||
logger.info(f"User registered successfully: {user_data.email}")
|
||||
return TokenResponse(**result)
|
||||
|
||||
except HTTPException as e:
|
||||
@@ -80,58 +119,6 @@ async def register(
|
||||
detail="Registration failed"
|
||||
)
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@track_execution_time("login_duration_seconds", "auth-service")
|
||||
async def login(
|
||||
login_data: UserLogin,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Login user and return tokens"""
|
||||
metrics = get_metrics_collector(request)
|
||||
|
||||
try:
|
||||
# Check login attempts (rate limiting)
|
||||
#attempts = await SecurityManager.get_login_attempts(login_data.email)
|
||||
#if attempts >= 5:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
# detail="Too many login attempts. Please try again later."
|
||||
# )
|
||||
|
||||
# Attempt login
|
||||
result = await AuthService.login(login_data.email, login_data.password, db)
|
||||
|
||||
# Clear login attempts on success
|
||||
await SecurityManager.clear_login_attempts(login_data.email)
|
||||
|
||||
# Record successful login
|
||||
if metrics:
|
||||
metrics.increment_counter("login_success_total")
|
||||
|
||||
logger.info(f"Login successful for {login_data.email}")
|
||||
return TokenResponse(**result)
|
||||
|
||||
except HTTPException as e:
|
||||
# Increment login attempts on failure
|
||||
await SecurityManager.increment_login_attempts(login_data.email)
|
||||
|
||||
# Record failed login
|
||||
if metrics:
|
||||
metrics.increment_counter("login_failure_total", labels={"reason": "auth_failed"})
|
||||
logger.warning(f"Login failed for {login_data.email}: {e.detail}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
# Record login error
|
||||
if metrics:
|
||||
metrics.increment_counter("login_failure_total", labels={"reason": "error"})
|
||||
logger.error(f"Login error for {login_data.email}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Login failed"
|
||||
)
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
@track_execution_time("token_refresh_duration_seconds", "auth-service")
|
||||
async def refresh_token(
|
||||
@@ -149,7 +136,6 @@ async def refresh_token(
|
||||
if metrics:
|
||||
metrics.increment_counter("token_refresh_success_total")
|
||||
|
||||
logger.info("Token refresh successful")
|
||||
return TokenResponse(**result)
|
||||
|
||||
except HTTPException as e:
|
||||
@@ -157,7 +143,6 @@ async def refresh_token(
|
||||
metrics.increment_counter("token_refresh_failure_total")
|
||||
logger.warning(f"Token refresh failed: {e.detail}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
if metrics:
|
||||
metrics.increment_counter("token_refresh_failure_total")
|
||||
@@ -167,37 +152,40 @@ async def refresh_token(
|
||||
detail="Token refresh failed"
|
||||
)
|
||||
|
||||
@router.post("/verify", response_model=TokenVerification)
|
||||
@track_execution_time("token_verification_duration_seconds", "auth-service")
|
||||
@router.post("/verify")
|
||||
@track_execution_time("token_verify_duration_seconds", "auth-service")
|
||||
async def verify_token(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
request: Request = None
|
||||
):
|
||||
"""Verify access token"""
|
||||
"""Verify access token and return user info"""
|
||||
metrics = get_metrics_collector(request) if request else None
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authorization header required"
|
||||
)
|
||||
|
||||
try:
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required"
|
||||
)
|
||||
|
||||
result = await AuthService.verify_user_token(credentials.credentials)
|
||||
|
||||
# Record successful verification
|
||||
if metrics:
|
||||
metrics.increment_counter("token_verify_success_total")
|
||||
|
||||
return TokenVerification(
|
||||
valid=True,
|
||||
user_id=result.get("user_id"),
|
||||
email=result.get("email"),
|
||||
exp=result.get("exp")
|
||||
)
|
||||
return {
|
||||
"valid": True,
|
||||
"user_id": result.get("user_id"),
|
||||
"email": result.get("email"),
|
||||
"exp": result.get("exp"),
|
||||
"message": None
|
||||
}
|
||||
|
||||
except HTTPException as e:
|
||||
if metrics:
|
||||
metrics.increment_counter("token_verify_failure_total")
|
||||
logger.warning(f"Token verification failed: {e.detail}")
|
||||
raise
|
||||
except Exception as e:
|
||||
if metrics:
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# ================================================================
|
||||
# services/auth/app/core/security.py (COMPLETE VERSION)
|
||||
# ================================================================
|
||||
# services/auth/app/core/security.py
|
||||
"""
|
||||
Security utilities for authentication service
|
||||
FIXED VERSION - Consistent JWT token structure
|
||||
"""
|
||||
|
||||
import bcrypt
|
||||
import re
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
import redis.asyncio as redis
|
||||
from fastapi import HTTPException, status
|
||||
@@ -19,14 +18,14 @@ from shared.auth.jwt_handler import JWTHandler
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Initialize JWT handler
|
||||
# Initialize JWT handler with SAME configuration as gateway
|
||||
jwt_handler = JWTHandler(settings.JWT_SECRET_KEY, settings.JWT_ALGORITHM)
|
||||
|
||||
# Redis client for session management
|
||||
redis_client = redis.from_url(settings.REDIS_URL)
|
||||
|
||||
class SecurityManager:
|
||||
"""Security utilities for authentication"""
|
||||
"""Security utilities for authentication - FIXED VERSION"""
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
@@ -61,25 +60,109 @@ class SecurityManager:
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(user_data: Dict[str, Any]) -> str:
|
||||
"""Create JWT access token"""
|
||||
expires_delta = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt_handler.create_access_token(user_data, expires_delta)
|
||||
"""
|
||||
Create JWT access token with CONSISTENT structure
|
||||
FIXED: Uses shared JWT handler for consistency
|
||||
"""
|
||||
return jwt_handler.create_access_token(
|
||||
user_data=user_data,
|
||||
expires_delta=timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_refresh_token(user_data: Dict[str, Any]) -> str:
|
||||
"""Create JWT refresh token"""
|
||||
expires_delta = timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
return jwt_handler.create_refresh_token(user_data, expires_delta)
|
||||
"""
|
||||
Create JWT refresh token with CONSISTENT structure
|
||||
FIXED: Uses shared JWT handler for consistency
|
||||
"""
|
||||
return jwt_handler.create_refresh_token(
|
||||
user_data=user_data,
|
||||
expires_delta=timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify JWT token"""
|
||||
return jwt_handler.verify_token(token)
|
||||
"""
|
||||
Verify JWT token with CONSISTENT validation
|
||||
FIXED: Uses shared JWT handler for consistency
|
||||
"""
|
||||
try:
|
||||
return jwt_handler.verify_token(token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash token for storage"""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Decode JWT token without verification (for debugging)
|
||||
"""
|
||||
try:
|
||||
return jwt_handler.decode_token_unsafe(token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token decode failed: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def track_login_attempt(email: str, ip_address: str, success: bool) -> None:
|
||||
"""Track login attempts for security monitoring"""
|
||||
try:
|
||||
key = f"login_attempts:{email}:{ip_address}"
|
||||
|
||||
if success:
|
||||
# Clear failed attempts on successful login
|
||||
await redis_client.delete(key)
|
||||
else:
|
||||
# Increment failed attempts
|
||||
attempts = await redis_client.incr(key)
|
||||
if attempts == 1:
|
||||
# Set expiration on first failed attempt
|
||||
await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60)
|
||||
|
||||
if attempts >= settings.MAX_LOGIN_ATTEMPTS:
|
||||
logger.warning(f"Account locked for {email} from {ip_address}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Too many failed login attempts. Try again in {settings.LOCKOUT_DURATION_MINUTES} minutes."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to track login attempt: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def is_account_locked(email: str, ip_address: str) -> bool:
|
||||
"""Check if account is locked due to failed login attempts"""
|
||||
try:
|
||||
key = f"login_attempts:{email}:{ip_address}"
|
||||
attempts = await redis_client.get(key)
|
||||
|
||||
if attempts:
|
||||
attempts = int(attempts)
|
||||
return attempts >= settings.MAX_LOGIN_ATTEMPTS
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check account lock status: {e}")
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def hash_api_key(api_key: str) -> str:
|
||||
"""Hash API key for storage"""
|
||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def generate_secure_token(length: int = 32) -> str:
|
||||
"""Generate secure random token"""
|
||||
import secrets
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
@staticmethod
|
||||
def mask_sensitive_data(data: str, visible_chars: int = 4) -> str:
|
||||
"""Mask sensitive data for logging"""
|
||||
if not data or len(data) <= visible_chars:
|
||||
return "*" * len(data) if data else ""
|
||||
|
||||
return data[:visible_chars] + "*" * (len(data) - visible_chars)
|
||||
|
||||
@staticmethod
|
||||
async def check_login_attempts(email: str) -> bool:
|
||||
@@ -108,10 +191,11 @@ class SecurityManager:
|
||||
|
||||
@staticmethod
|
||||
async def clear_login_attempts(email: str) -> None:
|
||||
"""Clear login attempts for email"""
|
||||
"""Clear login attempts for email after successful login"""
|
||||
try:
|
||||
key = f"login_attempts:{email}"
|
||||
await redis_client.delete(key)
|
||||
logger.debug(f"Cleared login attempts for {email}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing login attempts: {e}")
|
||||
|
||||
@@ -119,37 +203,39 @@ class SecurityManager:
|
||||
async def store_refresh_token(user_id: str, token: str) -> None:
|
||||
"""Store refresh token in Redis"""
|
||||
try:
|
||||
token_hash = SecurityManager.hash_token(token)
|
||||
token_hash = SecurityManager.hash_api_key(token) # Reuse hash method
|
||||
key = f"refresh_token:{user_id}:{token_hash}"
|
||||
|
||||
# Store for the duration of the refresh token
|
||||
# Store with expiration matching JWT refresh token expiry
|
||||
expire_seconds = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
|
||||
await redis_client.setex(key, expire_seconds, "valid")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing refresh token: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def verify_refresh_token(user_id: str, token: str) -> bool:
|
||||
"""Verify refresh token exists in Redis"""
|
||||
async def is_refresh_token_valid(user_id: str, token: str) -> bool:
|
||||
"""Check if refresh token is still valid in Redis"""
|
||||
try:
|
||||
token_hash = SecurityManager.hash_token(token)
|
||||
token_hash = SecurityManager.hash_api_key(token)
|
||||
key = f"refresh_token:{user_id}:{token_hash}"
|
||||
|
||||
result = await redis_client.get(key)
|
||||
return result is not None
|
||||
exists = await redis_client.exists(key)
|
||||
return bool(exists)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying refresh token: {e}")
|
||||
logger.error(f"Error checking refresh token validity: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def revoke_refresh_token(user_id: str, token: str) -> None:
|
||||
"""Revoke refresh token"""
|
||||
"""Revoke refresh token by removing from Redis"""
|
||||
try:
|
||||
token_hash = SecurityManager.hash_token(token)
|
||||
token_hash = SecurityManager.hash_api_key(token)
|
||||
key = f"refresh_token:{user_id}:{token_hash}"
|
||||
|
||||
await redis_client.delete(key)
|
||||
logger.debug(f"Revoked refresh token for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error revoking refresh token: {e}")
|
||||
|
||||
# Create singleton instance
|
||||
security_manager = SecurityManager()
|
||||
logger.error(f"Error revoking refresh token: {e}")
|
||||
@@ -1,6 +1,7 @@
|
||||
# shared/auth/jwt_handler.py - IMPROVED VERSION
|
||||
# shared/auth/jwt_handler.py
|
||||
"""
|
||||
Enhanced JWT Handler with proper token structure
|
||||
Enhanced JWT Handler with proper token structure and validation
|
||||
FIXED VERSION - Consistent token format between all services
|
||||
"""
|
||||
|
||||
from jose import jwt, JWTError
|
||||
@@ -11,7 +12,7 @@ import structlog
|
||||
logger = structlog.get_logger()
|
||||
|
||||
class JWTHandler:
|
||||
"""Enhanced JWT token handling"""
|
||||
"""Enhanced JWT token handling with consistent format"""
|
||||
|
||||
def __init__(self, secret_key: str, algorithm: str = "HS256"):
|
||||
self.secret_key = secret_key
|
||||
@@ -19,16 +20,23 @@ class JWTHandler:
|
||||
|
||||
def create_access_token(self, user_data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Create JWT access token WITHOUT tenant_id
|
||||
Tenant context is determined per request, not stored in token
|
||||
Create JWT access token with STANDARD structure
|
||||
FIXED: Consistent payload format across all services
|
||||
"""
|
||||
to_encode = {
|
||||
"sub": user_data["user_id"], # Standard JWT subject
|
||||
"user_id": user_data["user_id"],
|
||||
"email": user_data["email"],
|
||||
"type": "access"
|
||||
"sub": user_data["user_id"], # Standard JWT subject claim
|
||||
"user_id": user_data["user_id"], # Explicit user ID
|
||||
"email": user_data["email"], # User email
|
||||
"type": "access" # Token type
|
||||
}
|
||||
|
||||
# Add optional fields if present
|
||||
if "full_name" in user_data:
|
||||
to_encode["full_name"] = user_data["full_name"]
|
||||
if "is_verified" in user_data:
|
||||
to_encode["is_verified"] = user_data["is_verified"]
|
||||
|
||||
# Set expiration
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
@@ -44,7 +52,10 @@ class JWTHandler:
|
||||
return encoded_jwt
|
||||
|
||||
def create_refresh_token(self, user_data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create JWT refresh token"""
|
||||
"""
|
||||
Create JWT refresh token with MINIMAL payload
|
||||
FIXED: Consistent refresh token structure
|
||||
"""
|
||||
to_encode = {
|
||||
"sub": user_data["user_id"],
|
||||
"user_id": user_data["user_id"],
|
||||
@@ -62,35 +73,161 @@ class JWTHandler:
|
||||
})
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm)
|
||||
logger.debug(f"Created refresh token for user {user_data['user_id']}")
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify and decode JWT token with comprehensive validation"""
|
||||
"""
|
||||
Verify and decode JWT token with comprehensive validation
|
||||
FIXED: Better error handling and validation
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
# Decode token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.secret_key,
|
||||
algorithms=[self.algorithm],
|
||||
options={"verify_exp": True} # Verify expiration
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ["user_id", "email", "exp", "type"]
|
||||
if not all(field in payload for field in required_fields):
|
||||
logger.warning(f"Token missing required fields: {required_fields}")
|
||||
if not self._validate_payload(payload):
|
||||
logger.warning("Token payload validation failed")
|
||||
return None
|
||||
|
||||
# Validate token type
|
||||
if payload.get("type") not in ["access", "refresh"]:
|
||||
logger.warning(f"Invalid token type: {payload.get('type')}")
|
||||
return None
|
||||
|
||||
# Check expiration (jose handles this, but double-check)
|
||||
# Check if token is expired (additional check)
|
||||
exp = payload.get("exp")
|
||||
if exp and datetime.fromtimestamp(exp, tz=timezone.utc) < datetime.now(timezone.utc):
|
||||
logger.warning("Token has expired")
|
||||
return None
|
||||
|
||||
logger.debug(f"Token verified successfully for user {payload.get('user_id')}")
|
||||
return payload
|
||||
|
||||
except JWTError as e:
|
||||
logger.warning(f"JWT validation failed: {e}")
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token has expired")
|
||||
return None
|
||||
except jwt.JWTClaimsError as e:
|
||||
logger.warning(f"Token claims validation failed: {e}")
|
||||
return None
|
||||
except jwt.JWTError as e:
|
||||
logger.warning(f"Token validation failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error validating token: {e}")
|
||||
return None
|
||||
logger.error(f"Unexpected error during token verification: {e}")
|
||||
return None
|
||||
|
||||
def decode_token_unsafe(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Decode JWT token without verification (for debugging only)
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
options={"verify_signature": False, "verify_exp": False}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decode token: {e}")
|
||||
return None
|
||||
|
||||
def _validate_payload(self, payload: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Validate JWT payload structure
|
||||
FIXED: Comprehensive validation for required fields
|
||||
"""
|
||||
# Check required fields for all tokens
|
||||
required_base_fields = ["sub", "user_id", "type", "exp", "iat"]
|
||||
|
||||
for field in required_base_fields:
|
||||
if field not in payload:
|
||||
logger.warning(f"Missing required field in token: {field}")
|
||||
return False
|
||||
|
||||
# Validate token type
|
||||
token_type = payload.get("type")
|
||||
if token_type not in ["access", "refresh"]:
|
||||
logger.warning(f"Invalid token type: {token_type}")
|
||||
return False
|
||||
|
||||
# Additional validation for access tokens
|
||||
if token_type == "access":
|
||||
if "email" not in payload:
|
||||
logger.warning("Access token missing email field")
|
||||
return False
|
||||
|
||||
# Validate user_id format (should be UUID)
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id or not isinstance(user_id, str):
|
||||
logger.warning("Invalid user_id in token")
|
||||
return False
|
||||
|
||||
# Validate subject matches user_id
|
||||
if payload.get("sub") != user_id:
|
||||
logger.warning("Token subject does not match user_id")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def extract_user_id(self, token: str) -> Optional[str]:
|
||||
"""
|
||||
Extract user ID from token without full verification
|
||||
Useful for quick user identification
|
||||
"""
|
||||
try:
|
||||
payload = self.decode_token_unsafe(token)
|
||||
if payload:
|
||||
return payload.get("user_id")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract user ID from token: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def is_token_expired(self, token: str) -> bool:
|
||||
"""
|
||||
Check if token is expired without full verification
|
||||
"""
|
||||
try:
|
||||
payload = self.decode_token_unsafe(token)
|
||||
if payload and "exp" in payload:
|
||||
exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
||||
return exp < datetime.now(timezone.utc)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check token expiration: {e}")
|
||||
|
||||
return True # Assume expired if we can't check
|
||||
|
||||
def get_token_info(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive token information for debugging
|
||||
"""
|
||||
info = {
|
||||
"valid": False,
|
||||
"expired": True,
|
||||
"user_id": None,
|
||||
"email": None,
|
||||
"type": None,
|
||||
"exp": None,
|
||||
"iat": None
|
||||
}
|
||||
|
||||
try:
|
||||
# Try unsafe decode first
|
||||
payload = self.decode_token_unsafe(token)
|
||||
if payload:
|
||||
info.update({
|
||||
"user_id": payload.get("user_id"),
|
||||
"email": payload.get("email"),
|
||||
"type": payload.get("type"),
|
||||
"exp": payload.get("exp"),
|
||||
"iat": payload.get("iat"),
|
||||
"expired": self.is_token_expired(token)
|
||||
})
|
||||
|
||||
# Try full verification
|
||||
verified_payload = self.verify_token(token)
|
||||
info["valid"] = verified_payload is not None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get token info: {e}")
|
||||
|
||||
return info
|
||||
@@ -56,7 +56,7 @@ fi
|
||||
|
||||
# ✅ NEW: Step 3.6 - Test a Protected Endpoint
|
||||
echo -e "\n3.6. Testing Protected Endpoint (User Profile)..."
|
||||
USER_PROFILE_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/users/me" \
|
||||
USER_PROFILE_RESPONSE=$(curl -v -s -X GET "$API_BASE/api/v1/users/me" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
echo "User Profile Response: $USER_PROFILE_RESPONSE"
|
||||
|
||||
Reference in New Issue
Block a user