Вход через 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>
|
||||
|
||||
@@ -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 data-i18n="login_via_yandex">Через Яндекс</span>
|
||||
</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 id="tg-user-info" style="display:none;">
|
||||
<img id="tg-user-photo" src="" alt="" onerror="this.style.display='none'">
|
||||
@@ -1144,6 +1148,7 @@
|
||||
login_btn: 'Войти',
|
||||
login_via_tg: 'Через Telegram',
|
||||
login_via_yandex: 'Через Яндекс',
|
||||
login_via_vk: 'Через VK',
|
||||
err_login: 'Не удалось войти. Попробуйте снова.',
|
||||
logout_btn: 'Выйти',
|
||||
page_description: '🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан.',
|
||||
@@ -1250,6 +1255,7 @@
|
||||
login_btn: 'Login',
|
||||
login_via_tg: 'Via Telegram',
|
||||
login_via_yandex: 'Via Yandex',
|
||||
login_via_vk: 'Via VK',
|
||||
err_login: 'Login failed. Please try again.',
|
||||
logout_btn: 'Logout',
|
||||
page_description: '🧷 Select binding type and optional settings, then upload your file — get a ready scan.',
|
||||
@@ -1357,6 +1363,7 @@
|
||||
login_btn: 'Anmelden',
|
||||
login_via_tg: 'Über Telegram',
|
||||
login_via_yandex: 'Über Yandex',
|
||||
login_via_vk: 'Über VK',
|
||||
err_login: 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
|
||||
logout_btn: 'Abmelden',
|
||||
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_via_tg: 'Via Telegram',
|
||||
login_via_yandex: 'Via Yandex',
|
||||
login_via_vk: 'Via VK',
|
||||
err_login: 'Échec de connexion. Réessayez.',
|
||||
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.',
|
||||
@@ -1571,6 +1579,7 @@
|
||||
login_btn: 'Accedi',
|
||||
login_via_tg: 'Via Telegram',
|
||||
login_via_yandex: 'Via Yandex',
|
||||
login_via_vk: 'Via VK',
|
||||
err_login: 'Accesso non riuscito. Riprova.',
|
||||
logout_btn: 'Esci',
|
||||
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_via_tg: 'Con Telegram',
|
||||
login_via_yandex: 'Con Yandex',
|
||||
login_via_vk: 'Con VK',
|
||||
err_login: 'Error de inicio de sesión. Inténtalo de nuevo.',
|
||||
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.',
|
||||
@@ -1785,6 +1795,7 @@
|
||||
login_btn: 'Entrar',
|
||||
login_via_tg: 'Via Telegram',
|
||||
login_via_yandex: 'Via Yandex',
|
||||
login_via_vk: 'Via VK',
|
||||
err_login: 'Falha no login. Tente novamente.',
|
||||
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.',
|
||||
|
||||
Reference in New Issue
Block a user