# 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, List from shared.redis_utils import get_redis_client 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) # Note: Redis client is now accessed via get_redis_client() from shared.redis_utils 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 len(password) > 128: # Max length from Pydantic schema 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 get_password_validation_errors(password: str) -> List[str]: """Get detailed password validation errors for better UX""" errors = [] if len(password) < settings.PASSWORD_MIN_LENGTH: errors.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long") if len(password) > 128: errors.append("Password cannot exceed 128 characters") if settings.PASSWORD_REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password): errors.append("Password must contain at least one uppercase letter") if settings.PASSWORD_REQUIRE_LOWERCASE and not re.search(r'[a-z]', password): errors.append("Password must contain at least one lowercase letter") if settings.PASSWORD_REQUIRE_NUMBERS and not re.search(r'\d', password): errors.append("Password must contain at least one number") if settings.PASSWORD_REQUIRE_SYMBOLS and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password): errors.append("Password must contain at least one symbol (!@#$%^&*(),.?\":{}|<>)") return errors @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"] # ✅ CRITICAL FIX: Include role in access token! if "role" in user_data: payload["role"] = user_data["role"] else: payload["role"] = "admin" # Default role if not specified 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}")