REFACTOR API gateway fix 3

This commit is contained in:
Urtzi Alfaro
2025-07-26 20:04:24 +02:00
parent b0629c5971
commit 6176d5c4d8
7 changed files with 861 additions and 224 deletions

View File

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

View File

@@ -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}")