from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext from pydantic import BaseModel from sqlalchemy.orm import Session from app.database import get_db from app.models import UserModel SECRET_KEY = "change-me-in-production" ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 120 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") # ---------- Pydantic schemas ---------- class Token(BaseModel): access_token: str token_type: str = "bearer" class TokenData(BaseModel): username: Optional[str] = None class UserOut(BaseModel): id: int username: str email: Optional[str] = None role: str disabled: bool force_password_change: bool created_at: datetime class Config: from_attributes = True # ---------- Crypto helpers ---------- def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed) def hash_password(plain: str) -> str: return pwd_context.hash(plain) def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: to_encode = data.copy() expire = datetime.now(timezone.utc) + ( expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) ) to_encode.update({"exp": expire}) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) # ---------- DB helpers ---------- def get_user_by_username(db: Session, username: str) -> Optional[UserModel]: return db.query(UserModel).filter(UserModel.username == username).first() def authenticate_user(db: Session, username: str, password: str) -> Optional[UserModel]: user = get_user_by_username(db, username) if not user or not verify_password(password, user.hashed_password): return None return user def ensure_default_admin(db: Session) -> None: """Seed a default admin user if the users table is empty (first-run bootstrap).""" if db.query(UserModel).count() == 0: db.add(UserModel( username="admin", hashed_password=hash_password("admin"), role="admin", disabled=False, force_password_change=False, )) db.add(UserModel( username="viewer", hashed_password=hash_password("viewer"), role="viewer", disabled=False, force_password_change=False, )) db.commit() # ---------- FastAPI dependencies ---------- def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), ) -> UserModel: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") if not username: raise credentials_exception except JWTError: raise credentials_exception user = get_user_by_username(db, username) if user is None or user.disabled: raise credentials_exception return user def get_current_active_user(current_user: UserModel = Depends(get_current_user)) -> UserModel: if current_user.disabled: raise HTTPException(status_code=400, detail="Inactive user") return current_user def require_role(*roles: str): """Dependency factory: require one of the given roles.""" def _check(current_user: UserModel = Depends(get_current_active_user)) -> UserModel: if current_user.role not in roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Required role: {' or '.join(roles)}", ) return current_user return _check