LinkDesk/backend/utils/auth.py

473 lines
15 KiB
Python

from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional, Union
from fastapi import HTTPException, status, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
import os
import secrets
import hashlib
import json
import logging
# Setup logger
logger = logging.getLogger("vfx_auth")
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT settings
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production")
REFRESH_SECRET_KEY = os.getenv("REFRESH_SECRET_KEY", "your-refresh-secret-key-here-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
# Security scheme
security = HTTPBearer()
# API Key settings
API_KEY_PREFIX = "vfx_"
API_KEY_LENGTH = 32
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create a JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create a JWT refresh token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, REFRESH_SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str, token_type: str = "access") -> Optional[dict]:
"""Verify and decode a JWT token."""
try:
secret_key = SECRET_KEY if token_type == "access" else REFRESH_SECRET_KEY
logger.debug(f"🔐 Decoding {token_type} token with secret: {secret_key[:10]}...")
payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM])
logger.debug(f"🔐 Token decoded successfully: {payload}")
# Verify token type
if payload.get("type") != token_type:
logger.warning(f"🔐 Token type mismatch: expected {token_type}, got {payload.get('type')}")
return None
return payload
except JWTError as e:
logger.warning(f"🔐 JWT decode error: {e}")
return None
def get_current_user_from_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Extract user information from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
logger.debug(f"🔐 Verifying token: {credentials.credentials[:20]}...")
payload = verify_token(credentials.credentials, "access")
if payload is None:
logger.warning("🔐 Token verification failed - invalid token")
raise credentials_exception
logger.debug(f"🔐 Token payload: {payload}")
user_id_str = payload.get("sub")
if user_id_str is None:
logger.warning("🔐 Token verification failed - no sub field")
raise credentials_exception
try:
user_id = int(user_id_str)
logger.debug(f"🔐 Extracted user_id: {user_id}")
except (ValueError, TypeError):
logger.warning(f"🔐 Token verification failed - invalid user_id: {user_id_str}")
raise credentials_exception
return {"user_id": user_id, "email": payload.get("email")}
except HTTPException:
raise
except Exception as e:
logger.error(f"🔐 Token verification error: {e}")
raise credentials_exception
def get_current_user(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
"""Get current user from database using token data."""
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
return _get_user_from_db(db, token_data["user_id"])
finally:
db.close()
else:
return _get_user_from_db(db, token_data["user_id"])
def _get_user_from_db(db: Session, user_id: int):
"""Helper function to get user from database."""
from models.user import User
logger.debug(f"🔐 Looking up user_id: {user_id}")
user = db.query(User).filter(User.id == user_id).first()
if user is None:
logger.warning(f"🔐 User not found: {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
logger.debug(f"🔐 Found user: {user.email} (approved: {user.is_approved})")
if not user.is_approved:
logger.warning(f"🔐 User not approved: {user.email}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account not approved"
)
return user
def get_current_user_with_db(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
"""Get current user with database dependency injection."""
from database import get_db
if db is None:
# This should not happen in normal FastAPI usage
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database session not available"
)
return _get_user_from_db(db, token_data["user_id"])
def require_role(required_roles: list):
"""Decorator to require specific user roles."""
def role_checker(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
current_user = _get_user_from_db(db, token_data["user_id"])
finally:
db.close()
else:
current_user = _get_user_from_db(db, token_data["user_id"])
if current_user.role not in required_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
return role_checker
def require_admin_permission():
"""Decorator to require admin permission regardless of role."""
def admin_checker(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
current_user = _get_user_from_db(db, token_data["user_id"])
finally:
db.close()
else:
current_user = _get_user_from_db(db, token_data["user_id"])
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin permission required"
)
return current_user
return admin_checker
def create_role_dependency(required_roles: list):
"""Create a dependency that requires specific user roles with proper DB injection."""
def role_checker(
token_data: dict = Depends(get_current_user_from_token),
db: Session = None
):
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
current_user = _get_user_from_db(db, token_data["user_id"])
finally:
db.close()
else:
current_user = _get_user_from_db(db, token_data["user_id"])
if current_user.role not in required_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
return role_checker
# API Key utilities
def generate_api_key() -> str:
"""Generate a new API key."""
random_part = secrets.token_urlsafe(API_KEY_LENGTH)
return f"{API_KEY_PREFIX}{random_part}"
def hash_api_key(api_key: str) -> str:
"""Hash an API key for secure storage."""
return hashlib.sha256(api_key.encode()).hexdigest()
def verify_api_key_format(api_key: str) -> bool:
"""Verify that an API key has the correct format."""
return api_key.startswith(API_KEY_PREFIX) and len(api_key) > len(API_KEY_PREFIX)
def get_current_user_from_api_key(
request: Request,
db: Session = Depends(lambda: None)
) -> Optional[dict]:
"""Extract user information from API key."""
from database import get_db
from models.api_key import APIKey
from models.api_key_usage import APIKeyUsage
# Get API key from header
api_key = request.headers.get("X-API-Key")
if not api_key:
return None
# Verify format
if not verify_api_key_format(api_key):
return None
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
return _verify_api_key_and_get_user(db, api_key, request)
finally:
db.close()
else:
return _verify_api_key_and_get_user(db, api_key, request)
def _verify_api_key_and_get_user(db: Session, api_key: str, request: Request) -> Optional[dict]:
"""Helper function to verify API key and get user."""
from models.api_key import APIKey
from models.api_key_usage import APIKeyUsage
from models.user import User
# Hash the provided key
key_hash = hash_api_key(api_key)
# Find the API key in database
api_key_record = db.query(APIKey).filter(
APIKey.key_hash == key_hash,
APIKey.is_active == True
).first()
if not api_key_record:
return None
# Check if key is expired
if api_key_record.expires_at and api_key_record.expires_at < datetime.utcnow():
return None
# Get the user
user = db.query(User).filter(User.id == api_key_record.user_id).first()
if not user or not user.is_approved:
return None
# Log API key usage
usage_log = APIKeyUsage(
api_key_id=api_key_record.id,
endpoint=str(request.url.path),
method=request.method,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent")
)
db.add(usage_log)
# Update last used timestamp
api_key_record.last_used_at = datetime.utcnow()
db.commit()
# Parse scopes
try:
scopes = json.loads(api_key_record.scopes)
except json.JSONDecodeError:
scopes = []
return {
"user_id": user.id,
"email": user.email,
"role": user.role,
"api_key_id": api_key_record.id,
"scopes": scopes,
"auth_type": "api_key"
}
def get_current_user_flexible(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(lambda: None)
):
"""Get current user from either JWT token or API key."""
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
return _get_current_user_flexible_with_db(request, credentials, db)
finally:
db.close()
else:
return _get_current_user_flexible_with_db(request, credentials, db)
def _get_current_user_flexible_with_db(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials],
db: Session
):
"""Helper function for flexible authentication with database session."""
# Try API key first
api_key_user = get_current_user_from_api_key(request, db)
if api_key_user:
# Get full user object
user = _get_user_from_db(db, api_key_user["user_id"])
# Add API key specific data
user.api_key_id = api_key_user["api_key_id"]
user.scopes = api_key_user["scopes"]
user.auth_type = "api_key"
return user
# Try JWT token
if credentials:
try:
payload = verify_token(credentials.credentials, "access")
if payload:
user = _get_user_from_db(db, payload.get("sub"))
user.auth_type = "jwt"
return user
except Exception:
pass
# No valid authentication found
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def check_api_key_scope(user, required_scope: str) -> bool:
"""Check if the current user (authenticated via API key) has the required scope."""
if not hasattr(user, 'auth_type') or user.auth_type != 'api_key':
# JWT tokens have full access based on user role
return True
if not hasattr(user, 'scopes'):
return False
# Check if user has the specific scope or full access
return required_scope in user.scopes or "full:access" in user.scopes
def require_api_key_scope(required_scope: str):
"""Decorator to require specific API key scope."""
def scope_checker(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(lambda: None)
):
from database import get_db
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
user = _get_current_user_flexible_with_db(request, credentials, db)
finally:
db.close()
else:
user = _get_current_user_flexible_with_db(request, credentials, db)
if not check_api_key_scope(user, required_scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required scope: {required_scope}"
)
return user
return scope_checker