Вход через 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" YANDEX_REDIRECT = f"{os.getenv('SITE_BASE_URL', 'https://pdf2scan.online')}/api/auth/yandex/callback"
SESSION_COOKIE = "p2s_session" SESSION_COOKIE = "p2s_session"
SESSION_DAYS = 30 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 МБ) # Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ)
UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы
@@ -1902,6 +1906,81 @@ async def auth_logout(request: Request):
return resp 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> _API_DOCS_HTML = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>

View File

@@ -861,6 +861,10 @@
<span style="display:inline-flex;width:18px;height:18px;background:#FC3F1D;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;">Я</span> <span style="display:inline-flex;width:18px;height:18px;background:#FC3F1D;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:12px;">Я</span>
<span data-i18n="login_via_yandex">Через Яндекс</span> <span data-i18n="login_via_yandex">Через Яндекс</span>
</button> </button>
<button onclick="location.href='/api/auth/vk/start';" style="display:flex;align-items:center;gap:8px;width:100%;padding:9px 12px;background:none;border:none;border-radius:8px;cursor:pointer;font-family:inherit;font-size:14px;color:#1c2b45;text-align:left;">
<span style="display:inline-flex;width:22px;height:18px;background:#07f;color:#fff;border-radius:4px;align-items:center;justify-content:center;font-weight:700;font-size:11px;">VK</span>
<span data-i18n="login_via_vk">Через VK</span>
</button>
</div> </div>
<div id="tg-user-info" style="display:none;"> <div id="tg-user-info" style="display:none;">
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'"> <img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
@@ -1144,6 +1148,7 @@
login_btn: 'Войти', login_btn: 'Войти',
login_via_tg: 'Через Telegram', login_via_tg: 'Через Telegram',
login_via_yandex: 'Через Яндекс', login_via_yandex: 'Через Яндекс',
login_via_vk: 'Через VK',
err_login: 'Не удалось войти. Попробуйте снова.', err_login: 'Не удалось войти. Попробуйте снова.',
logout_btn: 'Выйти', logout_btn: 'Выйти',
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.', page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
@@ -1250,6 +1255,7 @@
login_btn: 'Login', login_btn: 'Login',
login_via_tg: 'Via Telegram', login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex', login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
err_login: 'Login failed. Please try again.', err_login: 'Login failed. Please try again.',
logout_btn: 'Logout', logout_btn: 'Logout',
page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.', page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.',
@@ -1357,6 +1363,7 @@
login_btn: 'Anmelden', login_btn: 'Anmelden',
login_via_tg: 'Über Telegram', login_via_tg: 'Über Telegram',
login_via_yandex: 'Über Yandex', login_via_yandex: 'Über Yandex',
login_via_vk: 'Über VK',
err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.', err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
logout_btn: 'Abmelden', logout_btn: 'Abmelden',
page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.', page_description: '🧷 Wählen Sie den Bindungstyp und optionale Einstellungen, laden Sie dann Ihre Datei hoch — erhalten Sie einen fertigen Scan.',
@@ -1464,6 +1471,7 @@
login_btn: 'Connexion', login_btn: 'Connexion',
login_via_tg: 'Via Telegram', login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex', login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
err_login: 'Échec de connexion. Réessayez.', err_login: 'Échec de connexion. Réessayez.',
logout_btn: 'Déconnexion', logout_btn: 'Déconnexion',
page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.', page_description: '🧷 Sélectionnez le type de reliure et les paramètres optionnels, puis téléchargez votre fichier — obtenez un scan prêt à l\'emploi.',
@@ -1571,6 +1579,7 @@
login_btn: 'Accedi', login_btn: 'Accedi',
login_via_tg: 'Via Telegram', login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex', login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
err_login: 'Accesso non riuscito. Riprova.', err_login: 'Accesso non riuscito. Riprova.',
logout_btn: 'Esci', logout_btn: 'Esci',
page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.', page_description: '🧷 Seleziona il tipo di rilegatura e le impostazioni opzionali, poi carica il file — ottieni una scansione pronta.',
@@ -1678,6 +1687,7 @@
login_btn: 'Iniciar sesión', login_btn: 'Iniciar sesión',
login_via_tg: 'Con Telegram', login_via_tg: 'Con Telegram',
login_via_yandex: 'Con Yandex', login_via_yandex: 'Con Yandex',
login_via_vk: 'Con VK',
err_login: 'Error de inicio de sesión. Inténtalo de nuevo.', err_login: 'Error de inicio de sesión. Inténtalo de nuevo.',
logout_btn: 'Cerrar sesión', logout_btn: 'Cerrar sesión',
page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.', page_description: '🧷 Selecciona el tipo de encuadernación y configuraciones opcionales, luego sube tu archivo — obtén un escaneo listo.',
@@ -1785,6 +1795,7 @@
login_btn: 'Entrar', login_btn: 'Entrar',
login_via_tg: 'Via Telegram', login_via_tg: 'Via Telegram',
login_via_yandex: 'Via Yandex', login_via_yandex: 'Via Yandex',
login_via_vk: 'Via VK',
err_login: 'Falha no login. Tente novamente.', err_login: 'Falha no login. Tente novamente.',
logout_btn: 'Sair', logout_btn: 'Sair',
page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.', page_description: '🧷 Selecione o tipo de encadernação e configurações opcionais, depois faça upload do arquivo — obtenha uma digitalização pronta.',