Ajout authentification JWT complète (app 100% protégée)

Backend:
- Nouveau module auth.py avec JWT et password handling
- Endpoint /api/auth/login (public)
- Endpoint /api/auth/me (protégé)
- TOUS les endpoints API protégés par require_auth
- Variables env: ADMIN_EMAIL, ADMIN_PASSWORD, JWT_SECRET_KEY
- Dependencies: python-jose, passlib

Frontend:
- Page de login (/login)
- AuthGuard component pour redirection automatique
- Axios interceptor: ajoute JWT token à chaque requête
- Gestion erreur 401: redirect automatique vers /login
- Bouton logout dans header
- Token stocké dans localStorage

Configuration:
- .env.example mis à jour avec variables auth
- Credentials admin configurables via env

Sécurité:
- Aucun endpoint public (sauf /api/auth/login et /health)
- JWT expiration configurable (24h par défaut)
- Password en clair dans env (à améliorer avec hash en prod)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-26 10:05:36 +01:00
parent 6ae861ff54
commit 774cb799a2
13 changed files with 522 additions and 11 deletions

View File

@@ -27,6 +27,10 @@ pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# Utilities
aiofiles==23.2.1
httpx==0.26.0

View File

@@ -1,14 +1,15 @@
"""FastAPI main application."""
from fastapi import FastAPI
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from ..utils.config import settings
from ..utils.logging import setup_logging, get_logger
from ..models.database import engine, Base
from ..core.auth import require_auth
# Import routes
from .routes import tracks, search, audio, analyze, similar, stats, library
from .routes import tracks, search, audio, analyze, similar, stats, library, auth
# Setup logging
setup_logging()
@@ -62,13 +63,17 @@ async def health_check():
# Include routers
app.include_router(tracks.router, prefix="/api/tracks", tags=["tracks"])
app.include_router(search.router, prefix="/api/search", tags=["search"])
app.include_router(audio.router, prefix="/api/audio", tags=["audio"])
app.include_router(analyze.router, prefix="/api/analyze", tags=["analyze"])
app.include_router(similar.router, prefix="/api", tags=["similar"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
app.include_router(library.router, prefix="/api/library", tags=["library"])
# Auth endpoints (public - no auth required)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
# Protected endpoints (auth required for ALL routes)
app.include_router(tracks.router, prefix="/api/tracks", tags=["tracks"], dependencies=[Depends(require_auth)])
app.include_router(search.router, prefix="/api/search", tags=["search"], dependencies=[Depends(require_auth)])
app.include_router(audio.router, prefix="/api/audio", tags=["audio"], dependencies=[Depends(require_auth)])
app.include_router(analyze.router, prefix="/api/analyze", tags=["analyze"], dependencies=[Depends(require_auth)])
app.include_router(similar.router, prefix="/api", tags=["similar"], dependencies=[Depends(require_auth)])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"], dependencies=[Depends(require_auth)])
app.include_router(library.router, prefix="/api/library", tags=["library"], dependencies=[Depends(require_auth)])
@app.get("/", tags=["root"])

View File

@@ -0,0 +1,82 @@
"""Authentication endpoints."""
from datetime import timedelta
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel, EmailStr
from ...core.auth import authenticate_user, create_access_token, get_current_user
from ...utils.config import settings
from ...utils.logging import get_logger
router = APIRouter()
logger = get_logger(__name__)
class LoginRequest(BaseModel):
"""Login request model."""
email: EmailStr
password: str
class LoginResponse(BaseModel):
"""Login response model."""
access_token: str
token_type: str = "bearer"
user: dict
class UserResponse(BaseModel):
"""User response model."""
email: str
role: str
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest):
"""Authenticate user and return JWT token.
Args:
request: Login credentials
Returns:
Access token and user info
Raises:
HTTPException: 401 if credentials are invalid
"""
user = authenticate_user(request.email, request.password)
if not user:
logger.warning(f"Failed login attempt for: {request.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token_expires = timedelta(hours=settings.JWT_EXPIRATION_HOURS)
access_token = create_access_token(
data={"sub": user["email"], "role": user["role"]},
expires_delta=access_token_expires
)
logger.info(f"User logged in: {user['email']}")
return {
"access_token": access_token,
"token_type": "bearer",
"user": user
}
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: dict = Depends(get_current_user)):
"""Get current authenticated user info.
Args:
current_user: Current user from JWT token
Returns:
User information
"""
return current_user

151
backend/src/core/auth.py Normal file
View File

@@ -0,0 +1,151 @@
"""Authentication utilities."""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from ..utils.config import settings
from ..utils.logging import get_logger
logger = get_logger(__name__)
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# HTTP Bearer for JWT
security = HTTPBearer()
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
Returns:
True if password matches
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password.
Args:
password: Plain text password
Returns:
Hashed password
"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token.
Args:
data: Data to encode in token
expires_delta: Token expiration time
Returns:
JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRATION_HOURS)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm="HS256")
return encoded_jwt
def verify_token(token: str) -> dict:
"""Verify and decode JWT token.
Args:
token: JWT token string
Returns:
Decoded token payload
Raises:
HTTPException: If token is invalid
"""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
return payload
except JWTError as e:
logger.error(f"Token verification failed: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def authenticate_user(email: str, password: str) -> Optional[dict]:
"""Authenticate user with email and password.
Args:
email: User email
password: User password
Returns:
User data if authenticated, None otherwise
"""
# Check against admin credentials from environment
if email == settings.ADMIN_EMAIL and password == settings.ADMIN_PASSWORD:
return {
"email": email,
"role": "admin"
}
return None
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""Get current authenticated user from JWT token.
Args:
credentials: HTTP Bearer credentials
Returns:
User data from token
Raises:
HTTPException: If authentication fails
"""
token = credentials.credentials
payload = verify_token(token)
email: str = payload.get("sub")
if email is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return {
"email": email,
"role": payload.get("role", "user")
}
async def require_auth(current_user: dict = Depends(get_current_user)) -> dict:
"""Dependency to require authentication.
Args:
current_user: Current user from get_current_user
Returns:
Current user data
"""
return current_user

View File

@@ -21,6 +21,12 @@ class Settings(BaseSettings):
ESSENTIA_MODELS_PATH: str = "./models"
AUDIO_LIBRARY_PATH: str = "/audio"
# Authentication
ADMIN_EMAIL: str = "admin@example.com"
ADMIN_PASSWORD: str = "changeme"
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_EXPIRATION_HOURS: int = 24
# Application
APP_NAME: str = "Audio Classifier API"
APP_VERSION: str = "1.0.0"