Spaces:
Sleeping
Sleeping
GitHub Actions commited on
Commit ·
e673ce2
1
Parent(s): 0e9cbc7
🚀 Auto-deploy from GitHub
Browse files- app/api/v1/endpoints/auth.py +16 -7
- app/api/v1/endpoints/download.py +2 -2
- app/api/v1/endpoints/generate.py +8 -2
- app/api/v1/endpoints/health.py +2 -2
- app/core/auth.py +152 -40
- docs/authentication.md +105 -0
- tests/test_authentication.py +27 -1
app/api/v1/endpoints/auth.py
CHANGED
|
@@ -11,6 +11,7 @@ import uuid
|
|
| 11 |
import logging
|
| 12 |
from ..schemas.auth_schemas import TokenRequest, TokenResponse, UserRegistrationRequest, UserResponse
|
| 13 |
from ....core.config import settings
|
|
|
|
| 14 |
from ....services.database import (
|
| 15 |
get_user_by_username,
|
| 16 |
get_user_by_email,
|
|
@@ -61,14 +62,14 @@ async def get_access_token(credentials: TokenRequest, request: Request):
|
|
| 61 |
# Create session in database
|
| 62 |
await create_user_session(user["id"], jti, expires_at)
|
| 63 |
|
| 64 |
-
# Generate a temporary JWT token
|
| 65 |
payload = {
|
| 66 |
-
"sub": credentials.username,
|
| 67 |
-
"
|
| 68 |
-
"
|
| 69 |
-
"
|
| 70 |
-
"
|
| 71 |
-
|
| 72 |
}
|
| 73 |
|
| 74 |
secret_key = os.getenv("SECRET_KEY", "your-secret-key-change-this")
|
|
@@ -255,6 +256,14 @@ async def logout_user(request: Request, token: str = Depends(security)):
|
|
| 255 |
)
|
| 256 |
raise HTTPException(status_code=401, detail="Invalid token")
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
async def validate_user_credentials(credentials: Dict[str, Any]) -> Dict[str, Any] | None:
|
| 259 |
"""
|
| 260 |
Validate user credentials against your Supabase database
|
|
|
|
| 11 |
import logging
|
| 12 |
from ..schemas.auth_schemas import TokenRequest, TokenResponse, UserRegistrationRequest, UserResponse
|
| 13 |
from ....core.config import settings
|
| 14 |
+
from ....core.auth import authenticate_request, require_current_user
|
| 15 |
from ....services.database import (
|
| 16 |
get_user_by_username,
|
| 17 |
get_user_by_email,
|
|
|
|
| 62 |
# Create session in database
|
| 63 |
await create_user_session(user["id"], jti, expires_at)
|
| 64 |
|
| 65 |
+
# Generate a temporary JWT token with minimal payload
|
| 66 |
payload = {
|
| 67 |
+
"sub": credentials.username, # Subject (username) - needed for user lookup
|
| 68 |
+
"jti": jti, # JWT ID for session tracking
|
| 69 |
+
"exp": expires_at, # Expiration time
|
| 70 |
+
"iat": datetime.utcnow(), # Issued at time
|
| 71 |
+
"type": "access_token" # Token type
|
| 72 |
+
# Note: user_id removed - will be looked up from database using username
|
| 73 |
}
|
| 74 |
|
| 75 |
secret_key = os.getenv("SECRET_KEY", "your-secret-key-change-this")
|
|
|
|
| 256 |
)
|
| 257 |
raise HTTPException(status_code=401, detail="Invalid token")
|
| 258 |
|
| 259 |
+
@router.get("/auth/me", response_model=UserResponse)
|
| 260 |
+
async def get_current_user_info(current_user: Dict[str, Any] = Depends(require_current_user)):
|
| 261 |
+
"""
|
| 262 |
+
Get current authenticated user information
|
| 263 |
+
Requires valid JWT token authentication
|
| 264 |
+
"""
|
| 265 |
+
return UserResponse(**current_user)
|
| 266 |
+
|
| 267 |
async def validate_user_credentials(credentials: Dict[str, Any]) -> Dict[str, Any] | None:
|
| 268 |
"""
|
| 269 |
Validate user credentials against your Supabase database
|
app/api/v1/endpoints/download.py
CHANGED
|
@@ -3,7 +3,7 @@ from fastapi.responses import FileResponse
|
|
| 3 |
from pathlib import Path
|
| 4 |
from ....services.database import get_supabase_client # Corrected import
|
| 5 |
from ....core.config import settings # Import settings
|
| 6 |
-
from ....core.auth import
|
| 7 |
from supabase import Client
|
| 8 |
|
| 9 |
router = APIRouter()
|
|
@@ -12,7 +12,7 @@ router = APIRouter()
|
|
| 12 |
async def download_generated_image(
|
| 13 |
card_id: str, # card_id from path
|
| 14 |
supabase: Client = Depends(get_supabase_client),
|
| 15 |
-
authenticated: bool = Depends(
|
| 16 |
):
|
| 17 |
"""
|
| 18 |
Download a custom generated image using the card_id to find the image path from Supabase.
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
from ....services.database import get_supabase_client # Corrected import
|
| 5 |
from ....core.config import settings # Import settings
|
| 6 |
+
from ....core.auth import authenticate_request
|
| 7 |
from supabase import Client
|
| 8 |
|
| 9 |
router = APIRouter()
|
|
|
|
| 12 |
async def download_generated_image(
|
| 13 |
card_id: str, # card_id from path
|
| 14 |
supabase: Client = Depends(get_supabase_client),
|
| 15 |
+
authenticated: bool = Depends(authenticate_request)
|
| 16 |
):
|
| 17 |
"""
|
| 18 |
Download a custom generated image using the card_id to find the image path from Supabase.
|
app/api/v1/endpoints/generate.py
CHANGED
|
@@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
|
| 2 |
from dotenv import load_dotenv
|
| 3 |
from supabase import Client
|
| 4 |
import uuid
|
|
|
|
| 5 |
from ..schemas.card_schemas import CardGenerateRequest, CardGenerateResponse
|
| 6 |
from ....core.generator import build_prompt # get_constellation wird hier nicht direkt verwendet
|
| 7 |
from ....core.card_renderer import generate_card as render_card_sync # Umbenennen für Klarheit
|
|
@@ -10,7 +11,7 @@ from ....services.database import get_supabase_client, save_card
|
|
| 10 |
from ....core.config import settings
|
| 11 |
from ....core.model_loader import get_generator
|
| 12 |
from ....core.constraints import generate_with_retry, check_constraints
|
| 13 |
-
from ....core.auth import
|
| 14 |
from fastapi.concurrency import run_in_threadpool # Importieren
|
| 15 |
|
| 16 |
load_dotenv()
|
|
@@ -28,12 +29,17 @@ async def generate_qr_code_async(*args, **kwargs):
|
|
| 28 |
async def generate_endpoint(
|
| 29 |
request: CardGenerateRequest,
|
| 30 |
supabase: Client = Depends(get_supabase_client),
|
| 31 |
-
authenticated: bool = Depends(
|
|
|
|
| 32 |
):
|
| 33 |
try:
|
| 34 |
lang = request.lang or "de"
|
| 35 |
input_date_str = request.card_date.isoformat()
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
card_prompt = build_prompt(
|
| 38 |
lang=lang,
|
| 39 |
card_date=input_date_str,
|
|
|
|
| 2 |
from dotenv import load_dotenv
|
| 3 |
from supabase import Client
|
| 4 |
import uuid
|
| 5 |
+
from typing import Optional, Dict, Any
|
| 6 |
from ..schemas.card_schemas import CardGenerateRequest, CardGenerateResponse
|
| 7 |
from ....core.generator import build_prompt # get_constellation wird hier nicht direkt verwendet
|
| 8 |
from ....core.card_renderer import generate_card as render_card_sync # Umbenennen für Klarheit
|
|
|
|
| 11 |
from ....core.config import settings
|
| 12 |
from ....core.model_loader import get_generator
|
| 13 |
from ....core.constraints import generate_with_retry, check_constraints
|
| 14 |
+
from ....core.auth import authenticate_request, get_current_user
|
| 15 |
from fastapi.concurrency import run_in_threadpool # Importieren
|
| 16 |
|
| 17 |
load_dotenv()
|
|
|
|
| 29 |
async def generate_endpoint(
|
| 30 |
request: CardGenerateRequest,
|
| 31 |
supabase: Client = Depends(get_supabase_client),
|
| 32 |
+
authenticated: bool = Depends(authenticate_request),
|
| 33 |
+
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
| 34 |
):
|
| 35 |
try:
|
| 36 |
lang = request.lang or "de"
|
| 37 |
input_date_str = request.card_date.isoformat()
|
| 38 |
|
| 39 |
+
# Log user context if available
|
| 40 |
+
user_id = current_user["id"] if current_user else None
|
| 41 |
+
username = current_user["username"] if current_user else "anonymous"
|
| 42 |
+
|
| 43 |
card_prompt = build_prompt(
|
| 44 |
lang=lang,
|
| 45 |
card_date=input_date_str,
|
app/api/v1/endpoints/health.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
from ..schemas.health_schema import HealthResponse
|
| 3 |
-
from ....core.auth import
|
| 4 |
import httpx
|
| 5 |
import asyncio
|
| 6 |
from typing import Optional
|
|
@@ -26,7 +26,7 @@ async def check_huggingface_space():
|
|
| 26 |
return "unreachable"
|
| 27 |
|
| 28 |
@router.get("/health", response_model=HealthResponse)
|
| 29 |
-
async def health_check(authenticated: bool = Depends(
|
| 30 |
"""
|
| 31 |
Health check endpoint that verifies server status, model loading status and HuggingFace space availability
|
| 32 |
"""
|
|
|
|
| 1 |
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
from ..schemas.health_schema import HealthResponse
|
| 3 |
+
from ....core.auth import authenticate_request
|
| 4 |
import httpx
|
| 5 |
import asyncio
|
| 6 |
from typing import Optional
|
|
|
|
| 26 |
return "unreachable"
|
| 27 |
|
| 28 |
@router.get("/health", response_model=HealthResponse)
|
| 29 |
+
async def health_check(authenticated: bool = Depends(authenticate_request)):
|
| 30 |
"""
|
| 31 |
Health check endpoint that verifies server status, model loading status and HuggingFace space availability
|
| 32 |
"""
|
app/core/auth.py
CHANGED
|
@@ -1,62 +1,89 @@
|
|
| 1 |
"""
|
| 2 |
-
Authentication middleware for
|
|
|
|
| 3 |
"""
|
| 4 |
from fastapi import HTTPException, status, Depends
|
| 5 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 6 |
-
from typing import Optional
|
| 7 |
import os
|
| 8 |
import jwt
|
|
|
|
| 9 |
from datetime import datetime
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
-
from ..services.database import get_user_session
|
| 12 |
|
| 13 |
load_dotenv()
|
| 14 |
|
| 15 |
security = HTTPBearer(auto_error=False)
|
|
|
|
| 16 |
|
| 17 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""Get HuggingFace API key from environment"""
|
| 19 |
-
return os.getenv("HF_API_KEY"
|
| 20 |
|
| 21 |
-
async def
|
| 22 |
"""
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"""
|
| 26 |
expected_token = get_hf_api_key()
|
| 27 |
|
| 28 |
-
# If no HF_API_KEY is set, allow public access
|
| 29 |
-
if not expected_token:
|
| 30 |
-
return True
|
| 31 |
-
|
| 32 |
-
# If HF_API_KEY is set but no credentials provided, deny access
|
| 33 |
if not credentials:
|
| 34 |
raise HTTPException(
|
| 35 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 36 |
-
detail="Authentication required. Please provide a valid HuggingFace API
|
| 37 |
headers={"WWW-Authenticate": "Bearer"},
|
| 38 |
)
|
| 39 |
|
| 40 |
token = credentials.credentials
|
| 41 |
|
| 42 |
-
#
|
| 43 |
-
if token == expected_token:
|
| 44 |
return True
|
| 45 |
|
| 46 |
-
#
|
| 47 |
try:
|
| 48 |
-
secret_key =
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
# Check if session is still valid (not revoked)
|
| 56 |
jti = payload.get("jti")
|
| 57 |
if jti:
|
| 58 |
session = await get_user_session(jti)
|
| 59 |
if not session:
|
|
|
|
| 60 |
raise HTTPException(
|
| 61 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 62 |
detail="Session has been revoked",
|
|
@@ -64,7 +91,18 @@ async def verify_hf_token(credentials: Optional[HTTPAuthorizationCredentials] =
|
|
| 64 |
)
|
| 65 |
|
| 66 |
return True
|
| 67 |
-
except jwt.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
pass
|
| 69 |
|
| 70 |
# If neither verification method worked, deny access
|
|
@@ -76,33 +114,32 @@ async def verify_hf_token(credentials: Optional[HTTPAuthorizationCredentials] =
|
|
| 76 |
|
| 77 |
async def optional_auth(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> bool:
|
| 78 |
"""
|
| 79 |
-
Optional authentication - doesn't raise errors if no token provided
|
| 80 |
-
|
|
|
|
| 81 |
"""
|
| 82 |
-
expected_token = get_hf_api_key()
|
| 83 |
-
|
| 84 |
-
# If no HF_API_KEY is set, allow public access
|
| 85 |
-
if not expected_token:
|
| 86 |
-
return True
|
| 87 |
-
|
| 88 |
-
# If no credentials provided, still allow access (optional auth)
|
| 89 |
if not credentials:
|
| 90 |
return False
|
| 91 |
|
| 92 |
-
# If credentials provided, verify them
|
| 93 |
token = credentials.credentials
|
|
|
|
| 94 |
|
| 95 |
# Check HF API key
|
| 96 |
-
if token == expected_token:
|
| 97 |
return True
|
| 98 |
|
| 99 |
# Check JWT token
|
| 100 |
try:
|
| 101 |
-
secret_key =
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
# Check session validity
|
| 108 |
jti = payload.get("jti")
|
|
@@ -113,3 +150,78 @@ async def optional_auth(credentials: Optional[HTTPAuthorizationCredentials] = De
|
|
| 113 |
return True
|
| 114 |
except jwt.InvalidTokenError:
|
| 115 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Authentication middleware for JWT token validation with Supabase database integration.
|
| 3 |
+
Supports dual authentication: JWT tokens for users and HuggingFace API key for admin access.
|
| 4 |
"""
|
| 5 |
from fastapi import HTTPException, status, Depends
|
| 6 |
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 7 |
+
from typing import Optional, Dict, Any
|
| 8 |
import os
|
| 9 |
import jwt
|
| 10 |
+
import logging
|
| 11 |
from datetime import datetime
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
+
from ..services.database import get_user_session, get_user_by_username
|
| 14 |
|
| 15 |
load_dotenv()
|
| 16 |
|
| 17 |
security = HTTPBearer(auto_error=False)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
+
def get_secret_key() -> str:
|
| 21 |
+
"""Get JWT secret key from environment"""
|
| 22 |
+
secret_key = os.getenv("SECRET_KEY")
|
| 23 |
+
if not secret_key:
|
| 24 |
+
raise ValueError("SECRET_KEY environment variable not set. Cannot issue or verify JWTs.")
|
| 25 |
+
return secret_key
|
| 26 |
+
|
| 27 |
+
def get_jwt_issuer() -> Optional[str]:
|
| 28 |
+
"""Get JWT issuer from environment"""
|
| 29 |
+
return os.getenv("JWT_ISSUER")
|
| 30 |
+
|
| 31 |
+
def get_jwt_audience() -> Optional[str]:
|
| 32 |
+
"""Get JWT audience from environment"""
|
| 33 |
+
return os.getenv("JWT_AUDIENCE")
|
| 34 |
+
|
| 35 |
+
def get_hf_api_key() -> Optional[str]:
|
| 36 |
"""Get HuggingFace API key from environment"""
|
| 37 |
+
return os.getenv("HF_API_KEY")
|
| 38 |
|
| 39 |
+
async def authenticate_request(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> bool:
|
| 40 |
"""
|
| 41 |
+
Primary authentication dependency for protected endpoints.
|
| 42 |
+
Implements dual authentication strategy:
|
| 43 |
+
1. HuggingFace API key (admin bypass) - simple string comparison
|
| 44 |
+
2. JWT token (user authentication) - cryptographic validation + session verification
|
| 45 |
+
|
| 46 |
+
For JWT tokens:
|
| 47 |
+
- Validates signature, expiration, audience, and issuer
|
| 48 |
+
- Checks session validity in Supabase database via 'jti' claim
|
| 49 |
+
- Rejects revoked sessions
|
| 50 |
+
|
| 51 |
+
Returns True if authentication succeeds, otherwise raises HTTPException.
|
| 52 |
"""
|
| 53 |
expected_token = get_hf_api_key()
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
if not credentials:
|
| 56 |
raise HTTPException(
|
| 57 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 58 |
+
detail="Authentication required. Please provide a valid JWT token or HuggingFace API key.",
|
| 59 |
headers={"WWW-Authenticate": "Bearer"},
|
| 60 |
)
|
| 61 |
|
| 62 |
token = credentials.credentials
|
| 63 |
|
| 64 |
+
# Check HuggingFace API key first (admin bypass - performance optimization)
|
| 65 |
+
if expected_token and token == expected_token:
|
| 66 |
return True
|
| 67 |
|
| 68 |
+
# Validate JWT token with full session verification
|
| 69 |
try:
|
| 70 |
+
secret_key = get_secret_key()
|
| 71 |
+
issuer = get_jwt_issuer()
|
| 72 |
+
audience = get_jwt_audience()
|
| 73 |
+
payload = jwt.decode(
|
| 74 |
+
token,
|
| 75 |
+
secret_key,
|
| 76 |
+
algorithms=["HS256"],
|
| 77 |
+
audience=audience,
|
| 78 |
+
issuer=issuer
|
| 79 |
+
)
|
| 80 |
|
| 81 |
# Check if session is still valid (not revoked)
|
| 82 |
jti = payload.get("jti")
|
| 83 |
if jti:
|
| 84 |
session = await get_user_session(jti)
|
| 85 |
if not session:
|
| 86 |
+
logger.warning(f"JWT verification failed: Session has been revoked for jti: {jti}")
|
| 87 |
raise HTTPException(
|
| 88 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 89 |
detail="Session has been revoked",
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
return True
|
| 94 |
+
except jwt.ExpiredSignatureError:
|
| 95 |
+
logger.warning("JWT verification failed: Token has expired")
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 98 |
+
detail="Token has expired",
|
| 99 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 100 |
+
)
|
| 101 |
+
except jwt.InvalidTokenError as e:
|
| 102 |
+
logger.warning(f"JWT verification failed: Invalid token - {e}")
|
| 103 |
+
# Potential Issue: Broad exception handling. Catching InvalidTokenError is a safe default
|
| 104 |
+
# to avoid leaking error details, but it can make debugging harder.
|
| 105 |
+
# Consider logging the specific error here for internal monitoring.
|
| 106 |
pass
|
| 107 |
|
| 108 |
# If neither verification method worked, deny access
|
|
|
|
| 114 |
|
| 115 |
async def optional_auth(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> bool:
|
| 116 |
"""
|
| 117 |
+
Optional authentication - doesn't raise errors if no token provided.
|
| 118 |
+
Returns True if authentication is successful, False otherwise.
|
| 119 |
+
Used for endpoints that can work with or without authentication.
|
| 120 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
if not credentials:
|
| 122 |
return False
|
| 123 |
|
|
|
|
| 124 |
token = credentials.credentials
|
| 125 |
+
expected_token = get_hf_api_key()
|
| 126 |
|
| 127 |
# Check HF API key
|
| 128 |
+
if expected_token and token == expected_token:
|
| 129 |
return True
|
| 130 |
|
| 131 |
# Check JWT token
|
| 132 |
try:
|
| 133 |
+
secret_key = get_secret_key()
|
| 134 |
+
issuer = get_jwt_issuer()
|
| 135 |
+
audience = get_jwt_audience()
|
| 136 |
+
payload = jwt.decode(
|
| 137 |
+
token,
|
| 138 |
+
secret_key,
|
| 139 |
+
algorithms=["HS256"],
|
| 140 |
+
audience=audience,
|
| 141 |
+
issuer=issuer
|
| 142 |
+
)
|
| 143 |
|
| 144 |
# Check session validity
|
| 145 |
jti = payload.get("jti")
|
|
|
|
| 150 |
return True
|
| 151 |
except jwt.InvalidTokenError:
|
| 152 |
return False
|
| 153 |
+
|
| 154 |
+
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[Dict[str, Any]]:
|
| 155 |
+
"""
|
| 156 |
+
Extract authenticated user data from JWT token.
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
- User data dict if authenticated with valid JWT token
|
| 160 |
+
- None if using HuggingFace API key (no user context)
|
| 161 |
+
- None if not authenticated or invalid token
|
| 162 |
+
|
| 163 |
+
For JWT tokens:
|
| 164 |
+
- Validates token signature and session in Supabase
|
| 165 |
+
- Retrieves full user data from database using 'sub' claim (username)
|
| 166 |
+
"""
|
| 167 |
+
if not credentials:
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
token = credentials.credentials
|
| 171 |
+
|
| 172 |
+
# Check if it's an HF API key (these don't have user context)
|
| 173 |
+
expected_hf_token = get_hf_api_key()
|
| 174 |
+
if expected_hf_token and token == expected_hf_token:
|
| 175 |
+
return None # HF API key users don't have user context
|
| 176 |
+
|
| 177 |
+
# Try to decode JWT token
|
| 178 |
+
try:
|
| 179 |
+
secret_key = get_secret_key()
|
| 180 |
+
issuer = get_jwt_issuer()
|
| 181 |
+
audience = get_jwt_audience()
|
| 182 |
+
payload = jwt.decode(
|
| 183 |
+
token,
|
| 184 |
+
secret_key,
|
| 185 |
+
algorithms=["HS256"],
|
| 186 |
+
audience=audience,
|
| 187 |
+
issuer=issuer
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# Check if session is still valid
|
| 191 |
+
jti = payload.get("jti")
|
| 192 |
+
if jti:
|
| 193 |
+
session = await get_user_session(jti)
|
| 194 |
+
if not session:
|
| 195 |
+
return None
|
| 196 |
+
|
| 197 |
+
# Get user data from database using username from token
|
| 198 |
+
username = payload.get("sub")
|
| 199 |
+
if username:
|
| 200 |
+
user = await get_user_by_username(username)
|
| 201 |
+
return user
|
| 202 |
+
|
| 203 |
+
return None
|
| 204 |
+
except jwt.InvalidTokenError:
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
async def require_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Dict[str, Any]:
|
| 208 |
+
"""
|
| 209 |
+
Extract authenticated user data from JWT token - mandatory authentication.
|
| 210 |
+
|
| 211 |
+
Use this dependency for endpoints that require user authentication.
|
| 212 |
+
Raises HTTPException if:
|
| 213 |
+
- No credentials provided
|
| 214 |
+
- Using HuggingFace API key (no user context)
|
| 215 |
+
- Invalid or expired JWT token
|
| 216 |
+
- Revoked session
|
| 217 |
+
|
| 218 |
+
Returns: User data dict from Supabase database
|
| 219 |
+
"""
|
| 220 |
+
user = await get_current_user(credentials)
|
| 221 |
+
if not user:
|
| 222 |
+
raise HTTPException(
|
| 223 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 224 |
+
detail="Authentication required. Please provide a valid JWT token.",
|
| 225 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 226 |
+
)
|
| 227 |
+
return user
|
docs/authentication.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Authentication System Documentation
|
| 2 |
+
|
| 3 |
+
## Übersicht
|
| 4 |
+
|
| 5 |
+
Das Authentifizierungssystem unterstützt zwei Arten von Tokens:
|
| 6 |
+
1. **HuggingFace API Keys** - für API-Zugriff ohne Benutzerkontext
|
| 7 |
+
2. **JWT Tokens** - für authentifizierte Benutzer mit Benutzerkontext
|
| 8 |
+
|
| 9 |
+
## Dependency Functions
|
| 10 |
+
|
| 11 |
+
### 1. `verify_hf_token()`
|
| 12 |
+
- **Zweck**: Grundlegende Authentifizierung
|
| 13 |
+
- **Rückgabe**: `bool` - True wenn authentifiziert
|
| 14 |
+
- **Verwendung**: Für Endpoints, die Authentifizierung benötigen, aber keinen Benutzerkontext
|
| 15 |
+
|
| 16 |
+
```python
|
| 17 |
+
@router.post("/some-endpoint")
|
| 18 |
+
async def endpoint(authenticated: bool = Depends(verify_hf_token)):
|
| 19 |
+
# Endpoint Logic
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### 2. `get_current_user()`
|
| 23 |
+
- **Zweck**: Optionale Benutzer-Extraktion
|
| 24 |
+
- **Rückgabe**: `Optional[Dict[str, Any]]` - Benutzerdaten oder None
|
| 25 |
+
- **Verwendung**: Für Endpoints, die optional Benutzerkontext nutzen können
|
| 26 |
+
|
| 27 |
+
```python
|
| 28 |
+
@router.post("/some-endpoint")
|
| 29 |
+
async def endpoint(
|
| 30 |
+
authenticated: bool = Depends(verify_hf_token),
|
| 31 |
+
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
| 32 |
+
):
|
| 33 |
+
if current_user:
|
| 34 |
+
user_id = current_user["id"]
|
| 35 |
+
username = current_user["username"]
|
| 36 |
+
# Endpoint Logic
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
### 3. `require_current_user()`
|
| 40 |
+
- **Zweck**: Pflicht-Benutzer-Authentifizierung
|
| 41 |
+
- **Rückgabe**: `Dict[str, Any]` - Benutzerdaten (oder 401 Error)
|
| 42 |
+
- **Verwendung**: Für Endpoints, die definitiv einen authentifizierten Benutzer benötigen
|
| 43 |
+
|
| 44 |
+
```python
|
| 45 |
+
@router.post("/user-only-endpoint")
|
| 46 |
+
async def endpoint(current_user: Dict[str, Any] = Depends(require_current_user)):
|
| 47 |
+
user_id = current_user["id"]
|
| 48 |
+
username = current_user["username"]
|
| 49 |
+
# Endpoint Logic
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## Token-Payload
|
| 53 |
+
|
| 54 |
+
Das JWT Token enthält minimale Informationen:
|
| 55 |
+
|
| 56 |
+
```json
|
| 57 |
+
{
|
| 58 |
+
"sub": "username", // Username für Datenbanksuche
|
| 59 |
+
"jti": "unique-session-id", // Session-Tracking
|
| 60 |
+
"exp": 1735689600, // Ablaufzeit
|
| 61 |
+
"iat": 1735603200, // Ausstellungszeit
|
| 62 |
+
"type": "access_token" // Token-Typ
|
| 63 |
+
}
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
**Wichtig**: Die `user_id` wird nicht mehr im Token gespeichert, sondern zur Laufzeit aus der Datenbank geholt.
|
| 67 |
+
|
| 68 |
+
## Neue Endpoints
|
| 69 |
+
|
| 70 |
+
### GET `/api/v1/auth/me`
|
| 71 |
+
Gibt Informationen über den aktuell authentifizierten Benutzer zurück.
|
| 72 |
+
|
| 73 |
+
**Authentifizierung**: JWT Token erforderlich
|
| 74 |
+
**Response**: UserResponse-Schema
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
curl -H "Authorization: Bearer <jwt_token>" \
|
| 78 |
+
http://localhost:8000/api/v1/auth/me
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## Sicherheitsverbesserungen
|
| 82 |
+
|
| 83 |
+
1. **Minimaler Token-Payload**: Sensible Daten werden nicht im Token gespeichert
|
| 84 |
+
2. **Session-Tracking**: Jeder Token ist mit einer Session in der Datenbank verknüpft
|
| 85 |
+
3. **Flexible Authentifizierung**: Verschiedene Dependency-Funktionen für verschiedene Anwendungsfälle
|
| 86 |
+
4. **Token-Widerruf**: Sessions können serverseitig widerrufen werden
|
| 87 |
+
|
| 88 |
+
## Best Practices
|
| 89 |
+
|
| 90 |
+
1. **SECRET_KEY sicher setzen**:
|
| 91 |
+
```bash
|
| 92 |
+
export SECRET_KEY=$(openssl rand -hex 32)
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
2. **Richtige Dependency wählen**:
|
| 96 |
+
- `verify_hf_token`: Grundlegende Auth ohne Benutzerkontext
|
| 97 |
+
- `get_current_user`: Optionaler Benutzerkontext
|
| 98 |
+
- `require_current_user`: Pflicht-Benutzerkontext
|
| 99 |
+
|
| 100 |
+
3. **Benutzer-Logging**: Nutzen Sie `current_user` für Audit-Logs
|
| 101 |
+
|
| 102 |
+
```python
|
| 103 |
+
if current_user:
|
| 104 |
+
logger.info(f"Action performed by user {current_user['username']} (ID: {current_user['id']})")
|
| 105 |
+
```
|
tests/test_authentication.py
CHANGED
|
@@ -120,7 +120,33 @@ def main():
|
|
| 120 |
"lang": "en"
|
| 121 |
},
|
| 122 |
"should_succeed": False
|
| 123 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
]
|
| 125 |
|
| 126 |
results = []
|
|
|
|
| 120 |
"lang": "en"
|
| 121 |
},
|
| 122 |
"should_succeed": False
|
| 123 |
+
},
|
| 124 |
+
|
| 125 |
+
# Test new user info endpoint
|
| 126 |
+
{
|
| 127 |
+
"name": "Get Current User (Valid JWT - after login)",
|
| 128 |
+
"endpoint": "/api/v1/auth/me",
|
| 129 |
+
"method": "GET",
|
| 130 |
+
"headers": {}, # Will be filled after login
|
| 131 |
+
"should_succeed": True,
|
| 132 |
+
"requires_jwt": True
|
| 133 |
+
},
|
| 134 |
+
|
| 135 |
+
{
|
| 136 |
+
"name": "Get Current User (No Auth)",
|
| 137 |
+
"endpoint": "/api/v1/auth/me",
|
| 138 |
+
"method": "GET",
|
| 139 |
+
"headers": no_auth_headers,
|
| 140 |
+
"should_succeed": False
|
| 141 |
+
},
|
| 142 |
+
|
| 143 |
+
{
|
| 144 |
+
"name": "Get Current User (HF API Key)",
|
| 145 |
+
"endpoint": "/api/v1/auth/me",
|
| 146 |
+
"method": "GET",
|
| 147 |
+
"headers": auth_headers,
|
| 148 |
+
"should_succeed": False # HF API key should not work for user endpoints
|
| 149 |
+
},
|
| 150 |
]
|
| 151 |
|
| 152 |
results = []
|