Вход через VK ID (OAuth 2.1 + PKCE)

- /api/auth/vk/{start,callback}: PKCE (code_verifier в cookie), обмен на
  id.vk.com/oauth2/auth + user_info, аккаунт vk:<id> + cookie-сессия
- Кнопка VK в меню входа, i18n

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-03 08:26:48 +00:00
parent 414a8fd0f4
commit e5c4d3c949
2 changed files with 90 additions and 0 deletions

View File

@@ -62,6 +62,10 @@ YANDEX_CLIENT_SECRET = os.getenv("YANDEX_CLIENT_SECRET", "")
YANDEX_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/yandex/callback"
SESSION_COOKIE = "p2s_session"
SESSION_DAYS = 30
# ── OAuth: VK ID (OAuth 2.1 / PKCE) ───────────────────────────────────────────
VK_CLIENT_ID = os.getenv("VK_CLIENT_ID", "")
VK_CLIENT_SECRET = os.getenv("VK_CLIENT_SECRET", "")
VK_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/vk/callback"
# Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ)
UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы
@@ -1902,6 +1906,81 @@ async def auth_logout(request: Request):
return resp
# ── OAuth: VK ID (OAuth 2.1 + PKCE) ───────────────────────────────────────────
def _pkce_pair():
verifier = secrets.token_urlsafe(48) # 43128 символов
challenge = _b64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
return verifier, challenge
def _vk_token_exchange(code: str, verifier: str, device_id: str) -> dict:
data = _urlparse.urlencode({
"grant_type": "authorization_code", "code": code, "code_verifier": verifier,
"client_id": VK_CLIENT_ID, "device_id": device_id, "redirect_uri": VK_REDIRECT,
}).encode()
req = _urlreq.Request("https://id.vk.com/oauth2/auth", data=data, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
with _urlreq.urlopen(req, timeout=20) as resp:
return _pjson.loads(resp.read().decode())
def _vk_userinfo(access_token: str) -> dict:
data = _urlparse.urlencode({"access_token": access_token, "client_id": VK_CLIENT_ID}).encode()
req = _urlreq.Request("https://id.vk.com/oauth2/user_info", data=data, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
with _urlreq.urlopen(req, timeout=20) as resp:
return _pjson.loads(resp.read().decode())
@app.get("/api/auth/vk/start")
async def vk_start():
if not VK_CLIENT_ID:
raise HTTPException(status_code=503, detail="Вход через VK не настроен.")
state = secrets.token_hex(16)
verifier, challenge = _pkce_pair()
url = ("https://id.vk.com/authorize?response_type=code"
f"&client_id={VK_CLIENT_ID}&redirect_uri={_urlparse.quote(VK_REDIRECT, safe='')}"
f"&state={state}&code_challenge={challenge}&code_challenge_method=S256&scope=email")
resp = RedirectResponse(url)
resp.set_cookie("p2s_vk_state", state, max_age=600, httponly=True, secure=True, samesite="lax", path="/")
resp.set_cookie("p2s_vk_verifier", verifier, max_age=600, httponly=True, secure=True, samesite="lax", path="/")
return resp
@app.get("/api/auth/vk/callback")
async def vk_callback(request: Request, code: str = "", state: str = "", device_id: str = ""):
if not code:
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
if state and request.cookies.get("p2s_vk_state") != state:
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
verifier = request.cookies.get("p2s_vk_verifier") or ""
try:
tok = await asyncio.to_thread(_vk_token_exchange, code, verifier, device_id)
access = tok.get("access_token")
vk_uid = str(tok.get("user_id") or "")
email = tok.get("email") or ""
info = await asyncio.to_thread(_vk_userinfo, access) if access else {}
except Exception as e:
print(f"vk oauth error: {e}")
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
user = (info.get("user") or {}) if isinstance(info, dict) else {}
if not vk_uid:
vk_uid = str(user.get("user_id") or "")
if not vk_uid:
return RedirectResponse(f"{SITE_BASE_URL}/?login=error")
email = email or user.get("email") or ""
name = ((user.get("first_name") or "") + " " + (user.get("last_name") or "")).strip() or "VK"
account_id = f"vk:{vk_uid}"
_db_ensure_account(account_id)
token = _db_create_session(account_id, "vk", name, email)
resp = RedirectResponse(f"{SITE_BASE_URL}/?login=vk")
resp.set_cookie(SESSION_COOKIE, token, max_age=SESSION_DAYS * 86400,
httponly=True, secure=True, samesite="lax", path="/")
resp.delete_cookie("p2s_vk_state", path="/")
resp.delete_cookie("p2s_vk_verifier", path="/")
return resp
_API_DOCS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>