feat: Add authentication system and admin panel

- Add User model with authentication
- Create JWT-based auth service
- Add login page with admin/admin default credentials
- Create admin panel with user management, settings, and site configuration
- Move settings to admin section (admin only)
- Change search page from multi-site checkboxes to single site selector
- Add logout functionality to layout
- Protect routes with authentication
This commit is contained in:
Claude committed 2025-11-23 09:15:56 +00:00
1 parent 5e5f49a5f6
commit 1dd6d4dae1
11 files changed
+1758 -84

No files matched your search

+36 -5
View File
@@ -13,9 +13,9 @@ from sqlalchemy import text
from app.database import SessionLocal, engine
from app.limiter import limiter
from app.routers import items, jobs, notifications, openrouter, search, search_sites, settings
from app.routers import auth, items, jobs, notifications, openrouter, search, search_sites, settings
from app.services.scheduler_service import scheduled_refresh, scheduler
from app.services import search_service
from app.services import auth_service, search_service
# Configure logging
logging.basicConfig(
@@ -28,6 +28,27 @@ logger = logging.getLogger(__name__)
def run_migrations():
"""Run database migrations for missing tables and columns"""
with engine.connect() as conn:
# 0. Create users table if it doesn't exist
result = conn.execute(text(
"SELECT 1 FROM information_schema.tables WHERE table_name = 'users'"
))
if not result.fetchone():
logger.info("Creating users table...")
conn.execute(text("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(512) NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)
"""))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)"))
conn.commit()
logger.info("users table created")
# 1. Create search_sites table if it doesn't exist
result = conn.execute(text(
"SELECT 1 FROM information_schema.tables WHERE table_name = 'search_sites'"
@@ -81,20 +102,27 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Migration error: {e}")
# Seed default sites
logger.info("Seeding default search sites...")
# Seed default sites and admin user
logger.info("Seeding default data...")
try:
db = SessionLocal()
try:
# Seed search sites
created = search_service.seed_default_sites(db)
if created > 0:
logger.info(f"{created} site(s) de recherche créé(s)")
else:
logger.info("Sites de recherche déjà initialisés")
# Seed default admin user
if auth_service.seed_default_admin(db):
logger.info("Utilisateur admin par défaut créé (admin/admin)")
else:
logger.info("Utilisateurs déjà initialisés")
finally:
db.close()
except Exception as e:
logger.error(f"Error seeding sites: {e}")
logger.error(f"Error seeding data: {e}")
logger.info("Starting smart scheduler (Heartbeat: 1 minute)")
scheduler.add_job(scheduled_refresh, IntervalTrigger(minutes=1), id="refresh_job", replace_existing=True)
@@ -142,6 +170,9 @@ for router in [notifications.router, items.router, settings.router, jobs.router,
app.include_router(search.router)
app.include_router(search_sites.router)
# Auth router (already has /api prefix)
app.include_router(auth.router)
@app.get("/api/")
def read_root():
+13
View File
@@ -6,6 +6,19 @@ from sqlalchemy.orm import relationship
from .database import Base
class User(Base):
"""User model for authentication"""
__tablename__ = "users"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
username: str = Column(String, unique=True, nullable=False, index=True) # type: ignore
password_hash: str = Column(String, nullable=False) # type: ignore
is_admin: bool = Column(Boolean, default=False) # type: ignore
is_active: bool = Column(Boolean, default=True) # type: ignore
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
last_login: datetime | None = Column(DateTime, nullable=True) # type: ignore
class NotificationProfile(Base):
__tablename__ = "notification_profiles"
+306
View File
@@ -0,0 +1,306 @@
"""
Authentication router for login, user management, and admin functions.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app import models
from app.database import SessionLocal
from app.services import auth_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Pydantic models
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
token: str
username: str
is_admin: bool
class UserResponse(BaseModel):
id: int
username: str
is_admin: bool
is_active: bool
created_at: str
last_login: str | None
class Config:
from_attributes = True
class CreateUserRequest(BaseModel):
username: str
password: str
is_admin: bool = False
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class AdminChangePasswordRequest(BaseModel):
new_password: str
def get_current_user(
authorization: Annotated[str | None, Header()] = None,
db: Session = Depends(get_db),
) -> models.User:
"""Dependency to get current authenticated user from JWT token."""
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Non authentifié",
)
# Extract token from "Bearer <token>" format
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Format de token invalide",
)
token = parts[1]
payload = auth_service.decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token invalide ou expiré",
)
user = auth_service.get_user_by_id(db, payload["user_id"])
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Utilisateur non trouvé ou désactivé",
)
return user
def get_admin_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""Dependency to require admin privileges."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Accès administrateur requis",
)
return current_user
@router.post("/login", response_model=LoginResponse)
def login(request: LoginRequest, db: Session = Depends(get_db)):
"""Authenticate user and return JWT token."""
user = auth_service.authenticate_user(db, request.username, request.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nom d'utilisateur ou mot de passe incorrect",
)
# Update last login
auth_service.update_last_login(db, user)
# Create token
token = auth_service.create_token(user.id, user.username, user.is_admin)
logger.info(f"User '{user.username}' logged in successfully")
return LoginResponse(
token=token,
username=user.username,
is_admin=user.is_admin,
)
@router.get("/me", response_model=UserResponse)
def get_current_user_info(current_user: models.User = Depends(get_current_user)):
"""Get current user information."""
return UserResponse(
id=current_user.id,
username=current_user.username,
is_admin=current_user.is_admin,
is_active=current_user.is_active,
created_at=current_user.created_at.isoformat() if current_user.created_at else "",
last_login=current_user.last_login.isoformat() if current_user.last_login else None,
)
@router.post("/change-password")
def change_password(
request: ChangePasswordRequest,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Change current user's password."""
# Verify current password
if not auth_service.verify_password(request.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mot de passe actuel incorrect",
)
# Update password
auth_service.update_password(db, current_user, request.new_password)
logger.info(f"User '{current_user.username}' changed password")
return {"message": "Mot de passe modifié avec succès"}
# Admin endpoints
@router.get("/users", response_model=list[UserResponse])
def list_users(
admin_user: models.User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
"""List all users (admin only)."""
users = auth_service.get_all_users(db)
return [
UserResponse(
id=u.id,
username=u.username,
is_admin=u.is_admin,
is_active=u.is_active,
created_at=u.created_at.isoformat() if u.created_at else "",
last_login=u.last_login.isoformat() if u.last_login else None,
)
for u in users
]
@router.post("/users", response_model=UserResponse)
def create_user(
request: CreateUserRequest,
admin_user: models.User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
"""Create a new user (admin only)."""
# Check if username already exists
existing = auth_service.get_user_by_username(db, request.username)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ce nom d'utilisateur existe déjà",
)
user = auth_service.create_user(db, request.username, request.password, request.is_admin)
logger.info(f"Admin '{admin_user.username}' created user '{user.username}'")
return UserResponse(
id=user.id,
username=user.username,
is_admin=user.is_admin,
is_active=user.is_active,
created_at=user.created_at.isoformat() if user.created_at else "",
last_login=None,
)
@router.delete("/users/{user_id}")
def delete_user(
user_id: int,
admin_user: models.User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
"""Delete a user (admin only)."""
# Prevent self-deletion
if user_id == admin_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Vous ne pouvez pas supprimer votre propre compte",
)
success = auth_service.delete_user(db, user_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Utilisateur non trouvé",
)
logger.info(f"Admin '{admin_user.username}' deleted user ID {user_id}")
return {"message": "Utilisateur supprimé"}
@router.put("/users/{user_id}/toggle-active", response_model=UserResponse)
def toggle_user_active(
user_id: int,
admin_user: models.User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
"""Toggle user active status (admin only)."""
# Prevent self-deactivation
if user_id == admin_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Vous ne pouvez pas désactiver votre propre compte",
)
user = auth_service.toggle_user_active(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Utilisateur non trouvé",
)
logger.info(f"Admin '{admin_user.username}' toggled active status for user '{user.username}'")
return UserResponse(
id=user.id,
username=user.username,
is_admin=user.is_admin,
is_active=user.is_active,
created_at=user.created_at.isoformat() if user.created_at else "",
last_login=user.last_login.isoformat() if user.last_login else None,
)
@router.put("/users/{user_id}/password")
def admin_change_user_password(
user_id: int,
request: AdminChangePasswordRequest,
admin_user: models.User = Depends(get_admin_user),
db: Session = Depends(get_db),
):
"""Change a user's password (admin only)."""
user = auth_service.get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Utilisateur non trouvé",
)
auth_service.update_password(db, user, request.new_password)
logger.info(f"Admin '{admin_user.username}' changed password for user '{user.username}'")
return {"message": "Mot de passe modifié avec succès"}
+150
View File
@@ -0,0 +1,150 @@
"""
Authentication service for user management and JWT tokens.
"""
import hashlib
import os
import secrets
from datetime import UTC, datetime, timedelta
import jwt
from sqlalchemy.orm import Session
from app import models
# JWT Configuration
JWT_SECRET = os.getenv("JWT_SECRET", secrets.token_hex(32))
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = 24
# Default admin credentials
DEFAULT_ADMIN_USERNAME = "admin"
DEFAULT_ADMIN_PASSWORD = "admin"
def hash_password(password: str) -> str:
"""Hash password using SHA-256 with salt."""
salt = secrets.token_hex(16)
password_hash = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
return f"{salt}:{password_hash}"
def verify_password(password: str, password_hash: str) -> bool:
"""Verify password against hash."""
try:
salt, stored_hash = password_hash.split(":")
computed_hash = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
return computed_hash == stored_hash
except ValueError:
return False
def create_token(user_id: int, username: str, is_admin: bool) -> str:
"""Create JWT token for authenticated user."""
payload = {
"user_id": user_id,
"username": username,
"is_admin": is_admin,
"exp": datetime.now(UTC) + timedelta(hours=JWT_EXPIRATION_HOURS),
"iat": datetime.now(UTC),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict | None:
"""Decode and validate JWT token."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def get_user_by_username(db: Session, username: str) -> models.User | None:
"""Get user by username."""
return db.query(models.User).filter(models.User.username == username).first()
def get_user_by_id(db: Session, user_id: int) -> models.User | None:
"""Get user by ID."""
return db.query(models.User).filter(models.User.id == user_id).first()
def authenticate_user(db: Session, username: str, password: str) -> models.User | None:
"""Authenticate user with username and password."""
user = get_user_by_username(db, username)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
if not user.is_active:
return None
return user
def create_user(
db: Session,
username: str,
password: str,
is_admin: bool = False,
) -> models.User:
"""Create a new user."""
password_hash = hash_password(password)
user = models.User(
username=username,
password_hash=password_hash,
is_admin=is_admin,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
def update_password(db: Session, user: models.User, new_password: str) -> None:
"""Update user password."""
user.password_hash = hash_password(new_password)
db.commit()
def update_last_login(db: Session, user: models.User) -> None:
"""Update user's last login timestamp."""
user.last_login = datetime.now(UTC)
db.commit()
def get_all_users(db: Session) -> list[models.User]:
"""Get all users."""
return db.query(models.User).all()
def delete_user(db: Session, user_id: int) -> bool:
"""Delete a user by ID."""
user = get_user_by_id(db, user_id)
if not user:
return False
db.delete(user)
db.commit()
return True
def toggle_user_active(db: Session, user_id: int) -> models.User | None:
"""Toggle user active status."""
user = get_user_by_id(db, user_id)
if not user:
return None
user.is_active = not user.is_active
db.commit()
db.refresh(user)
return user
def seed_default_admin(db: Session) -> bool:
"""Create default admin user if no users exist."""
user_count = db.query(models.User).count()
if user_count == 0:
create_user(db, DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD, is_admin=True)
return True
return False