"""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 подключаются ниже).