Init Repo
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
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
|
||||
Reference in New Issue
Block a user