Files
bakery-ia/services/auth/app/core/security.py

238 lines
8.8 KiB
Python
Raw Normal View History

2025-07-26 21:48:53 +02:00
# services/auth/app/core/security.py - FIXED VERSION
"""
Security utilities for authentication service
2025-07-26 21:48:53 +02:00
FIXED VERSION - Consistent password hashing using passlib
"""
import re
2025-07-17 21:25:27 +02:00
import hashlib
2025-07-26 20:04:24 +02:00
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import redis.asyncio as redis
from fastapi import HTTPException, status
2025-07-18 14:41:39 +02:00
import structlog
2025-07-26 21:48:53 +02:00
from passlib.context import CryptContext
from app.core.config import settings
from shared.auth.jwt_handler import JWTHandler
2025-07-18 14:41:39 +02:00
logger = structlog.get_logger()
2025-07-26 21:48:53 +02:00
# ✅ FIX: Use passlib for consistent password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
2025-07-26 20:04:24 +02:00
# 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:
2025-07-26 20:04:24 +02:00
"""Security utilities for authentication - FIXED VERSION"""
@staticmethod
def hash_password(password: str) -> str:
2025-07-26 21:48:53 +02:00
"""Hash password using passlib bcrypt - FIXED"""
return pwd_context.hash(password)
@staticmethod
def verify_password(password: str, hashed_password: str) -> bool:
2025-07-26 21:48:53 +02:00
"""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 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
2025-07-26 21:48:53 +02:00
def create_access_token(user_data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": user_data["user_id"],
"user_id": user_data["user_id"],
"email": user_data["email"],
"type": "access",
"full_name": user_data.get("full_name", ""),
"is_verified": user_data.get("is_verified", False),
"exp": expire,
"iat": datetime.now(timezone.utc)
}
return jwt_handler.create_access_token(payload)
@staticmethod
def create_refresh_token(user_data: Dict[str, Any]) -> str:
2025-07-26 21:48:53 +02:00
"""Create JWT refresh token"""
expire = datetime.now(timezone.utc) + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": user_data["user_id"],
"user_id": user_data["user_id"],
"type": "refresh",
"exp": expire,
"iat": datetime.now(timezone.utc)
}
return jwt_handler.create_access_token(payload)
2025-07-26 20:04:24 +02:00
@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)
2025-07-17 21:25:27 +02:00
@staticmethod
2025-07-26 20:04:24 +02:00
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)
2025-07-17 21:25:27 +02:00
@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}")
2025-07-17 21:25:27 +02:00
return True # Allow on error
@staticmethod
2025-07-17 21:25:27 +02:00
async def increment_login_attempts(email: str) -> None:
"""Increment login attempts for email"""
try:
key = f"login_attempts:{email}"
2025-07-17 21:25:27 +02:00
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
2025-07-17 21:25:27 +02:00
async def clear_login_attempts(email: str) -> None:
2025-07-26 20:04:24 +02:00
"""Clear login attempts for email after successful login"""
try:
key = f"login_attempts:{email}"
await redis_client.delete(key)
2025-07-26 20:04:24 +02:00
logger.debug(f"Cleared login attempts for {email}")
except Exception as e:
logger.error(f"Error clearing login attempts: {e}")
@staticmethod
2025-07-17 21:25:27 +02:00
async def store_refresh_token(user_id: str, token: str) -> None:
"""Store refresh token in Redis"""
try:
2025-07-26 20:04:24 +02:00
token_hash = SecurityManager.hash_api_key(token) # Reuse hash method
2025-07-17 21:25:27 +02:00
key = f"refresh_token:{user_id}:{token_hash}"
2025-07-26 20:04:24 +02:00
# Store with expiration matching JWT refresh token expiry
2025-07-17 21:25:27 +02:00
expire_seconds = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
await redis_client.setex(key, expire_seconds, "valid")
2025-07-26 20:04:24 +02:00
except Exception as e:
logger.error(f"Error storing refresh token: {e}")
@staticmethod
2025-07-26 20:04:24 +02:00
async def is_refresh_token_valid(user_id: str, token: str) -> bool:
"""Check if refresh token is still valid in Redis"""
try:
2025-07-26 20:04:24 +02:00
token_hash = SecurityManager.hash_api_key(token)
2025-07-17 21:25:27 +02:00
key = f"refresh_token:{user_id}:{token_hash}"
2025-07-26 20:04:24 +02:00
exists = await redis_client.exists(key)
return bool(exists)
except Exception as e:
2025-07-26 20:04:24 +02:00
logger.error(f"Error checking refresh token validity: {e}")
return False
@staticmethod
2025-07-17 21:25:27 +02:00
async def revoke_refresh_token(user_id: str, token: str) -> None:
2025-07-26 20:04:24 +02:00
"""Revoke refresh token by removing from Redis"""
try:
2025-07-26 20:04:24 +02:00
token_hash = SecurityManager.hash_api_key(token)
2025-07-17 21:25:27 +02:00
key = f"refresh_token:{user_id}:{token_hash}"
2025-07-26 20:04:24 +02:00
await redis_client.delete(key)
2025-07-26 20:04:24 +02:00
logger.debug(f"Revoked refresh token for user {user_id}")
except Exception as e:
2025-07-26 20:04:24 +02:00
logger.error(f"Error revoking refresh token: {e}")