diff --git a/.env.example b/.env.example index cea872a..f240d55 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,12 @@ ANALYSIS_NUM_WORKERS=4 ESSENTIA_MODELS_PATH=/app/models AUDIO_LIBRARY_PATH=/path/to/your/audio/library +# Authentication +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=changeme +JWT_SECRET_KEY=your-super-secret-jwt-key-change-this-in-production +JWT_EXPIRATION_HOURS=24 + # Frontend # API URL accessed by the browser (use port 8001 since backend is mapped to 8001) # For production on a remote server, set this to your server's public URL diff --git a/backend/requirements.txt b/backend/requirements.txt index f388193..2390c17 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/src/api/main.py b/backend/src/api/main.py index e74835f..b63ef35 100644 --- a/backend/src/api/main.py +++ b/backend/src/api/main.py @@ -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"]) diff --git a/backend/src/api/routes/auth.py b/backend/src/api/routes/auth.py new file mode 100644 index 0000000..d8b9409 --- /dev/null +++ b/backend/src/api/routes/auth.py @@ -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 diff --git a/backend/src/core/auth.py b/backend/src/core/auth.py new file mode 100644 index 0000000..354a004 --- /dev/null +++ b/backend/src/core/auth.py @@ -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 diff --git a/backend/src/utils/config.py b/backend/src/utils/config.py index dde2ffd..30dc188 100644 --- a/backend/src/utils/config.py +++ b/backend/src/utils/config.py @@ -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" diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 4023c5d..55607bd 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next" import { Inter } from "next/font/google" import "./globals.css" import { QueryProvider } from "@/components/providers/QueryProvider" +import AuthGuard from "@/components/AuthGuard" import Script from "next/script" const inter = Inter({ subsets: ["latin"] }) @@ -23,7 +24,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..e04f30b --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,124 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { getApiUrl } from "@/lib/api" + +export default function LoginPage() { + const router = useRouter() + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setIsLoading(true) + + try { + const response = await fetch(`${getApiUrl()}/api/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.detail || "Login failed") + } + + const data = await response.json() + + // Store token in localStorage + localStorage.setItem("access_token", data.access_token) + localStorage.setItem("user", JSON.stringify(data.user)) + + // Redirect to home + router.push("/") + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed") + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+ {/* Logo/Title */} +
+

+ Audio Classifier +

+

Sign in to continue

+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Login form */} +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="admin@example.com" + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="••••••••" + disabled={isLoading} + /> +
+ + +
+
+ + {/* Footer */} +

+ Audio Classifier v1.0.0 +

+
+
+ ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 12b46b6..9d68459 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -3,6 +3,7 @@ import { useState, useMemo } from "react" import { useQuery } from "@tanstack/react-query" import { getTracks, getApiUrl } from "@/lib/api" +import { logout, getUser } from "@/lib/auth" import type { FilterParams, Track } from "@/lib/types" import FilterPanel from "@/components/FilterPanel" import AudioPlayer from "@/components/AudioPlayer" @@ -160,6 +161,18 @@ export default function Home() { {tracksData?.total || 0} piste{(tracksData?.total || 0) > 1 ? 's' : ''} + {/* Logout button */} + + {/* Rescan button */}