from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any import bcrypt import jwt from fastapi import Depends, HTTPException, Security, status from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.database import get_db _bearer = HTTPBearer(auto_error=False) _api_key = APIKeyHeader(name="X-API-Token", auto_error=False) def _password_bytes(password: str) -> bytes: # bcrypt limit is 72 bytes; truncate UTF-8 safely for hashing raw = password.encode("utf-8") return raw[:72] def hash_password(password: str) -> str: return bcrypt.hashpw(_password_bytes(password), bcrypt.gensalt()).decode("ascii") def verify_password(password: str, password_hash: str) -> bool: try: return bcrypt.checkpw(_password_bytes(password), password_hash.encode("ascii")) except Exception: return False def _jwt_secret() -> str: settings = get_settings() return (settings.jwt_secret or settings.api_token or "changeme").strip() def create_access_token(username: str, user_id: int) -> str: settings = get_settings() expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expire_hours) payload = {"sub": username, "uid": user_id, "exp": expire} return jwt.encode(payload, _jwt_secret(), algorithm="HS256") def decode_access_token(token: str) -> dict[str, Any]: try: return jwt.decode(token, _jwt_secret(), algorithms=["HS256"]) except jwt.PyJWTError as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", ) from e def require_auth( bearer: HTTPAuthorizationCredentials | None = Security(_bearer), api_key: str | None = Security(_api_key), db: Session = Depends(get_db), ) -> dict[str, Any]: """Accept JWT from login, or legacy shared API_TOKEN.""" from app.models import User settings = get_settings() raw = None if bearer and bearer.credentials: raw = bearer.credentials elif api_key: raw = api_key if not raw: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Требуется вход", ) # Legacy shared token (scripts / migration) if settings.api_token and raw == settings.api_token: return {"username": "api_token", "uid": 0, "legacy": True} payload = decode_access_token(raw) username = str(payload.get("sub") or "") uid = int(payload.get("uid") or 0) user = db.scalar(select(User).where(User.id == uid)) if uid else None if not user or user.username != username: user = db.scalar(select(User).where(User.username == username)) if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") return {"username": user.username, "uid": user.id, "legacy": False} # Back-compat alias used by older imports require_token = require_auth AuthDep = Depends(require_auth) def ensure_admin_user(db: Session) -> None: from app.models import User settings = get_settings() username = (settings.admin_username or "admin").strip() or "admin" password = (settings.admin_password or settings.api_token or "changeme").strip() or "changeme" user = db.scalar(select(User).where(User.username == username)) if user: return db.add(User(username=username, password_hash=hash_password(password))) db.commit()