Newer
Older
hard-panel / panel / backend / app / api / auth_routes.py
"""OAuth/session-роуты gnexus-auth — скроировано с gnexus-creds.

GET  /auth/login?return_to=/… — 302 на gnexus-auth (PKCE, state в oauth_states)
GET  /auth/callback?code&state — обмен кода, сессия в cookie, redirect на return_to
POST /auth/logout — удалить сессию
GET  /api/v1/me — профиль из сессии (для экрана входа и identity в дровере)
POST /webhooks/gnexus-auth(+/) — HMAC-верифицированные события gnexus-auth
"""

from datetime import datetime, timedelta, timezone
import json

from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from gnexus_gauth.exceptions import (
    StateValidationException,
    TokenExchangeException,
    TransportException,
)
from gnexus_gauth.oauth import AuthorizationUrlBuilder, PkceGenerator
from pydantic import BaseModel

from app.auth import (
    SCOPES,
    SESSION_COOKIE,
    allowlisted,
    auth_enabled,
    create_session,
    delete_session,
    exchange_and_fetch,
    get_ctx,
    safe_return_to,
)
from app.config import get_settings
from app.db import get_db
from app.security import auth_mode_error

router = APIRouter()
webhook_router = APIRouter()


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _now_iso() -> str:
    return _now().isoformat()


@router.get("/auth/login")
async def login(request: Request, return_to: str = "/") -> RedirectResponse:
    """Начало OAuth: state + PKCE → oauth_states, 302 на gnexus-auth."""
    settings = get_settings()
    return_to = safe_return_to(return_to)
    ctx = get_ctx()
    state = PkceGenerator.generate_state()
    verifier = PkceGenerator.generate_verifier()
    challenge = PkceGenerator.generate_challenge(verifier)
    db = get_db()
    await db.execute(
        "INSERT INTO oauth_states (state, pkce_verifier, return_to, expires_at) VALUES (?, ?, ?, ?)",
        (state, verifier, return_to, (_now() + timedelta(seconds=settings.session_ttl_seconds)).isoformat()),
    )
    await db.commit()
    url = AuthorizationUrlBuilder(ctx["config"]).build(
        state=state,
        pkce_challenge=challenge,
        return_to=return_to,
        scopes=SCOPES,
    )
    return RedirectResponse(url, status_code=302)


@router.get("/auth/callback")
async def callback(request: Request, code: str = "", state: str = ""):
    """Обмен кода на токен, сессия в cookie, редирект на return_to."""
    if not state or not code:
        raise auth_mode_error()
    settings = get_settings()
    db = get_db()
    cursor = await db.execute(
        "SELECT pkce_verifier, return_to, expires_at FROM oauth_states WHERE state = ?",
        (state,),
    )
    saved = await cursor.fetchone()
    if saved is None or saved["expires_at"] < _now_iso():
        return JSONResponse(
            {"detail": "Invalid or expired OAuth state."}, status_code=400
        )
    try:
        import asyncio

        _, user = await asyncio.to_thread(
            exchange_and_fetch, code, saved["pkce_verifier"]
        )
    except (TokenExchangeException, StateValidationException, TransportException):
        return JSONResponse(
            {"detail": "Authorization server is unreachable or rejected the login."},
            status_code=502,
        )
    if not allowlisted(user.email, user.user_id):
        return JSONResponse({"detail": "User is not allowed here."}, status_code=403)
    if user.status in {"disabled", "blocked", "deleted"}:
        return JSONResponse({"detail": "User is disabled."}, status_code=403)
    session_id = await create_session(user)
    # Успешный логин — пользователь жив и не заблокирован: снимаем флаг,
    # поставленный вебхуком user.blocked (иначе его некому снять).
    await db.execute("UPDATE users SET blocked = 0 WHERE user_id = ?", (user.user_id,))
    await db.commit()
    await db.execute("DELETE FROM oauth_states WHERE state = ?", (state,))
    await db.commit()
    response = RedirectResponse(safe_return_to(saved["return_to"]), status_code=302)
    response.set_cookie(
        SESSION_COOKIE,
        session_id,
        httponly=True,
        samesite="lax",
        max_age=settings.session_ttl_seconds,
        secure=bool(settings.auth_redirect_uri.startswith("https://")),
    )
    return response


@router.post("/auth/logout")
async def logout(request: Request) -> Response:
    """Удалить свою сессию (cookie гасится тоже)."""
    settings = get_settings()
    await delete_session(request.cookies.get(SESSION_COOKIE))
    response = JSONResponse({"status": "ok"})
    response.delete_cookie(
        SESSION_COOKIE,
        httponly=True,
        samesite="lax",
        secure=bool(settings.auth_redirect_uri.startswith("https://")),
    )
    return response


# --- /me для фронтенда (в api/v1 — тот же префикс SPA) -------------------------

me_router = APIRouter(prefix="/api/v1")


class LocaleUpdate(BaseModel):
    locale: str = ""


@me_router.get("/me", dependencies=[])
async def me(request: Request) -> dict:
    """Профиль текущего пользователя + флаг режима авторизации.

    Auth выключен → {auth_enabled: false} — SPA молча открывает панель.
    Включён и сессии нет → 401 (SPA показывает экран входа).
    Включён и сессия есть → {auth_enabled: true, user: …, locale, locale_effective}.
    """
    if not auth_enabled():
        return {"auth_enabled": False, "user": None, "locale_effective": None}
    from app.auth import get_session, get_user_row
    from app.locales import effective_locale

    session = await get_session(request.cookies.get(SESSION_COOKIE))
    if session is None:
        raise auth_mode_error()
    row = await get_user_row(session["user_id"])
    profile = json.loads(row["profile"]) if row and row["profile"] else {}
    override = row["locale"] if row else None
    return {
        "auth_enabled": True,
        "user": {
            "user_id": session["user_id"],
            "email": session["email"],
            "display_name": session["display_name"],
            "avatar_url": session["avatar_url"],
            "role": (row["system_role"] if row and row["system_role"] else None) or "user",
        },
        "locale": override,
        "locale_effective": effective_locale(override, profile),
    }


@me_router.patch("/me", dependencies=[])
async def update_me(request: Request, payload: LocaleUpdate) -> dict:
    """Пер-сервисный override языка панели (настройки → «Язык интерфейса»).

    Только cookie-сессия: у Bearer-токена админа нет личности, которой можно
    сохранить настройку, поэтому он здесь не проходит (401). 'auto' или пусто
    сбрасывают override (= следовать языку аккаунта gnexus-auth).
    """
    if not auth_enabled():
        raise auth_mode_error()
    from app.auth import get_session, get_user_row, set_user_locale
    from app.locales import effective_locale, normalize_locale

    session = await get_session(request.cookies.get(SESSION_COOKIE))
    if session is None:
        raise auth_mode_error()

    value = (payload.locale or "").strip().lower()
    override = None if value in ("", "auto") else normalize_locale(value)
    if value not in ("", "auto") and override is None:
        return JSONResponse({"detail": "unsupported locale"}, status_code=422)

    await set_user_locale(session["user_id"], override, email=session["email"],
                          display_name=session["display_name"])
    row = await get_user_row(session["user_id"])
    profile = json.loads(row["profile"]) if row and row["profile"] else {}
    return {
        "auth_enabled": True,
        "user": {
            "user_id": session["user_id"],
            "email": session["email"],
            "display_name": session["display_name"],
            "avatar_url": session["avatar_url"],
        },
        "locale": row["locale"] if row else None,
        "locale_effective": effective_locale(row["locale"] if row else None, profile),
    }


# --- Webhook gnexus-auth (HMAC) ------------------------------------------------

@webhook_router.post("/webhooks/gnexus-auth")
@webhook_router.post("/webhooks/gnexus-auth/")
async def gnexus_auth_webhook(request: Request) -> dict:
    from app.auth import apply_webhook_update, get_ctx

    settings = get_settings()
    raw = (await request.body()).decode("utf-8", errors="replace")
    headers = dict(request.headers)
    ctx = get_ctx()
    try:
        ctx["webhook"].verify(raw, headers, settings.auth_webhook_secret)
        event = ctx["parser"].parse(raw)
    except Exception:  # noqa: BLE001 — чужое/несанкционированное всегда 401
        return JSONResponse({"detail": "invalid webhook signature"}, status_code=401)
    subject = (
        event.target_identifiers.get("sub")
        or event.target_identifiers.get("user_id")
        or event.metadata.get("sub")
    )
    if subject:
        await apply_webhook_update(str(subject), event.event_type, event.metadata)
    return {"status": "ok"}


# /api/v1/me проверяет сессию без require_admin — он сам является gate-палкой.
# Остальное API защищается в security.py (routers подключаются ниже).