Replace VK OAuth with manual community token entry

Resolves #4 — VK OAuth flow caused "Security Error" because market sync
requires a community access token, not a personal user token. Replaced
OAuth with manual token input (same pattern as Evotor). Added
step-by-step instructions. Updated health checker to validate community
tokens via groups.getById instead of users.get.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-10 15:32:13 +03:00
parent 4d4d5b0118
commit debb2efb3d
3 changed files with 74 additions and 106 deletions

View File

@@ -11,7 +11,7 @@ logger = logging.getLogger("uvicorn.error")
EVOTOR_STORES_URL = "https://api.evotor.ru/stores" EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token" EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
VK_USERS_GET_URL = "https://api.vk.com/method/users.get" VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById"
VK_API_VERSION = "5.131" VK_API_VERSION = "5.131"
# Refresh Evotor token if it expires within this window # Refresh Evotor token if it expires within this window
@@ -59,7 +59,7 @@ async def check_vk_connection(access_token: str) -> bool:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get( resp = await client.get(
VK_USERS_GET_URL, VK_GROUPS_GET_URL,
params={"access_token": access_token, "v": VK_API_VERSION}, params={"access_token": access_token, "v": VK_API_VERSION},
timeout=10, timeout=10,
) )

View File

@@ -1,4 +1,3 @@
import secrets
from datetime import datetime from datetime import datetime
import httpx import httpx
@@ -15,15 +14,9 @@ from web.models import User, VkConnection
router = APIRouter(prefix="/vk") router = APIRouter(prefix="/vk")
VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize"
VK_TOKEN_URL = "https://oauth.vk.com/access_token"
VK_API_URL = "https://api.vk.com/method" VK_API_URL = "https://api.vk.com/method"
def _redirect_uri() -> str:
return f"{settings.BASE_URL}/vk/callback"
@router.get("") @router.get("")
def vk_page( def vk_page(
request: Request, request: Request,
@@ -43,102 +36,62 @@ def vk_page(
}) })
@router.get("/connect") @router.post("/token")
def vk_connect(request: Request, user: User | None = Depends(get_current_user)): async def vk_token(
if not user:
return RedirectResponse("/login", 303)
state = secrets.token_urlsafe(32)
request.session["vk_oauth_state"] = state
params = (
f"?client_id={settings.VK_CLIENT_ID}"
f"&response_type=code"
f"&redirect_uri={_redirect_uri()}"
f"&scope={settings.VK_SCOPES.replace(' ', '%20')}"
f"&state={state}"
f"&display=page"
f"&v={settings.VK_API_VERSION}"
)
return RedirectResponse(VK_AUTHORIZE_URL + params, 302)
@router.get("/callback")
async def vk_callback(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User | None = Depends(get_current_user), user: User | None = Depends(get_current_user),
): ):
"""Save a manually entered VK community access token."""
if not user: if not user:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
code = request.query_params.get("code") form = await request.form()
state = request.query_params.get("state") token = (form.get("token") or "").strip()
saved_state = request.session.pop("vk_oauth_state", None) if not token:
return RedirectResponse("/vk?error=empty_token", 303)
if not code or not state or state != saved_state: # Fetch community info to validate the token and get group name/id
return RedirectResponse("/vk?error=invalid_state", 303) group_id = None
group_name = None
# Exchange code for token (VK uses GET with query params)
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
token_response = await client.get( resp = await client.get(
VK_TOKEN_URL, f"{VK_API_URL}/groups.getById",
params={ params={"access_token": token, "v": settings.VK_API_VERSION},
"client_id": settings.VK_CLIENT_ID,
"client_secret": settings.VK_CLIENT_SECRET,
"code": code,
"redirect_uri": _redirect_uri(),
},
timeout=15, timeout=15,
) )
token_response.raise_for_status() if resp.status_code == 200:
token_data = token_response.json() data = resp.json()
except Exception: if "error" in data:
return RedirectResponse("/vk?error=token_exchange", 303) return RedirectResponse("/vk?error=invalid_token", 303)
groups = data.get("response", {}).get("groups", [])
access_token = token_data.get("access_token") if groups:
vk_user_id = str(token_data.get("user_id", "")) or None group_id = str(groups[0].get("id", ""))
if not access_token: group_name = groups[0].get("name")
return RedirectResponse("/vk?error=no_token", 303) elif resp.status_code == 401:
return RedirectResponse("/vk?error=invalid_token", 303)
# Fetch VK profile info
first_name = None
last_name = None
try:
async with httpx.AsyncClient() as client:
profile_response = await client.get(
f"{VK_API_URL}/users.get",
params={"access_token": access_token, "v": settings.VK_API_VERSION},
timeout=15,
)
if profile_response.status_code == 200:
profile_data = profile_response.json()
items = profile_data.get("response", [])
if items:
first_name = items[0].get("first_name")
last_name = items[0].get("last_name")
except Exception: except Exception:
pass pass
# Save or update connection
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
now = datetime.utcnow()
if connection: if connection:
connection.access_token = access_token connection.access_token = token
connection.vk_user_id = vk_user_id connection.vk_user_id = group_id
connection.first_name = first_name connection.first_name = group_name
connection.last_name = last_name connection.last_name = None
connection.is_online = True connection.is_online = True
connection.last_checked_at = datetime.utcnow() connection.last_checked_at = now
else: else:
connection = VkConnection( connection = VkConnection(
user_id=user.id, user_id=user.id,
access_token=access_token, access_token=token,
vk_user_id=vk_user_id, vk_user_id=group_id,
first_name=first_name, first_name=group_name,
last_name=last_name, last_name=None,
is_online=True, is_online=True,
last_checked_at=datetime.utcnow(), last_checked_at=now,
) )
db.add(connection) db.add(connection)
db.commit() db.commit()

View File

@@ -7,12 +7,10 @@
{% if error %} {% if error %}
<div class="alert alert-danger mt-4"> <div class="alert alert-danger mt-4">
{% if error == "invalid_state" %} {% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново. <i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Убедитесь, что скопировали ключ доступа сообщества правильно.
{% elif error == "token_exchange" %} {% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от ВКонтакте. Попробуйте позже. <i class="bi bi-exclamation-triangle me-2"></i>Введите ключ доступа.
{% elif error == "no_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>ВКонтакте не вернул токен доступа. Попробуйте позже.
{% else %} {% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }} <i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %} {% endif %}
@@ -31,15 +29,15 @@
<span class="text-muted small">Статус</span> <span class="text-muted small">Статус</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span> <span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li> </li>
{% if connection.first_name or connection.last_name %} {% if connection.first_name %}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Профиль</span> <span class="text-muted small">Сообщество</span>
<span>{{ connection.first_name }} {{ connection.last_name }}</span> <span>{{ connection.first_name }}</span>
</li> </li>
{% endif %} {% endif %}
{% if connection.vk_user_id %} {% if connection.vk_user_id %}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">ID пользователя</span> <span class="text-muted small">ID сообщества</span>
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span> <span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
</li> </li>
{% endif %} {% endif %}
@@ -48,10 +46,18 @@
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span> <span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
</li> </li>
</ul> </ul>
<div class="card-body d-grid gap-2"> <div class="card-footer">
<a href="/vk/connect" class="btn btn-primary">Переподключить</a> <p class="text-muted small mb-2">Обновить ключ доступа:</p>
<form method="post" action="/vk/token">
<div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый ключ доступа" required>
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
</div>
</form>
</div>
<div class="card-body d-grid">
<form method="post" action="/vk/disconnect"> <form method="post" action="/vk/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт ВКонтакте</button> <button type="submit" class="btn btn-outline-danger w-100">Отключить ВКонтакте</button>
</form> </form>
</div> </div>
@@ -59,17 +65,26 @@
{# ── NOT CONNECTED STATE ── #} {# ── NOT CONNECTED STATE ── #}
<div class="card-body"> <div class="card-body">
<p class="text-muted mb-3"> <p class="text-muted mb-3">
Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать Для подключения вам нужен <strong>ключ доступа сообщества</strong> ВКонтакте.
каталог товаров из Эвотор в вашу группу ВКонтакте. Синхронизация товаров работает только через сообщество.
</p> </p>
<ul class="text-muted small mb-4">
<li>Вы будете перенаправлены на сайт ВКонтакте для авторизации</li> <ol class="text-muted small mb-4">
<li>После подтверждения доступа синхронизация будет настроена автоматически</li> <li class="mb-1">Откройте <a href="https://vk.com" target="_blank" rel="noopener">vk.com <i class="bi bi-box-arrow-up-right small"></i></a> и перейдите в управление вашим сообществом.</li>
<li>Вы можете отключить доступ в любой момент</li> <li class="mb-1">Перейдите в раздел <strong>Настройки → Работа с API</strong>.</li>
</ul> <li class="mb-1">Создайте ключ доступа с правами <strong>Управление товарами</strong> и <strong>Управление сообществом</strong>.</li>
<div class="d-grid"> <li class="mb-1">Скопируйте ключ и вставьте его в поле ниже.</li>
<a href="/vk/connect" class="btn btn-primary btn-lg">Подключить ВКонтакте</a> </ol>
</div>
<form method="post" action="/vk/token">
<div class="mb-3">
<label class="form-label small text-muted">Ключ доступа сообщества</label>
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте ключ доступа" required autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Подключить</button>
</div>
</form>
</div> </div>
{% endif %} {% endif %}