211 lines
7.6 KiB
Python
211 lines
7.6 KiB
Python
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 GOOGLE_CLIENT_ID as _GOOGLE_CLIENT_ID
|
|
except ImportError:
|
|
_GOOGLE_CLIENT_ID = ""
|
|
try:
|
|
from config import GOOGLE_CLIENT_SECRET as _GOOGLE_CLIENT_SECRET
|
|
except ImportError:
|
|
_GOOGLE_CLIENT_SECRET = ""
|
|
try:
|
|
from config import GOOGLE_REDIRECT_URI as _GOOGLE_REDIRECT_URI
|
|
except ImportError:
|
|
_GOOGLE_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 _GOOGLE_STATE_SECRET
|
|
except ImportError:
|
|
_GOOGLE_STATE_SECRET = "solo-google-state-fallback"
|
|
|
|
|
|
GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
|
|
GOOGLE_USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo"
|
|
STATE_TTL_SECONDS = 600
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def google_configured() -> bool:
|
|
return bool(_GOOGLE_CLIENT_ID and _GOOGLE_CLIENT_SECRET and _GOOGLE_REDIRECT_URI)
|
|
|
|
|
|
def _sign_state(payload: str) -> str:
|
|
mac = hmac.new(str(_GOOGLE_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("/google/authorize")
|
|
async def google_authorize(
|
|
request: Request,
|
|
return_to: str = Query(default=""),
|
|
):
|
|
"""Начинает OAuth-флоу Google: редиректит юзера на Google consent screen."""
|
|
if not google_configured():
|
|
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
|
|
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
|
|
state = _make_state(safe_return)
|
|
params = {
|
|
"client_id": _GOOGLE_CLIENT_ID,
|
|
"redirect_uri": _GOOGLE_REDIRECT_URI,
|
|
"response_type": "code",
|
|
"scope": "openid email profile",
|
|
"state": state,
|
|
"access_type": "online",
|
|
"prompt": "select_account",
|
|
}
|
|
url = f"{GOOGLE_AUTH_ENDPOINT}?{urlencode(params)}"
|
|
logger.info("[Auth] Google authorize: ip={}", _client_ip(request))
|
|
return RedirectResponse(url, status_code=302)
|
|
|
|
|
|
@router.get("/google/callback")
|
|
async def google_callback(
|
|
request: Request,
|
|
response: Response,
|
|
code: str = Query(default=""),
|
|
state: str = Query(default=""),
|
|
error: str = Query(default=""),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Коллбек Google: обмен code → token → userinfo → identity."""
|
|
if not google_configured():
|
|
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
|
|
if error:
|
|
logger.warning("[Auth] Google callback error: {} ip={}", error, _client_ip(request))
|
|
return RedirectResponse(f"/login?error=google_{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] Google 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(
|
|
GOOGLE_TOKEN_ENDPOINT,
|
|
data={
|
|
"code": code,
|
|
"client_id": _GOOGLE_CLIENT_ID,
|
|
"client_secret": _GOOGLE_CLIENT_SECRET,
|
|
"redirect_uri": _GOOGLE_REDIRECT_URI,
|
|
"grant_type": "authorization_code",
|
|
},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
except httpx.HTTPError as e:
|
|
logger.warning("[Auth] Google token exchange network error: {}", e)
|
|
raise HTTPException(status_code=502, detail="Не удалось связаться с Google") from e
|
|
if token_res.status_code != 200:
|
|
logger.warning("[Auth] Google token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
|
|
raise HTTPException(status_code=401, detail="Google отклонил токен")
|
|
token_payload = token_res.json()
|
|
access_token = token_payload.get("access_token")
|
|
if not access_token:
|
|
raise HTTPException(status_code=401, detail="Google не вернул access_token")
|
|
|
|
try:
|
|
info_res = await client.get(
|
|
GOOGLE_USERINFO_ENDPOINT,
|
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
)
|
|
except httpx.HTTPError as e:
|
|
logger.warning("[Auth] Google userinfo network error: {}", e)
|
|
raise HTTPException(status_code=502, detail="Не удалось получить профиль Google") from e
|
|
|
|
if info_res.status_code != 200:
|
|
raise HTTPException(status_code=401, detail="Google не вернул профиль")
|
|
info = info_res.json()
|
|
google_sub = str(info.get("sub") or "").strip()
|
|
email = (info.get("email") or "").strip().lower() or None
|
|
email_verified = bool(info.get("email_verified"))
|
|
if not google_sub:
|
|
raise HTTPException(status_code=401, detail="Google не вернул идентификатор пользователя")
|
|
|
|
identity = await idb.get_or_create_identity_for_google(
|
|
session,
|
|
google_sub=google_sub,
|
|
email=email if (email and email_verified) else None,
|
|
)
|
|
await bind_identity_actor(request, session, identity)
|
|
token = await idb.issue_token_for_identity(session, identity)
|
|
logger.info(
|
|
"[Auth] Login success: identity={}, google_sub={}, ip={}, method=google",
|
|
identity.id,
|
|
google_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("/google/status")
|
|
async def google_status():
|
|
"""Позволяет фронтенду узнать, настроен ли Google-вход на этом сервере."""
|
|
return {"enabled": google_configured()}
|