mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-22 22:58:11 +02:00
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:
11 files changed
+1758
-84
No files matched your search
+36
-5
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user