- 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>
377 lines
10 KiB
Python
377 lines
10 KiB
Python
"""
|
|
Authentication API Endpoints
|
|
Handles login, logout, token refresh, and Microsoft SSO.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
import msal
|
|
import os
|
|
|
|
from app.core.database import get_db, UserRepository, AuditLogRepository
|
|
from app.core.auth import (
|
|
verify_password,
|
|
hash_password,
|
|
create_tokens_response,
|
|
verify_refresh_token,
|
|
get_current_user_id
|
|
)
|
|
from app.core.redis_client import RedisSessionStore
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ===== Request/Response Models =====
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
class LoginResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str
|
|
expires_in: int
|
|
user: dict
|
|
|
|
|
|
class TokenRefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
class LogoutRequest(BaseModel):
|
|
session_id: Optional[str] = None
|
|
|
|
|
|
# ===== Local Authentication Endpoints =====
|
|
|
|
@router.post("/login", response_model=LoginResponse)
|
|
async def login(
|
|
login_data: LoginRequest,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Local authentication - username/password login.
|
|
|
|
Returns JWT tokens + user info.
|
|
"""
|
|
# Get user from database
|
|
user = await UserRepository.get_by_username(db, login_data.username)
|
|
|
|
# Validate user exists and password correct
|
|
if not user or not user.password_hash:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid username or password"
|
|
)
|
|
|
|
if not verify_password(login_data.password, user.password_hash):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid username or password"
|
|
)
|
|
|
|
# Check if user is active
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User account is disabled"
|
|
)
|
|
|
|
# Create JWT tokens
|
|
tokens = create_tokens_response(user.id)
|
|
|
|
# Create user session in Redis
|
|
redis: RedisSessionStore = request.app.state.redis
|
|
session_id = await redis.create_user_session(
|
|
user_id=user.id,
|
|
refresh_token=tokens["refresh_token"],
|
|
ip_address=request.client.host,
|
|
user_agent=request.headers.get("user-agent", "")
|
|
)
|
|
|
|
# Update last login
|
|
await UserRepository.update_last_login(db, user.id)
|
|
|
|
# Log action
|
|
await AuditLogRepository.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
action="login",
|
|
details=f"Login from {request.client.host}"
|
|
)
|
|
|
|
return LoginResponse(
|
|
**tokens,
|
|
user=user.to_dict()
|
|
)
|
|
|
|
|
|
@router.post("/token/refresh")
|
|
async def refresh_access_token(
|
|
refresh_data: TokenRefreshRequest,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Refresh access token using refresh token.
|
|
"""
|
|
# Verify refresh token
|
|
try:
|
|
user_id = verify_refresh_token(refresh_data.refresh_token)
|
|
except HTTPException as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid refresh token"
|
|
)
|
|
|
|
# Check if user still exists and is active
|
|
user = await UserRepository.get_by_id(db, user_id)
|
|
if not user or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User not found or inactive"
|
|
)
|
|
|
|
# Create new tokens
|
|
tokens = create_tokens_response(user.id)
|
|
|
|
# Update Redis session with new refresh token
|
|
redis: RedisSessionStore = request.app.state.redis
|
|
# Note: We keep the old session_id but update the refresh token
|
|
# In production, you might want to rotate session_id as well
|
|
|
|
return {
|
|
**tokens,
|
|
"user": user.to_dict()
|
|
}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(
|
|
logout_data: LogoutRequest,
|
|
request: Request,
|
|
user_id: int = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Logout user - invalidate session in Redis.
|
|
"""
|
|
# Delete user session from Redis
|
|
redis: RedisSessionStore = request.app.state.redis
|
|
|
|
if logout_data.session_id:
|
|
await redis.delete_user_session(logout_data.session_id)
|
|
|
|
# Log action
|
|
await AuditLogRepository.log_action(
|
|
db,
|
|
user_id=user_id,
|
|
action="logout",
|
|
details=f"Logout from {request.client.host}"
|
|
)
|
|
|
|
return {"message": "Logged out successfully"}
|
|
|
|
|
|
# ===== Microsoft SSO Endpoints =====
|
|
|
|
# Microsoft OAuth configuration
|
|
AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID")
|
|
AZURE_CLIENT_SECRET = os.getenv("AZURE_CLIENT_SECRET")
|
|
AZURE_TENANT_ID = os.getenv("AZURE_TENANT_ID")
|
|
REDIRECT_URI = os.getenv("REDIRECT_URI", "http://localhost:8000/auth/microsoft/callback")
|
|
|
|
|
|
@router.get("/microsoft/login")
|
|
async def microsoft_sso_login():
|
|
"""
|
|
Redirect to Microsoft SSO login page.
|
|
Returns auth URL for frontend to redirect to.
|
|
"""
|
|
if not AZURE_CLIENT_ID or not AZURE_TENANT_ID:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="Microsoft SSO not configured"
|
|
)
|
|
|
|
# Create MSAL confidential client app
|
|
msal_app = msal.ConfidentialClientApplication(
|
|
client_id=AZURE_CLIENT_ID,
|
|
client_credential=AZURE_CLIENT_SECRET,
|
|
authority=f"https://login.microsoftonline.com/{AZURE_TENANT_ID}"
|
|
)
|
|
|
|
# Get authorization URL
|
|
auth_url = msal_app.get_authorization_request_url(
|
|
scopes=["User.Read"],
|
|
redirect_uri=REDIRECT_URI
|
|
)
|
|
|
|
return {"auth_url": auth_url}
|
|
|
|
|
|
@router.get("/microsoft/callback")
|
|
async def microsoft_sso_callback(
|
|
code: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Handle Microsoft SSO callback.
|
|
Exchange authorization code for tokens and create user session.
|
|
"""
|
|
if not AZURE_CLIENT_ID or not AZURE_TENANT_ID:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail="Microsoft SSO not configured"
|
|
)
|
|
|
|
# Create MSAL app
|
|
msal_app = msal.ConfidentialClientApplication(
|
|
client_id=AZURE_CLIENT_ID,
|
|
client_credential=AZURE_CLIENT_SECRET,
|
|
authority=f"https://login.microsoftonline.com/{AZURE_TENANT_ID}"
|
|
)
|
|
|
|
# Acquire token by authorization code
|
|
result = msal_app.acquire_token_by_authorization_code(
|
|
code=code,
|
|
scopes=["User.Read"],
|
|
redirect_uri=REDIRECT_URI
|
|
)
|
|
|
|
if "access_token" not in result:
|
|
error = result.get("error_description", "Unknown error")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=f"SSO authentication failed: {error}"
|
|
)
|
|
|
|
# Get user info from Microsoft Graph
|
|
import requests
|
|
graph_response = requests.get(
|
|
"https://graph.microsoft.com/v1.0/me",
|
|
headers={"Authorization": f"Bearer {result['access_token']}"}
|
|
)
|
|
|
|
if graph_response.status_code != 200:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Failed to get user info from Microsoft Graph"
|
|
)
|
|
|
|
user_info = graph_response.json()
|
|
|
|
# Create or update user in database
|
|
username = user_info.get("userPrincipalName") or user_info.get("mail")
|
|
email = user_info.get("mail")
|
|
full_name = user_info.get("displayName")
|
|
|
|
user = await UserRepository.get_by_username(db, username)
|
|
|
|
if not user:
|
|
# Create new SSO user
|
|
user = await UserRepository.create_user(
|
|
db,
|
|
username=username,
|
|
password_hash=None, # SSO users don't have passwords
|
|
email=email,
|
|
full_name=full_name,
|
|
auth_method="sso"
|
|
)
|
|
else:
|
|
# Update existing user
|
|
user.last_login = None # Will be updated by update_last_login
|
|
await db.commit()
|
|
|
|
# Create JWT tokens
|
|
tokens = create_tokens_response(user.id)
|
|
|
|
# Create user session in Redis
|
|
redis: RedisSessionStore = request.app.state.redis
|
|
session_id = await redis.create_user_session(
|
|
user_id=user.id,
|
|
refresh_token=tokens["refresh_token"],
|
|
ip_address=request.client.host,
|
|
user_agent=request.headers.get("user-agent", "")
|
|
)
|
|
|
|
# Update last login
|
|
await UserRepository.update_last_login(db, user.id)
|
|
|
|
# Log action
|
|
await AuditLogRepository.log_action(
|
|
db,
|
|
user_id=user.id,
|
|
action="sso_login",
|
|
details=f"SSO login from {request.client.host}"
|
|
)
|
|
|
|
return LoginResponse(
|
|
**tokens,
|
|
user=user.to_dict()
|
|
)
|
|
|
|
|
|
# ===== User Info Endpoint =====
|
|
|
|
@router.get("/me")
|
|
async def get_current_user(
|
|
user_id: int = Depends(get_current_user_id),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Get current user info from JWT token.
|
|
"""
|
|
user = await UserRepository.get_by_id(db, user_id)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User not found"
|
|
)
|
|
|
|
return user.to_dict()
|
|
|
|
|
|
# ===== Admin Endpoints (for testing) =====
|
|
|
|
@router.post("/register")
|
|
async def register_user(
|
|
login_data: LoginRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Register new user (for testing/development).
|
|
In production, disable this or add admin auth.
|
|
"""
|
|
# Check if user already exists
|
|
existing_user = await UserRepository.get_by_username(db, login_data.username)
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Username already exists"
|
|
)
|
|
|
|
# Create new user
|
|
password_hashed = hash_password(login_data.password)
|
|
user = await UserRepository.create_user(
|
|
db,
|
|
username=login_data.username,
|
|
password_hash=password_hashed,
|
|
email=None,
|
|
full_name=None,
|
|
auth_method="local"
|
|
)
|
|
|
|
return {
|
|
"message": "User created successfully",
|
|
"user": user.to_dict()
|
|
}
|