avtopost.kultmost.ru/token_manager.py
2026-08-02 12:08:08 -04:00

431 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import json
import re # регистронезависимый поиск email при VK-регистрации
import hashlib
import base64
from datetime import datetime, timedelta, UTC
import httpx
import logging
from bson import ObjectId
from config import *
from db import mongo_client, redis_client, gridfs_map, social_collection
import asyncio
import secrets
from utils import fetch_bytes, get_password_hash
import io
import uuid
from utils import encrypt, decrypt
# ---------------------- Константы OAuth ----------------------
OAUTH_URL = 'https://id.vk.com/oauth2/auth'
AUTHORIZE_URL = 'https://id.vk.ru/authorize'
SCOPES = 'offline,wall,photos,groups,email' # email — для регистрации через VK
REDIRECT_URL = f'{FULL_DOMAIN}/api/v1/social/vk/token'
class TokenManager:
"""
Управление VK-токенами с шифрованием чувствительных данных.
"""
def __init__(self):
self.tokens_coll = mongo_client["vk"]["tokens"]
async def get_auth_url(self, user_id: str = None, purpose: str = "link", next: str = None) -> str:
"""purpose: "link" (привязка соцсети) или "register" (вход/регистрация через VK);
next — куда вернуть пользователя после успешного входа."""
state = os.urandom(32).hex()
code_verifier = os.urandom(50).hex()
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('utf-8')).digest()
).decode('utf-8').rstrip('=')
oauth_data = {
"user_id": user_id,
"code_verifier": code_verifier,
"purpose": purpose,
"next": next,
}
await redis_client.setex(f"oauth_state:{state}", 60 * 15, json.dumps(oauth_data))
url = (
f"{AUTHORIZE_URL}?"
f"response_type=code&client_id={CLIENT_ID}&scope={SCOPES}"
f"&redirect_uri={REDIRECT_URL}&state={state}"
f"&code_challenge={code_challenge}&code_challenge_method=S256"
)
return url
async def save_vk(self, contents):
grid = gridfs_map.get('social')
filename = str(uuid.uuid4())
stream = io.BytesIO(contents)
return await grid.upload_from_stream(filename, stream)
async def get_token(self, code: str, state: str, device_id: str = None) -> dict | None:
"""
Принимает `code` (выданный VK после авторизации) и `state`
(проверка, что запрос пришёл от того же клиента).
Возвращает словарь токенов или `None` в случае ошибки.
"""
# Проверка обязательных параметров
if not state or not code:
logging.error("[TokenManager] Missing code or state")
return None
# Читаем данные из Redis, ранее сохранённые в get_auth_url()
key = f"oauth_state:{state}"
oauth_data_raw = await redis_client.get(key)
if not oauth_data_raw:
logging.error(f"[TokenManager] State not found or expired: {state}")
return None
oauth_data = json.loads(oauth_data_raw)
user_id = oauth_data.get("user_id")
purpose = oauth_data.get("purpose", "link")
next_url = oauth_data.get("next")
created = False
code_verifier = oauth_data["code_verifier"]
# Выполняем запрос к VK для обмена кода на токены
try:
async with httpx.AsyncClient(timeout=15.0, verify=ctx, http2=True) as client:
response = await client.post(
OAUTH_URL,
data={
'grant_type': 'authorization_code',
'code': code,
'code_verifier': code_verifier,
'client_id': CLIENT_ID,
'device_id': device_id or "",
'redirect_uri': REDIRECT_URL,
# Генерируем новый state только для логирования/отслеживания.
'state': os.urandom(32).hex(),
}
)
response.raise_for_status()
token_data = response.json()
access_token = token_data['access_token']
reqp = await client.post(f"{VK_API_URI}users.get", data={
"access_token": access_token,
"fields": "photo_max_orig,screen_name",
"v": "5.199",
})
vk_profile = reqp.json()['response'][0]
# Режим регистрации: находим или создаём пользователя ДО сохранения соцсетей,
# чтобы дальше socials/tokens сохранились уже с реальным user_id.
if purpose == "register" and not user_id:
user_id, created = await self._resolve_user_for_register(
vk_profile, token_data.get("email")
)
reqg = await client.post(f"{VK_API_URI}groups.get", data={
'access_token':access_token,
'extended':1,
'filter':'admin,editor,moder',
'v':'5.199',
})
vk_groups = reqg.json()['response']['items']
urls = [vk_profile['photo_max_orig']] + [i['photo_200'] for i in vk_groups]
image_bytes = await asyncio.gather(*(fetch_bytes(client, u) for u in urls))
# В режиме регистрации ставим аватар VK в профиль, если фото не задано вручную
if purpose == "register" and user_id and image_bytes:
await self._maybe_set_vk_avatar(user_id, created, image_bytes[0])
results = [await self.save_vk(i) for i in image_bytes]
socials = [{
'name': f"{vk_profile['last_name']} {vk_profile['first_name']}",
'screen_name': vk_profile['screen_name'],
'id': vk_profile['id'],
'img': results[0],
'user_id': user_id,
'active': True,
'type': 'vk',
}]
for i,group in enumerate(vk_groups):
socials.append({
'name': group['name'],
'screen_name': group['screen_name'],
'id': group['id'],
'group': True,
'type': 'vk',
'active': False, # True if len(vk_groups) < 4 else False,
'img': results[i+1],
'user_id': user_id
})
for doc in socials:
await mongo_client['social']['social'].update_one({'id': doc['id'],'user_id': doc['user_id']},
{'$set': doc},upsert=True)
# Сохраняем полученные токены (Mongo + Redis)
await self.save_tokens(user_id, token_data, device_id, client_id=CLIENT_ID)
# Удаляем использованный state, чтобы его нельзя было переиспользовать.
await redis_client.delete(key)
return {
"user_id": user_id,
"registered": created,
"purpose": purpose,
"next": next_url,
}
# Обработка ошибок HTTP (напр., неверный код, истёк скоп)
except httpx.HTTPStatusError as e:
logging.error(f"[TokenManager] Token exchange failed: {e.response.text}")
# Любые прочие исключения (сеть, тайм‑аут и т.д.)
except Exception as e:
logging.error(f"[TokenManager] Token exchange error: {e}")
# На любой ошибке гарантируем удаление state (чистка)
await redis_client.delete(key)
return None
async def _resolve_user_for_register(self, vk_profile: dict, email: str | None):
"""Находит пользователя по vk_id или email, иначе создаёт нового.
Возвращает кортеж (user_id: str, created: bool).
При совпадении по email — привязывает vk_id к найденному аккаунту.
"""
users = mongo_client["auth"]["users"]
vk_id = vk_profile.get("id")
# 1) Уже привязанный VK-аккаунт
existing = await social_collection.find_one({'id':vk_id},{'user_id':1})
if existing:
return existing["user_id"], False
existing = await users.find_one({"vk_id": vk_id}, {"_id": 1})
if existing:
return str(existing["_id"]), False
# 2) Существующий аккаунт с таким email — логиним и привязываем VK
if email:
existing = await users.find_one(
{"email": {"$regex": f"^{re.escape(email)}$", "$options": "i"}},
{"_id": 1},
)
if existing:
await users.update_one({"_id": existing["_id"]}, {"$set": {"vk_id": vk_id}})
return str(existing["_id"]), False
# 3) Создаём нового пользователя (роль по умолчанию — художник)
first = vk_profile.get("first_name", "") or ""
last = vk_profile.get("last_name", "") or ""
doc = {
"username": f"{first} {last}".strip(),
"female_name_lastname": f"{last} {first}".strip(),
"email": email,
"phone": None,
"img": "/static/painter.svg",
"roles": ["user"],
"extra_scopes": [],
"vk_id": vk_id,
"client_id": "vk",
"living_place": None,
"offer_accepted": False,
# Неиспользуемый случайный пароль: вход по email/паролю невозможен,
# но login-эндпоинт не падает на None.startswith(...).
"hashed_password": get_password_hash(secrets.token_urlsafe(32)),
}
res = await users.insert_one(doc)
return str(res.inserted_id), True
async def _maybe_set_vk_avatar(self, user_id: str, created: bool, avatar_bytes: bytes):
"""Ставит аватар из VK в профиль, если пользователь не задал своё фото.
«Не задан вручную» = img пустой или дефолтная заглушка (/static/...).
Для только что созданного пользователя ставим всегда.
"""
if not avatar_bytes:
return
users = mongo_client["auth"]["users"]
if not created:
u = await users.find_one({"_id": ObjectId(user_id)}, {"img": 1})
img = (u or {}).get("img")
if img and not str(img).startswith("/static/"):
return # у пользователя уже своё фото — не трогаем
try:
grid = gridfs_map["users"]
file_id = await grid.upload_from_stream(f"vk_{user_id}.jpg", io.BytesIO(avatar_bytes))
await users.update_one({"_id": ObjectId(user_id)}, {"$set": {"img": str(file_id)}})
except Exception as e:
logging.error(f"[TokenManager] VK avatar save failed: {e}")
# ====================== ШИФРОВАНИЕ ======================
async def _encrypt_token_fields(self, token_data: dict) -> dict:
"""Шифрует access_token и refresh_token."""
encrypted = token_data.copy()
if "access_token" in encrypted and isinstance(encrypted["access_token"], (str, bytes)):
at = encrypted["access_token"].encode("utf-8") if isinstance(encrypted["access_token"], str) else encrypted["access_token"]
encrypted["encrypted_access_token"] = encrypt(at)
encrypted.pop("access_token", None)
if "refresh_token" in encrypted and isinstance(encrypted["refresh_token"], (str, bytes)):
rt = encrypted["refresh_token"].encode("utf-8") if isinstance(encrypted["refresh_token"], str) else encrypted["refresh_token"]
encrypted["encrypted_refresh_token"] = encrypt(rt)
encrypted.pop("refresh_token", None)
return encrypted
async def _decrypt_token_fields(self, token_doc: dict) -> dict:
"""Расшифровывает токены."""
decrypted = dict(token_doc)
if "encrypted_access_token" in decrypted:
try:
decrypted["access_token"] = decrypt(decrypted["encrypted_access_token"]).decode("utf-8")
decrypted.pop("encrypted_access_token", None)
except Exception:
pass
if "encrypted_refresh_token" in decrypted:
try:
decrypted["refresh_token"] = decrypt(decrypted["encrypted_refresh_token"]).decode("utf-8")
decrypted.pop("encrypted_refresh_token", None)
except Exception:
pass
return decrypted
async def save_tokens(self, user_id: str, token_data: dict, device_id: str = None, client_id: int = CLIENT_ID):
expires_in = token_data.get("expires_in", 3600)
expires_at = datetime.now(UTC) + timedelta(seconds=expires_in)
encrypted_data = await self._encrypt_token_fields(token_data)
doc = encrypted_data.copy()
uid = ObjectId(user_id)
doc.update({
"user_id": uid,
"expires_at": expires_at,
"client_id": client_id or CLIENT_ID,
"device_id": device_id,
})
token_json = json.dumps({
**{k: v for k, v in doc.items() if k not in ["expires_at", "user_id"]},
"expires_at": expires_at.isoformat(),
"user_id": user_id,
})
await redis_client.setex(f"tokens:{user_id}", 60 * 60 * 24 * 60, token_json)
# Зашифрованный access_token
if "access_token" in token_data:
at_bytes = token_data["access_token"].encode("utf-8") if isinstance(token_data["access_token"], str) else token_data["access_token"]
enc_access = encrypt(at_bytes)
await redis_client.setex(f"access_token:{user_id}", expires_in - 60, enc_access)
doc.pop('user_id', None)
await self.tokens_coll.update_one(
{"user_id": uid},
{"$set": doc, "$setOnInsert": {"user_id": [uid]}},
upsert=True,
)
async def _load_from_mongo(self, user_id):
doc = await self.tokens_coll.find_one(
{"user_id": ObjectId(user_id)},
{
"expires_at": 1,
"encrypted_access_token": 1,
"encrypted_refresh_token": 1,
"device_id": 1,
"client_id": 1,
},
)
if doc:
return await self._decrypt_token_fields(doc)
return None
async def _cache_in_redis(self, user_id, token_doc):
token_json = json.dumps({
**{k: v for k, v in token_doc.items() if k not in ["expires_at", "user_id", "_id"]},
"expires_at": token_doc['expires_at'].isoformat(),
"user_id": user_id,
})
await redis_client.setex(f"tokens:{user_id}", 60 * 60 * 24 * 60, token_json)
async def get_access_token(self, user_id: str, need_refresh: bool = False) -> str | None:
# Короткий токен (зашифрованный)
access_token_enc = await redis_client.get(f"access_token:{user_id}")
if access_token_enc and not need_refresh:
try:
return decrypt(access_token_enc).decode("utf-8")
except Exception:
pass
# Полный кэш
cached = await redis_client.get(f"tokens:{user_id}")
if cached:
try:
token_data = json.loads(cached)
token_data = await self._decrypt_token_fields(token_data)
except Exception:
token_data = None
else:
token_data = None
if isinstance(token_data, dict) and "error" in token_data:
token_data = None
if token_data is None:
doc = await self._load_from_mongo(user_id)
if not doc:
return None
await self._cache_in_redis(user_id, doc)
token_data = doc
# Проверка срока
if isinstance(token_data.get("expires_at"), str):
expires_at = datetime.fromisoformat(token_data["expires_at"].replace("Z", "+00:00"))
else:
expires_at = token_data.get("expires_at")
if expires_at and expires_at.astimezone(UTC) > datetime.now(UTC) + timedelta(minutes=5) and not need_refresh:
ttl = int((expires_at.astimezone(UTC) - datetime.now(UTC)).total_seconds()) - 30
enc = encrypt(token_data["access_token"].encode("utf-8"))
await redis_client.setex(f"access_token:{user_id}", ttl, enc)
return token_data["access_token"]
return await self.refresh_tokens(
user_id,
token_data.get("refresh_token"),
token_data.get("device_id"),
token_data.get('client_id', CLIENT_ID)
)
async def refresh_tokens(self, user_id: str, refresh_token: str, device_id: str, client_id: int = CLIENT_ID) -> str | None:
if not refresh_token or not device_id:
return None
try:
async with httpx.AsyncClient(timeout=15.0, verify=ctx, http2=True) as client:
resp = await client.post(
OAUTH_URL,
data={
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': client_id or CLIENT_ID,
'device_id': device_id,
'state': os.urandom(32).hex(),
},
)
resp.raise_for_status()
new_tokens = resp.json()
# В случае ошибок VK вернёт поле `error`
if 'error' in new_tokens:
logging.error(
f"[TokenManager] Refresh failed for user {user_id}: "
f"{new_tokens['error']} - {new_tokens.get('error_description', 'No description')}"
)
return None
# Сохраняем fresh‑токены
await self.save_tokens(user_id, new_tokens, device_id, client_id)
return new_tokens.get("access_token")
except Exception as e:
logging.error(f"[TokenManager] Refresh failed: {e}")
return None
async def revoke_tokens(self, user_id: str):
await self.tokens_coll.delete_one({"user_id": ObjectId(user_id)})
await redis_client.delete(f"tokens:{user_id}", f"access_token:{user_id}")