Вход через 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:
79
app/main.py
79
app/main.py
@@ -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) # 43–128 символов
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user