import base64 import hashlib import hmac import secrets import time from urllib.parse import urlencode import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from api.depends import ( bind_identity_actor, get_session, set_auth_cookie, set_is_admin_cookie, ) from api.v2.routes.auth._common import _client_ip from database import identities as idb from logger import logger try: from config import YANDEX_CLIENT_ID as _YANDEX_CLIENT_ID except ImportError: _YANDEX_CLIENT_ID = "" try: from config import YANDEX_CLIENT_SECRET as _YANDEX_CLIENT_SECRET except ImportError: _YANDEX_CLIENT_SECRET = "" try: from config import YANDEX_REDIRECT_URI as _YANDEX_REDIRECT_URI except ImportError: _YANDEX_REDIRECT_URI = "" try: from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI except ImportError: _OAUTH_SUCCESS_URI = "/dashboard" try: from config import API_TOKEN as _YANDEX_STATE_SECRET except ImportError: _YANDEX_STATE_SECRET = "solo-yandex-state-fallback" YANDEX_AUTH_ENDPOINT = "https://oauth.yandex.ru/authorize" YANDEX_TOKEN_ENDPOINT = "https://oauth.yandex.ru/token" YANDEX_USERINFO_ENDPOINT = "https://login.yandex.ru/info" STATE_TTL_SECONDS = 600 router = APIRouter() def yandex_configured() -> bool: return bool(_YANDEX_CLIENT_ID and _YANDEX_CLIENT_SECRET and _YANDEX_REDIRECT_URI) def _sign_state(payload: str) -> str: mac = hmac.new(str(_YANDEX_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest() return base64.urlsafe_b64encode(mac).decode().rstrip("=") def _make_state(return_to: str) -> str: nonce = secrets.token_urlsafe(16) ts = str(int(time.time())) payload = f"{nonce}.{ts}.{return_to}" sig = _sign_state(payload) raw = f"{payload}.{sig}".encode() return base64.urlsafe_b64encode(raw).decode().rstrip("=") def _verify_state(state: str) -> str | None: try: padded = state + "=" * (-len(state) % 4) raw = base64.urlsafe_b64decode(padded).decode() except Exception: return None parts = raw.rsplit(".", 1) if len(parts) != 2: return None payload, sig = parts expected = _sign_state(payload) if not hmac.compare_digest(sig, expected): return None chunks = payload.split(".", 2) if len(chunks) != 3: return None _nonce, ts, return_to = chunks try: if int(time.time()) - int(ts) > STATE_TTL_SECONDS: return None except Exception: return None return return_to or _OAUTH_SUCCESS_URI @router.get("/yandex/authorize") async def yandex_authorize( request: Request, return_to: str = Query(default=""), ): """Начинает OAuth-флоу Яндекс ID: редиректит юзера на consent screen.""" if not yandex_configured(): raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере") safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI state = _make_state(safe_return) params = { "client_id": _YANDEX_CLIENT_ID, "redirect_uri": _YANDEX_REDIRECT_URI, "response_type": "code", "state": state, "force_confirm": "yes", } url = f"{YANDEX_AUTH_ENDPOINT}?{urlencode(params)}" logger.info("[Auth] Yandex authorize: ip={}", _client_ip(request)) return RedirectResponse(url, status_code=302) @router.get("/yandex/callback") async def yandex_callback( request: Request, response: Response, code: str = Query(default=""), state: str = Query(default=""), error: str = Query(default=""), session: AsyncSession = Depends(get_session), ): """Коллбек Яндекс ID: обмен code → token → userinfo → identity.""" if not yandex_configured(): raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере") if error: logger.warning("[Auth] Yandex callback error: {} ip={}", error, _client_ip(request)) return RedirectResponse(f"/login?error=yandex_{error}", status_code=302) if not code or not state: raise HTTPException(status_code=400, detail="Отсутствует code или state") return_to = _verify_state(state) if return_to is None: logger.warning("[Auth] Yandex callback: invalid/expired state ip={}", _client_ip(request)) raise HTTPException(status_code=400, detail="Неверный или просроченный state") async with httpx.AsyncClient(timeout=10.0) as client: try: token_res = await client.post( YANDEX_TOKEN_ENDPOINT, data={ "grant_type": "authorization_code", "code": code, "client_id": _YANDEX_CLIENT_ID, "client_secret": _YANDEX_CLIENT_SECRET, }, headers={"Accept": "application/json"}, ) except httpx.HTTPError as e: logger.warning("[Auth] Yandex token exchange network error: {}", e) raise HTTPException(status_code=502, detail="Не удалось связаться с Яндекс") from e if token_res.status_code != 200: logger.warning("[Auth] Yandex token exchange failed: {} {}", token_res.status_code, token_res.text[:200]) raise HTTPException(status_code=401, detail="Яндекс отклонил токен") token_payload = token_res.json() access_token = token_payload.get("access_token") if not access_token: raise HTTPException(status_code=401, detail="Яндекс не вернул access_token") try: info_res = await client.get( YANDEX_USERINFO_ENDPOINT, headers={"Authorization": f"OAuth {access_token}"}, params={"format": "json"}, ) except httpx.HTTPError as e: logger.warning("[Auth] Yandex userinfo network error: {}", e) raise HTTPException(status_code=502, detail="Не удалось получить профиль Яндекс") from e if info_res.status_code != 200: raise HTTPException(status_code=401, detail="Яндекс не вернул профиль") info = info_res.json() yandex_sub = str(info.get("id") or "").strip() email = (info.get("default_email") or "").strip().lower() or None if not yandex_sub: raise HTTPException(status_code=401, detail="Яндекс не вернул идентификатор пользователя") identity = await idb.get_or_create_identity_for_yandex( session, yandex_sub=yandex_sub, email=email, ) await bind_identity_actor(request, session, identity) token = await idb.issue_token_for_identity(session, identity, request=request) logger.info( "[Auth] Login success: identity={}, yandex_sub={}, ip={}, method=yandex", identity.id, yandex_sub, _client_ip(request), ) redirect = RedirectResponse(return_to, status_code=302) set_auth_cookie(redirect, token, request) set_is_admin_cookie(redirect, identity, request) return redirect @router.get("/yandex/status") async def yandex_status(): """Позволяет фронтенду узнать, настроен ли вход через Яндекс на этом сервере.""" return {"enabled": yandex_configured()}