# services/auth/app/core/security.py - FIXED VERSION """ Security utilities for authentication service FIXED VERSION - Consistent password hashing using passlib """ 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 passlib.context import CryptContext from app.core.config import settings from shared.auth.jwt_handler import JWTHandler logger = structlog.get_logger() # ✅ FIX: Use passlib for consistent password hashing pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # 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 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 hash_password(password: str) -> str: """Hash password using passlib bcrypt - FIXED""" return pwd_context.hash(password) @staticmethod def verify_password(password: str, hashed_password: str) -> bool: """Verify password against hash using passlib - FIXED""" try: return pwd_context.verify(password, hashed_password) except Exception as e: logger.error(f"Password verification error: {e}") return False @staticmethod def create_access_token(user_data: Dict[str, Any]) -> str: """ Create JWT ACCESS token with proper payload structure ✅ FIXED: Only creates access tokens """ # Validate required fields for access token if "user_id" not in user_data: raise ValueError("user_id required for access token creation") if "email" not in user_data: raise ValueError("email required for access token creation") try: expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) # ✅ FIX 1: ACCESS TOKEN payload structure payload = { "sub": user_data["user_id"], "user_id": user_data["user_id"], "email": user_data["email"], "type": "access", # ✅ EXPLICITLY set as access token "exp": expire, "iat": datetime.now(timezone.utc), "iss": "bakery-auth" } # Add optional fields for access tokens if "full_name" in user_data: payload["full_name"] = user_data["full_name"] if "is_verified" in user_data: payload["is_verified"] = user_data["is_verified"] if "is_active" in user_data: payload["is_active"] = user_data["is_active"] logger.debug(f"Creating access token with payload keys: {list(payload.keys())}") # ✅ FIX 2: Use JWT handler to create access token token = jwt_handler.create_access_token_from_payload(payload) logger.debug(f"Access token created successfully for user {user_data['email']}") return token except Exception as e: logger.error(f"Access token creation failed for {user_data.get('email', 'unknown')}: {e}") raise ValueError(f"Failed to create access token: {str(e)}") @staticmethod def create_refresh_token(user_data: Dict[str, Any]) -> str: """ Create JWT REFRESH token with minimal payload structure ✅ FIXED: Only creates refresh tokens, different from access tokens """ # Validate required fields for refresh token if "user_id" not in user_data: raise ValueError("user_id required for refresh token creation") if not user_data.get("user_id"): raise ValueError("user_id cannot be empty") try: expire = datetime.now(timezone.utc) + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS) # ✅ FIX 3: REFRESH TOKEN payload structure (minimal, different from access) payload = { "sub": user_data["user_id"], "user_id": user_data["user_id"], "type": "refresh", # ✅ EXPLICITLY set as refresh token "exp": expire, "iat": datetime.now(timezone.utc), "iss": "bakery-auth" } # Add unique JTI for refresh tokens to prevent duplicates if "jti" in user_data: payload["jti"] = user_data["jti"] else: import uuid payload["jti"] = str(uuid.uuid4()) # Include email only if available (optional for refresh tokens) if "email" in user_data and user_data["email"]: payload["email"] = user_data["email"] logger.debug(f"Creating refresh token with payload keys: {list(payload.keys())}") # ✅ FIX 4: Use JWT handler to create REFRESH token (not access token!) token = jwt_handler.create_refresh_token_from_payload(payload) logger.debug(f"Refresh token created successfully for user {user_data['user_id']}") return token except Exception as e: logger.error(f"Refresh token creation failed for {user_data.get('user_id', 'unknown')}: {e}") raise ValueError(f"Failed to create refresh token: {str(e)}") @staticmethod def verify_token(token: str) -> Optional[Dict[str, Any]]: """Verify JWT token with enhanced error handling""" try: payload = jwt_handler.verify_token(token) if payload: logger.debug(f"Token verified successfully for user: {payload.get('email', 'unknown')}") return payload except Exception as e: logger.warning(f"Token verification failed: {e}") return None @staticmethod def decode_token(token: str) -> Dict[str, Any]: """Decode JWT token without verification (for refresh token handling)""" try: payload = jwt_handler.decode_token_no_verify(token) return payload except Exception as e: logger.error(f"Token decoding failed: {e}") raise ValueError("Invalid token format") @staticmethod def generate_secure_hash(data: str) -> str: """Generate secure hash for token storage""" return hashlib.sha256(data.encode()).hexdigest() @staticmethod async def track_login_attempt(email: str, ip_address: str, success: bool) -> None: """Track login attempts for security monitoring""" try: # This would use Redis for production # For now, just log the attempt logger.info(f"Login attempt tracked: email={email}, ip={ip_address}, success={success}") except Exception as e: logger.warning(f"Failed to track login attempt: {e}") @staticmethod def is_token_expired(token: str) -> bool: """Check if token is expired""" try: payload = SecurityManager.decode_token(token) exp_timestamp = payload.get("exp") if exp_timestamp: exp_datetime = datetime.fromtimestamp(exp_timestamp, tz=timezone.utc) return datetime.now(timezone.utc) > exp_datetime return True except Exception: return True @staticmethod def verify_token(token: str) -> Optional[Dict[str, Any]]: """Verify JWT token with enhanced error handling""" try: payload = jwt_handler.verify_token(token) if payload: logger.debug(f"Token verified successfully for user: {payload.get('email', 'unknown')}") return payload except Exception as e: logger.warning(f"Token verification 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}")