# 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, timezone from typing import Optional, Dict, Any import redis.asyncio as redis from fastapi import HTTPException, status import structlog from app.core.config import settings from shared.auth.jwt_handler import JWTHandler logger = structlog.get_logger() # 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 - FIXED VERSION""" @staticmethod def hash_password(password: str) -> str: """Hash password using bcrypt""" salt = bcrypt.gensalt(rounds=settings.BCRYPT_ROUNDS) return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') @staticmethod def verify_password(password: str, hashed_password: str) -> bool: """Verify password against hash""" return bcrypt.checkpw(password.encode('utf-8'), hashed_password.encode('utf-8')) @staticmethod def validate_password(password: str) -> bool: """Validate password strength""" if len(password) < settings.PASSWORD_MIN_LENGTH: return False if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password): return False if settings.PASSWORD_REQUIRE_LOWERCASE and not re.search(r'[a-z]', password): return False if settings.PASSWORD_REQUIRE_NUMBERS and not re.search(r'\d', password): return False if settings.PASSWORD_REQUIRE_SYMBOLS and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password): return False return True @staticmethod def create_access_token(user_data: Dict[str, Any]) -> str: """ 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 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 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 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: """Check if user has exceeded login attempts""" try: key = f"login_attempts:{email}" attempts = await redis_client.get(key) if attempts is None: return True return int(attempts) < settings.MAX_LOGIN_ATTEMPTS except Exception as e: logger.error(f"Error checking login attempts: {e}") return True # Allow on error @staticmethod async def increment_login_attempts(email: str) -> None: """Increment login attempts for email""" try: key = f"login_attempts:{email}" await redis_client.incr(key) await redis_client.expire(key, settings.LOCKOUT_DURATION_MINUTES * 60) except Exception as e: logger.error(f"Error incrementing login attempts: {e}") @staticmethod async def clear_login_attempts(email: str) -> None: """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}") @staticmethod async def store_refresh_token(user_id: str, token: str) -> None: """Store refresh token in Redis""" try: token_hash = SecurityManager.hash_api_key(token) # Reuse hash method key = f"refresh_token:{user_id}:{token_hash}" # 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 is_refresh_token_valid(user_id: str, token: str) -> bool: """Check if refresh token is still valid in Redis""" try: token_hash = SecurityManager.hash_api_key(token) key = f"refresh_token:{user_id}:{token_hash}" exists = await redis_client.exists(key) return bool(exists) except Exception as 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 by removing from Redis""" try: 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}")