431 lines
19 KiB
Python
431 lines
19 KiB
Python
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}") |