2025-07-26 20:04:24 +02:00
|
|
|
# services/auth/app/core/security.py
|
2025-07-17 13:09:24 +02:00
|
|
|
"""
|
|
|
|
|
Security utilities for authentication service
|
2025-07-26 20:04:24 +02:00
|
|
|
FIXED VERSION - Consistent JWT token structure
|
2025-07-17 13:09:24 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import bcrypt
|
|
|
|
|
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
|
2025-07-17 13:09:24 +02:00
|
|
|
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-17 13:09:24 +02:00
|
|
|
|
|
|
|
|
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-17 13:09:24 +02:00
|
|
|
|
2025-07-26 20:04:24 +02:00
|
|
|
# Initialize JWT handler with SAME configuration as gateway
|
2025-07-17 13:09:24 +02:00
|
|
|
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"""
|
2025-07-17 13:09:24 +02:00
|
|
|
|
|
|
|
|
@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:
|
2025-07-26 20:04:24 +02:00
|
|
|
"""
|
|
|
|
|
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)
|
|
|
|
|
)
|
2025-07-17 13:09:24 +02:00
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def create_refresh_token(user_data: Dict[str, Any]) -> str:
|
2025-07-26 20:04:24 +02:00
|
|
|
"""
|
|
|
|
|
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)
|
|
|
|
|
)
|
2025-07-17 13:09:24 +02:00
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def verify_token(token: str) -> Optional[Dict[str, Any]]:
|
2025-07-26 20:04:24 +02:00
|
|
|
"""
|
|
|
|
|
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)
|
2025-07-17 13:09:24 +02:00
|
|
|
|
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
|
|
|
|
2025-07-17 13:09:24 +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
|
2025-07-17 13:09:24 +02:00
|
|
|
|
|
|
|
|
@staticmethod
|
2025-07-17 21:25:27 +02:00
|
|
|
async def increment_login_attempts(email: str) -> None:
|
|
|
|
|
"""Increment login attempts for email"""
|
2025-07-17 13:09:24 +02:00
|
|
|
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)
|
2025-07-17 13:09:24 +02:00
|
|
|
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"""
|
2025-07-17 13:09:24 +02:00
|
|
|
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}")
|
2025-07-17 13:09:24 +02:00
|
|
|
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:
|
2025-07-17 13:09:24 +02:00
|
|
|
"""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-17 13:09:24 +02:00
|
|
|
|
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
|
|
|
|
2025-07-17 13:09: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"""
|
2025-07-17 13:09:24 +02:00
|
|
|
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-17 13:09:24 +02:00
|
|
|
|
2025-07-26 20:04:24 +02:00
|
|
|
exists = await redis_client.exists(key)
|
|
|
|
|
return bool(exists)
|
|
|
|
|
|
2025-07-17 13:09:24 +02:00
|
|
|
except Exception as e:
|
2025-07-26 20:04:24 +02:00
|
|
|
logger.error(f"Error checking refresh token validity: {e}")
|
2025-07-17 13:09:24 +02:00
|
|
|
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"""
|
2025-07-17 13:09:24 +02:00
|
|
|
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
|
|
|
|
2025-07-17 13:09: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}")
|
|
|
|
|
|
2025-07-17 13:09:24 +02:00
|
|
|
except Exception as e:
|
2025-07-26 20:04:24 +02:00
|
|
|
logger.error(f"Error revoking refresh token: {e}")
|