REFACTOR API gateway fix 3
This commit is contained in:
@@ -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}")
|
||||
Reference in New Issue
Block a user