diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..98c2609 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,170 @@ +"""User authentication and management system""" +import json +import os +import hashlib +import hmac +from datetime import datetime, timedelta +from typing import Optional, Dict +from passlib.context import CryptContext +import logging + +logger = logging.getLogger(__name__) + +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# JWT Secret key - SHOULD BE CONFIGURED VIA ENV +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days + +# Users database file +USERS_DB_FILE = "config/users.json" + + +class UserManager: + """Manages user storage and authentication""" + + def __init__(self, db_file: str = USERS_DB_FILE): + self.db_file = db_file + self.users: Dict[str, dict] = {} + self._load_users() + + def _load_users(self): + """Load users from JSON file""" + try: + if os.path.exists(self.db_file): + with open(self.db_file, 'r', encoding='utf-8') as f: + self.users = json.load(f) + logger.info(f"Loaded {len(self.users)} users from database") + except Exception as e: + logger.error(f"Error loading users: {e}") + self.users = {} + + def _save_users(self): + """Save users to JSON file""" + try: + os.makedirs(os.path.dirname(self.db_file), exist_ok=True) + with open(self.db_file, 'w', encoding='utf-8') as f: + json.dump(self.users, f, indent=2, ensure_ascii=False, default=str) + logger.info(f"Saved {len(self.users)} users to database") + except Exception as e: + logger.error(f"Error saving users: {e}") + + def get_user(self, username: str) -> Optional[dict]: + """Get user by username""" + return self.users.get(username) + + def get_user_by_id(self, user_id: str) -> Optional[dict]: + """Get user by ID""" + for user in self.users.values(): + if user.get('id') == user_id: + return user + return None + + def create_user(self, username: str, password: str, email: str = None, full_name: str = None) -> dict: + """Create a new user""" + if username in self.users: + raise ValueError(f"Username '{username}' already exists") + + # Truncate password to 72 bytes if necessary (bcrypt limitation) + password_bytes = password.encode('utf-8') + if len(password_bytes) > 72: + password = password_bytes[:72].decode('utf-8', errors='ignore') + + # Hash password + hashed_password = pwd_context.hash(password) + + # Create user + user = { + "id": hashlib.sha256(username.encode()).hexdigest()[:32], + "username": username, + "email": email, + "full_name": full_name, + "hashed_password": hashed_password, + "is_active": True, + "created_at": datetime.now().isoformat(), + "last_login": None + } + + self.users[username] = user + self._save_users() + + logger.info(f"Created user: {username}") + return user + + def authenticate_user(self, username: str, password: str) -> Optional[dict]: + """Authenticate user with username and password""" + user = self.get_user(username) + if not user: + return None + + if not pwd_context.verify(password, user["hashed_password"]): + return None + + # Update last login + user["last_login"] = datetime.now().isoformat() + self._save_users() + + return user + + def update_last_login(self, username: str): + """Update user's last login time""" + user = self.get_user(username) + if user: + user["last_login"] = datetime.now().isoformat() + self._save_users() + + +# Global user manager instance +user_manager = UserManager() + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash a password for storage""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: timedelta = None) -> str: + """Create JWT access token""" + from jose import jwt + + 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}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + return encoded_jwt + + +def verify_token(token: str) -> Optional[str]: + """Verify JWT token and return username""" + from jose import jwt + from jose.exceptions import JWTError + + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return None + return username + except JWTError: + return None + + +def get_current_user(token: str) -> Optional[dict]: + """Get current user from JWT token""" + username = verify_token(token) + if username: + return user_manager.get_user(username) + return None diff --git a/app/models/auth.py b/app/models/auth.py new file mode 100644 index 0000000..9079d37 --- /dev/null +++ b/app/models/auth.py @@ -0,0 +1,40 @@ +"""Authentication models for user management""" +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime + + +class UserCreate(BaseModel): + """Schema for user registration""" + username: str = Field(..., min_length=3, max_length=50) + email: Optional[EmailStr] = None + password: str = Field(..., min_length=6) + full_name: Optional[str] = None + + +class UserLogin(BaseModel): + """Schema for user login""" + username: str + password: str + + +class User(BaseModel): + """Schema for user data""" + id: str + username: str + email: Optional[str] = None + full_name: Optional[str] = None + is_active: bool = True + created_at: datetime + last_login: Optional[datetime] = None + + +class Token(BaseModel): + """Schema for authentication token""" + access_token: str + token_type: str = "bearer" + + +class UserInDB(User): + """Schema for user stored in database (with hashed password)""" + hashed_password: str diff --git a/config/users.json b/config/users.json new file mode 100644 index 0000000..495f023 --- /dev/null +++ b/config/users.json @@ -0,0 +1,72 @@ +{ + "testuser": { + "id": "ae5deb822e0d71992900471a7199d0d9", + "username": "testuser", + "email": "test@example.com", + "full_name": "Test User", + "hashed_password": "$2b$12$gDgt6xCBS4y2FgNrCk0JU.cn8SPwrNo6vIebDSQlkfeDmvP43safy", + "is_active": true, + "created_at": "2026-01-26T11:32:14.262592", + "last_login": "2026-01-26T12:18:26.818435" + }, + "apitest": { + "id": "e81cbf18a5239377aa4972773d34cc2b", + "username": "apitest", + "email": "apitest@example.com", + "full_name": "API Test User", + "hashed_password": "$2b$12$sJWQhQ0S/rMX3VJiEOMstuusfPgCvXN8zq/lCnKocL28PRomX9RJ6", + "is_active": true, + "created_at": "2026-01-26T11:32:46.943188", + "last_login": "2026-01-26T11:32:47.140656" + }, + "testuser_final": { + "id": "2b4aade7e46060f88e36ae92ba767545", + "username": "testuser_final", + "email": "final@test.com", + "full_name": "Final Test User", + "hashed_password": "$2b$12$wN7Saj99c4B39O5Y2XNQ4eVuPm7o6b8eeJ1TxFrvy5.g7ycyh9rKm", + "is_active": true, + "created_at": "2026-01-26T11:33:45.726090", + "last_login": "2026-01-26T11:33:46.548491" + }, + "webtest": { + "id": "2cae3fde0b88cf1274fe58ec039302cc", + "username": "webtest", + "email": null, + "full_name": null, + "hashed_password": "$2b$12$2Rr32QkYCj05GGAOQGua0umCHYRyPnvcDVXPbYaSu5SmYaohXi08a", + "is_active": true, + "created_at": "2026-01-26T11:44:09.995999", + "last_login": "2026-01-26T11:44:10.190329" + }, + "roman": { + "id": "4eaae75f1df2f52bda44f6b18a400542", + "username": "roman", + "email": null, + "full_name": null, + "hashed_password": "$2b$12$IC9kz7kxf1mQPhsdveFnyOX3V5Q1.pB9/uqCKWI7nhn.SYamtvxCC", + "is_active": true, + "created_at": "2026-01-26T12:15:58.008205", + "last_login": "2026-01-29T17:23:44.242173" + }, + "testuser999": { + "id": "f9abf4b8aa96d5116807ac1cf8540418", + "username": "testuser999", + "email": null, + "full_name": null, + "hashed_password": "$2b$12$y2uy62IR0xVmCcUmQ8gL6.nkvFthjyuRGxtSKh6CD5soey6T/IFu6", + "is_active": true, + "created_at": "2026-01-26T12:18:26.623497", + "last_login": null + }, + "flowtest": { + "id": "4b797133389d3f5042f13aac323a8840", + "username": "flowtest", + "email": "flow@test.com", + "full_name": null, + "hashed_password": "$2b$12$Dcb7fKZPycLRsW851m9pk.1ZeyHcX65PAnb5HqLY74cJKonUfDDOC", + "is_active": true, + "created_at": "2026-01-26T12:18:50.138613", + "last_login": "2026-01-26T12:18:50.332004" + } +} \ No newline at end of file diff --git a/main.py b/main.py index dd7c64f..7a1bbf4 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,18 @@ -from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException, Query, Request +from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException, Query, Request, Depends, status from fastapi.responses import StreamingResponse, FileResponse, JSONResponse, Response from fastapi.responses import HTMLResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import uvicorn import logging from pathlib import Path -from typing import List +from typing import List, Optional import shutil import os import re -from datetime import datetime +from datetime import datetime, timedelta from urllib.parse import quote logger = logging.getLogger(__name__) @@ -30,8 +31,13 @@ from app.models.sonarr import ( SonarrMapping, SonarrDownloadRequest ) +from app.models.auth import UserCreate, UserLogin, User, Token +from app.auth import user_manager, create_access_token, verify_token, get_current_user from app.utils import sanitize_filename, is_safe_filename +# Security +security = HTTPBearer() + app = FastAPI(title="Ohm Stream Downloader") # Configure CORS @@ -151,12 +157,180 @@ async def health(): return {"status": "healthy"} +# ==================== AUTHENTICATION API ==================== + +async def get_current_user_from_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + """Dependency to get current user from JWT token""" + token = credentials.credentials + username = verify_token(token) + + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = user_manager.get_user(username) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + + +@app.post("/api/auth/register") +async def register(user_data: UserCreate): + """ + Register a new user + + Creates a new user account with username and password. + Returns the user info without the hashed password. + """ + try: + # Check if user already exists + existing_user = user_manager.get_user(user_data.username) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered" + ) + + # Create user + user = user_manager.create_user( + username=user_data.username, + password=user_data.password, + email=user_data.email, + full_name=user_data.full_name + ) + + # Remove password from response + user_response = User( + id=user["id"], + username=user["username"], + email=user.get("email"), + full_name=user.get("full_name"), + is_active=user["is_active"], + created_at=datetime.fromisoformat(user["created_at"]), + last_login=datetime.fromisoformat(user["last_login"]) if user.get("last_login") else None + ) + + return { + "status": "success", + "message": "User registered successfully", + "user": user_response + } + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error registering user: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to register user" + ) + + +@app.post("/api/auth/login") +async def login(form_data: UserLogin): + """ + Login user and return JWT token + + Authenticates user with username and password. + Returns a JWT access token valid for 7 days. + """ + user = user_manager.authenticate_user(form_data.username, form_data.password) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.get("is_active", True): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is disabled" + ) + + # Create access token + access_token = create_access_token( + data={"sub": user["username"]}, + expires_delta=timedelta(days=7) + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "user": { + "id": user["id"], + "username": user["username"], + "email": user.get("email"), + "full_name": user.get("full_name") + } + } + + +@app.get("/api/auth/me") +async def get_me(current_user: dict = Depends(get_current_user_from_token)): + """ + Get current user information + + Returns information about the currently authenticated user. + Requires valid JWT token in Authorization header. + """ + return { + "user": { + "id": current_user["id"], + "username": current_user["username"], + "email": current_user.get("email"), + "full_name": current_user.get("full_name"), + "is_active": current_user.get("is_active", True), + "created_at": current_user.get("created_at"), + "last_login": current_user.get("last_login") + } + } + + +@app.post("/api/auth/logout") +async def logout(): + """ + Logout user (client-side only) + + Since JWT tokens are stateless, logout is handled client-side + by simply removing the token from storage. + + This endpoint exists for API consistency and future extensions. + """ + return { + "status": "success", + "message": "Logout successful. Please remove the token from client storage." + } + + +# ==================== PROTECTED ENDPOINTS EXAMPLE ==================== +# Example of how to protect existing endpoints: +# Add: current_user: dict = Depends(get_current_user_from_token) parameter + # Web Interface @app.get("/web") async def web_interface(request: Request): return templates.TemplateResponse("index.html", {"request": request}) +@app.get("/login") +async def login_page(request: Request): + """Login/Register page""" + return templates.TemplateResponse("login.html", {"request": request}) + + # API Endpoints @app.post("/api/download") async def create_download(request: DownloadRequest, background_tasks: BackgroundTasks): diff --git a/requirements.txt b/requirements.txt index 83a68fa..e2fc92d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,8 @@ pytest-cov==6.0.0 pytest-mock==3.14.0 pytest-timeout==2.3.1 pytest-html==4.1.1 + +# Authentication dependencies +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 +bcrypt<4.0 diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000..15e9e6b --- /dev/null +++ b/static/js/auth.js @@ -0,0 +1,142 @@ +/** + * Authentication management for web interface + */ + +// Use relative path for API +const AUTH_API_BASE = '/api'; + +// Check if user is authenticated +async function checkAuth() { + const token = localStorage.getItem('auth_token'); + const userStr = localStorage.getItem('user'); + + if (!token) { + // Redirect to login page instead of just showing prompt + redirectToLogin(); + return false; + } + + // Verify token with server + try { + const response = await fetch(`${AUTH_API_BASE}/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + showUserInfo(data.user); + showMainContent(); + return true; + } else { + // Token invalid, remove it and redirect + localStorage.removeItem('auth_token'); + localStorage.removeItem('user'); + redirectToLogin(); + return false; + } + } catch (error) { + console.error('Auth check error:', error); + // On error, redirect to login + redirectToLogin(); + return false; + } +} + +// Redirect to login page +function redirectToLogin() { + // Only redirect if not already on login page + if (!window.location.pathname.includes('/login')) { + window.location.href = '/login'; + } +} + +// Show user info when authenticated +function showUserInfo(user) { + const userInfo = document.getElementById('userInfo'); + const loginPrompt = document.getElementById('loginPrompt'); + const mainTabs = document.getElementById('mainTabs'); + const currentUser = document.getElementById('currentUser'); + + if (userInfo) userInfo.style.display = 'flex'; + if (loginPrompt) loginPrompt.style.display = 'none'; + if (mainTabs) mainTabs.style.visibility = 'visible'; + if (currentUser) currentUser.textContent = user.full_name || user.username; +} + +// Show main content (only when authenticated) +function showMainContent() { + const mainContent = document.getElementById('main-content'); + if (mainContent) mainContent.style.display = 'block'; +} + +// Hide main content (when not authenticated) +function hideMainContent() { + const mainContent = document.getElementById('main-content'); + if (mainContent) mainContent.style.display = 'none'; +} + +// Show login prompt when not authenticated (not used anymore - we redirect instead) +function showLoginPrompt() { + const userInfo = document.getElementById('userInfo'); + const loginPrompt = document.getElementById('loginPrompt'); + const mainTabs = document.getElementById('mainTabs'); + + if (userInfo) userInfo.style.display = 'none'; + if (loginPrompt) loginPrompt.style.display = 'block'; + if (mainTabs) mainTabs.style.visibility = 'hidden'; + + // Hide main content + hideMainContent(); +} + +// Handle logout +async function handleLogout() { + if (!confirm('Êtes-vous sûr de vouloir vous déconnecter?')) { + return; + } + + // Remove token from localStorage + localStorage.removeItem('auth_token'); + localStorage.removeItem('user'); + + // Call logout endpoint + try { + await fetch(`${AUTH_API_BASE}/auth/logout`, { method: 'POST' }); + } catch (error) { + console.error('Logout error:', error); + } + + // Redirect to login page + window.location.href = '/login'; +} + +// Add authorization header to all fetch requests +function addAuthHeader(options = {}) { + const token = localStorage.getItem('auth_token'); + if (token) { + options.headers = options.headers || {}; + options.headers['Authorization'] = `Bearer ${token}`; + } + return options; +} + +// Wrapper for fetch with auth +async function authFetch(url, options = {}) { + options = addAuthHeader(options); + return fetch(url, options); +} + +// Make functions available globally +window.checkAuth = checkAuth; +window.showUserInfo = showUserInfo; +window.showLoginPrompt = showLoginPrompt; +window.handleLogout = handleLogout; +window.authFetch = authFetch; +window.addAuthHeader = addAuthHeader; + +// Check authentication on page load +document.addEventListener('DOMContentLoaded', () => { + checkAuth(); +}); diff --git a/templates/base.html b/templates/base.html index ccfec6b..dce88df 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,15 +9,16 @@ - - - - - - - - - + + + + + + + + + +
diff --git a/templates/components/header.html b/templates/components/header.html index 2d3f7b1..9388947 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -1,8 +1,24 @@

⚡ Ohm Stream Downloader

Téléchargez vos vidéos, animes et séries depuis vos hébergeurs préférés

- -
+ + + + + + + + + + {% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..349351d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,333 @@ + + + + + + Connexion - Ohm Stream Downloader + + + + +
+

🎬 Ohm Stream

+ +
+
Connexion
+
Inscription
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + +