solventum-image-metadata/backend/app/core/auth.py
SamoilenkoVadym 563d476a94 feat(backend): migrate from Flask to FastAPI with Redis sessions
- Create FastAPI application with async I/O
- Implement Redis session storage (fixes session loss on restart)
- Add JWT authentication with refresh tokens
- Add Microsoft SSO support via MSAL
- Copy all processors from src/ (100% reused, no changes)
- Create file upload/download endpoints
- Create metadata update endpoints
- Create template CRUD endpoints
- Add SQLAlchemy async database models
- Add Docker Compose configuration with Redis

Solves critical issues:
- Session management: Redis replaces in-memory dicts
- Scalability: Async FastAPI + microservices architecture
- File handling: Persistent storage with auto-cleanup

Key files:
- backend/app/main.py - FastAPI entry point
- backend/app/core/redis_client.py - Session store
- backend/app/core/auth.py - JWT authentication
- backend/app/api/* - All REST endpoints
- backend/app/processors/ - Reused from src/

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-02-09 13:14:37 +00:00

247 lines
5.9 KiB
Python

"""
JWT Authentication
Replaces Flask session-based auth with JWT tokens + Redis refresh tokens.
"""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import os
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT Configuration
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
# Security scheme
security = HTTPBearer()
# ===== Password Hashing =====
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password
"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against its hash.
Args:
plain_password: Plain text password
hashed_password: Hashed password from database
Returns:
True if password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
# ===== JWT Token Creation =====
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create JWT access token (short-lived, 30 minutes).
Args:
data: Payload data (typically {"sub": user_id})
expires_delta: Optional custom expiration time
Returns:
JWT token string
"""
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(user_id: int) -> str:
"""
Create JWT refresh token (long-lived, 7 days).
Stored in Redis for validation.
Args:
user_id: User ID from database
Returns:
JWT refresh token string
"""
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {
"sub": str(user_id),
"exp": expire,
"type": "refresh"
}
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
# ===== JWT Token Validation =====
def decode_token(token: str) -> dict:
"""
Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload
Raises:
HTTPException: If token is invalid or expired
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_access_token(token: str) -> int:
"""
Verify access token and extract user ID.
Args:
token: JWT access token
Returns:
user_id: User ID from token
Raises:
HTTPException: If token is invalid or not an access token
"""
payload = decode_token(token)
# Check token type
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract user ID
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
return int(user_id)
def verify_refresh_token(token: str) -> int:
"""
Verify refresh token and extract user ID.
Args:
token: JWT refresh token
Returns:
user_id: User ID from token
Raises:
HTTPException: If token is invalid or not a refresh token
"""
payload = decode_token(token)
# Check token type
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract user ID
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
return int(user_id)
# ===== FastAPI Dependencies =====
async def get_current_user_id(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> int:
"""
FastAPI dependency to get current user ID from JWT token.
Use this to protect endpoints: @router.get("/protected", dependencies=[Depends(get_current_user_id)])
Args:
credentials: HTTP Bearer credentials from Authorization header
Returns:
user_id: Current user's ID
Raises:
HTTPException: If token is invalid
"""
token = credentials.credentials
user_id = verify_access_token(token)
return user_id
# ===== Helper Functions =====
def create_tokens_response(user_id: int) -> dict:
"""
Create both access and refresh tokens for login response.
Args:
user_id: User ID from database
Returns:
Dict with access_token, refresh_token, token_type
"""
access_token = create_access_token({"sub": str(user_id)})
refresh_token = create_refresh_token(user_id)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60 # seconds
}