Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

View File

@@ -0,0 +1,132 @@
"""
Authentication dependency for auth service
services/auth/app/core/auth.py
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from jose import JWTError, jwt
import structlog
from app.core.config import settings
from app.core.database import get_db
from app.models.users import User
logger = structlog.get_logger()
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""
Dependency to get the current authenticated user
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# Decode JWT token
payload = jwt.decode(
credentials.credentials,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
# Get user identifier from token
user_id: str = payload.get("sub")
if user_id is None:
logger.warning("Token payload missing 'sub' field")
raise credentials_exception
logger.info(f"Authenticating user: {user_id}")
except JWTError as e:
logger.warning(f"JWT decode error: {e}")
raise credentials_exception
try:
# Get user from database
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user is None:
logger.warning(f"User not found for ID: {user_id}")
raise credentials_exception
if not user.is_active:
logger.warning(f"Inactive user attempted access: {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
logger.info(f"User authenticated: {user.email}")
return user
except Exception as e:
logger.error(f"Error getting user: {e}")
raise credentials_exception
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Dependency to get the current active user
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
return current_user
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Generate password hash"""
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta=None):
"""Create JWT access token"""
from datetime import datetime, timedelta
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict):
"""Create JWT refresh token"""
from datetime import datetime, timedelta
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
return encoded_jwt

View File

@@ -0,0 +1,70 @@
# ================================================================
# AUTH SERVICE CONFIGURATION
# services/auth/app/core/config.py
# ================================================================
"""
Authentication service configuration
User management and JWT token handling
"""
from shared.config.base import BaseServiceSettings
import os
class AuthSettings(BaseServiceSettings):
"""Auth service specific settings"""
# Service Identity
APP_NAME: str = "Authentication Service"
SERVICE_NAME: str = "auth-service"
DESCRIPTION: str = "User authentication and authorization service"
# Database configuration (secure approach - build from components)
@property
def DATABASE_URL(self) -> str:
"""Build database URL from secure components"""
# Try complete URL first (for backward compatibility)
complete_url = os.getenv("AUTH_DATABASE_URL")
if complete_url:
return complete_url
# Build from components (secure approach)
user = os.getenv("AUTH_DB_USER", "auth_user")
password = os.getenv("AUTH_DB_PASSWORD", "auth_pass123")
host = os.getenv("AUTH_DB_HOST", "localhost")
port = os.getenv("AUTH_DB_PORT", "5432")
name = os.getenv("AUTH_DB_NAME", "auth_db")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
# Redis Database (dedicated for auth)
REDIS_DB: int = 0
# Enhanced Password Requirements for Spain
PASSWORD_MIN_LENGTH: int = 8
PASSWORD_REQUIRE_UPPERCASE: bool = True
PASSWORD_REQUIRE_LOWERCASE: bool = True
PASSWORD_REQUIRE_NUMBERS: bool = True
PASSWORD_REQUIRE_SYMBOLS: bool = False
# Spanish GDPR Compliance
GDPR_COMPLIANCE_ENABLED: bool = True
DATA_RETENTION_DAYS: int = int(os.getenv("AUTH_DATA_RETENTION_DAYS", "365"))
CONSENT_REQUIRED: bool = True
PRIVACY_POLICY_URL: str = os.getenv("PRIVACY_POLICY_URL", "/privacy")
# Account Security
ACCOUNT_LOCKOUT_ENABLED: bool = True
MAX_LOGIN_ATTEMPTS: int = 5
LOCKOUT_DURATION_MINUTES: int = 30
PASSWORD_HISTORY_COUNT: int = 5
# Session Management
SESSION_TIMEOUT_MINUTES: int = int(os.getenv("SESSION_TIMEOUT_MINUTES", "60"))
CONCURRENT_SESSIONS_LIMIT: int = int(os.getenv("CONCURRENT_SESSIONS_LIMIT", "3"))
# Email Verification
EMAIL_VERIFICATION_REQUIRED: bool = os.getenv("EMAIL_VERIFICATION_REQUIRED", "true").lower() == "true"
EMAIL_VERIFICATION_EXPIRE_HOURS: int = int(os.getenv("EMAIL_VERIFICATION_EXPIRE_HOURS", "24"))
settings = AuthSettings()

View File

@@ -0,0 +1,290 @@
# ================================================================
# services/auth/app/core/database.py (ENHANCED VERSION)
# ================================================================
"""
Database configuration for authentication service
"""
import structlog
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import NullPool
from app.core.config import settings
from shared.database.base import Base
logger = structlog.get_logger()
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
poolclass=NullPool,
echo=settings.DEBUG,
future=True
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False
)
async def get_db() -> AsyncSession:
"""Database dependency"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception as e:
await session.rollback()
logger.error(f"Database session error: {e}")
raise
finally:
await session.close()
async def create_tables():
"""Create database tables"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created successfully")
# ================================================================
# services/auth/app/core/database.py - UPDATED TO USE SHARED INFRASTRUCTURE
# ================================================================
"""
Database configuration for authentication service
Uses shared database infrastructure for consistency
"""
import structlog
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from shared.database.base import DatabaseManager, Base
from app.core.config import settings
logger = structlog.get_logger()
# ✅ Initialize database manager using shared infrastructure
database_manager = DatabaseManager(settings.DATABASE_URL)
# ✅ Alias for convenience - matches the existing interface
get_db = database_manager.get_db
# ✅ Use the shared background session method
get_background_db_session = database_manager.get_background_session
async def get_db_health() -> bool:
"""
Health check function for database connectivity
"""
try:
async with database_manager.async_engine.begin() as conn:
await conn.execute(text("SELECT 1"))
logger.debug("Database health check passed")
return True
except Exception as e:
logger.error(f"Database health check failed: {str(e)}")
return False
async def create_tables():
"""Create database tables using shared infrastructure"""
await database_manager.create_tables()
logger.info("Auth database tables created successfully")
# ✅ Auth service specific database utilities
class AuthDatabaseUtils:
"""Auth service specific database utilities"""
@staticmethod
async def cleanup_old_refresh_tokens(days_old: int = 30):
"""Clean up old refresh tokens"""
try:
async with database_manager.get_background_session() as session:
if settings.DATABASE_URL.startswith("sqlite"):
query = text(
"DELETE FROM refresh_tokens "
"WHERE created_at < datetime('now', :days_param)"
)
params = {"days_param": f"-{days_old} days"}
else:
# PostgreSQL
query = text(
"DELETE FROM refresh_tokens "
"WHERE created_at < NOW() - INTERVAL :days_param"
)
params = {"days_param": f"{days_old} days"}
result = await session.execute(query, params)
# No need to commit - get_background_session() handles it
logger.info("Cleaned up old refresh tokens",
deleted_count=result.rowcount,
days_old=days_old)
return result.rowcount
except Exception as e:
logger.error("Failed to cleanup old refresh tokens",
error=str(e))
return 0
@staticmethod
async def get_auth_statistics(tenant_id: str = None) -> dict:
"""Get authentication statistics"""
try:
async with database_manager.get_background_session() as session:
# Base query for users
users_query = text("SELECT COUNT(*) as count FROM users WHERE is_active = :is_active")
params = {}
if tenant_id:
# If tenant filtering is needed (though auth service might not have tenant_id in users table)
# This is just an example - adjust based on your actual schema
pass
# Get active users count
active_users_result = await session.execute(
users_query,
{**params, "is_active": True}
)
active_users = active_users_result.scalar() or 0
# Get inactive users count
inactive_users_result = await session.execute(
users_query,
{**params, "is_active": False}
)
inactive_users = inactive_users_result.scalar() or 0
# Get refresh tokens count
tokens_query = text("SELECT COUNT(*) as count FROM refresh_tokens")
tokens_result = await session.execute(tokens_query)
active_tokens = tokens_result.scalar() or 0
return {
"active_users": active_users,
"inactive_users": inactive_users,
"total_users": active_users + inactive_users,
"active_tokens": active_tokens
}
except Exception as e:
logger.error(f"Failed to get auth statistics: {str(e)}")
return {
"active_users": 0,
"inactive_users": 0,
"total_users": 0,
"active_tokens": 0
}
@staticmethod
async def check_user_exists(user_id: str) -> bool:
"""Check if user exists"""
try:
async with database_manager.get_background_session() as session:
query = text(
"SELECT COUNT(*) as count "
"FROM users "
"WHERE id = :user_id "
"LIMIT 1"
)
result = await session.execute(query, {"user_id": user_id})
count = result.scalar() or 0
return count > 0
except Exception as e:
logger.error("Failed to check user existence",
user_id=user_id, error=str(e))
return False
@staticmethod
async def get_user_token_count(user_id: str) -> int:
"""Get count of active refresh tokens for a user"""
try:
async with database_manager.get_background_session() as session:
query = text(
"SELECT COUNT(*) as count "
"FROM refresh_tokens "
"WHERE user_id = :user_id"
)
result = await session.execute(query, {"user_id": user_id})
count = result.scalar() or 0
return count
except Exception as e:
logger.error("Failed to get user token count",
user_id=user_id, error=str(e))
return 0
# Enhanced database session dependency with better error handling
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""
Enhanced database session dependency with better logging and error handling
"""
async with database_manager.async_session_local() as session:
try:
logger.debug("Database session created")
yield session
except Exception as e:
logger.error(f"Database session error: {str(e)}", exc_info=True)
await session.rollback()
raise
finally:
await session.close()
logger.debug("Database session closed")
# Database initialization for auth service
async def initialize_auth_database():
"""Initialize database tables for auth service"""
try:
logger.info("Initializing auth service database")
# Import models to ensure they're registered
from app.models.users import User
from app.models.refresh_tokens import RefreshToken
# Create tables using shared infrastructure
await database_manager.create_tables()
logger.info("Auth service database initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize auth service database: {str(e)}")
raise
# Database cleanup for auth service
async def cleanup_auth_database():
"""Cleanup database connections for auth service"""
try:
logger.info("Cleaning up auth service database connections")
# Close engine connections
if hasattr(database_manager, 'async_engine') and database_manager.async_engine:
await database_manager.async_engine.dispose()
logger.info("Auth service database cleanup completed")
except Exception as e:
logger.error(f"Failed to cleanup auth service database: {str(e)}")
# Export the commonly used items to maintain compatibility
__all__ = [
'Base',
'database_manager',
'get_db',
'get_background_db_session',
'get_db_session',
'get_db_health',
'AuthDatabaseUtils',
'initialize_auth_database',
'cleanup_auth_database',
'create_tables'
]

View File

@@ -0,0 +1,453 @@
# 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
# NEW: Add subscription data to JWT payload
if "tenant_id" in user_data:
payload["tenant_id"] = user_data["tenant_id"]
if "tenant_role" in user_data:
payload["tenant_role"] = user_data["tenant_role"]
if "subscription" in user_data:
payload["subscription"] = user_data["subscription"]
if "tenant_access" in user_data:
# Limit tenant_access to 10 entries to prevent JWT size explosion
tenant_access = user_data["tenant_access"]
if tenant_access and len(tenant_access) > 10:
tenant_access = tenant_access[:10]
logger.warning(f"Truncated tenant_access to 10 entries for user {user_data['user_id']}")
payload["tenant_access"] = tenant_access
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
def create_service_token(service_name: str, tenant_id: Optional[str] = None) -> str:
"""
Create JWT service token for inter-service communication
✅ UNIFIED: Uses shared JWT handler for consistent token creation
✅ ENHANCED: Supports tenant context for tenant-scoped operations
Args:
service_name: Name of the service (e.g., 'auth-service', 'tenant-service')
tenant_id: Optional tenant ID for tenant-scoped service operations
Returns:
Encoded JWT service token
"""
try:
# Use unified JWT handler to create service token
token = jwt_handler.create_service_token(
service_name=service_name,
tenant_id=tenant_id
)
logger.debug(f"Created service token for {service_name}", tenant_id=tenant_id)
return token
except Exception as e:
logger.error(f"Failed to create service token for {service_name}: {e}")
raise ValueError(f"Failed to create service token: {str(e)}")
@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:
redis_client = await get_redis_client()
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 HTTPException:
raise # Re-raise HTTPException
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:
redis_client = await get_redis_client()
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 generate_reset_token() -> str:
"""Generate a secure password reset token"""
import secrets
return secrets.token_urlsafe(32)
@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:
redis_client = await get_redis_client()
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:
redis_client = await get_redis_client()
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:
redis_client = await get_redis_client()
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:
redis_client = await get_redis_client()
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:
redis_client = await get_redis_client()
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:
redis_client = await get_redis_client()
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}")