Initial commit - production deployment
This commit is contained in:
0
services/auth/app/core/__init__.py
Normal file
0
services/auth/app/core/__init__.py
Normal file
132
services/auth/app/core/auth.py
Normal file
132
services/auth/app/core/auth.py
Normal 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
|
||||
70
services/auth/app/core/config.py
Normal file
70
services/auth/app/core/config.py
Normal 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()
|
||||
290
services/auth/app/core/database.py
Normal file
290
services/auth/app/core/database.py
Normal 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'
|
||||
]
|
||||
453
services/auth/app/core/security.py
Normal file
453
services/auth/app/core/security.py
Normal 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}")
|
||||
Reference in New Issue
Block a user