diff --git a/.env.example b/.env.example index 6cf3ece..c87fc25 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync SECRET_KEY=your-random-secret-key-here BASE_URL=http://localhost:8000 +EVOTOR_APP_ID=your-evotor-app-id +EVOTOR_WEBHOOK_SECRET=your-webhook-secret + DB_ROOT_PASSWORD=rootpass DB_NAME=evosync DB_USER=evosync diff --git a/scripts/evotor-get-token.sh b/scripts/evotor-get-token.sh new file mode 100644 index 0000000..e288b8e --- /dev/null +++ b/scripts/evotor-get-token.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Obtain an Evotor developer access token via password grant (no browser required). +# Uses dev.evotor.ru credentials (your Evotor developer account). +# +# Usage: ./scripts/evotor-get-token.sh + +set -euo pipefail + +# Load .env if present +if [[ -f .env ]]; then + set -a; source .env; set +a +fi + +EVOTOR_TOKEN_URL="https://dev.evotor.ru/oauth/token" + +# Prompt for credentials +read -rp "Evotor developer login (email): " EVOTOR_LOGIN +read -rsp "Evotor developer password: " EVOTOR_PASSWORD +echo + +echo +echo "A 2FA code will be sent to your email if this IP is not recognized." +read -rp "2FA code (leave blank if not required): " EVOTOR_2FA + +# Build request body +BODY="type=LOGIN&grant_type=password&client_id=Evo-UI&username=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_LOGIN")&password=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_PASSWORD")" + +EXTRA_HEADERS=() +if [[ -n "$EVOTOR_2FA" ]]; then + EXTRA_HEADERS+=(-H "2fa_confirmation: $EVOTOR_2FA") +fi + +echo +echo "Requesting token..." +RESPONSE=$(curl -s -X POST "$EVOTOR_TOKEN_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "${EXTRA_HEADERS[@]}" \ + -d "$BODY") + +echo +echo "Response:" +echo "$RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$RESPONSE" + +ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || true) + +if [[ -z "$ACCESS_TOKEN" ]]; then + echo + echo "ERROR: No access_token in response." >&2 + exit 1 +fi + +echo +echo "Access token:" +echo "$ACCESS_TOKEN" +echo +echo "To save this token to .env, add or update:" +echo " EVOTOR_ACCESS_TOKEN=$ACCESS_TOKEN" diff --git a/web/config.py b/web/config.py index 62355aa..6ff3b49 100644 --- a/web/config.py +++ b/web/config.py @@ -7,9 +7,8 @@ class Settings(BaseSettings): BASE_URL: str = "http://localhost:8080" PASSWORD_RESET_EXPIRE_MINUTES: int = 60 - EVOTOR_CLIENT_ID: str = "" - EVOTOR_CLIENT_SECRET: str = "" - EVOTOR_SCOPES: str = "store:read product:read" + EVOTOR_APP_ID: str = "" + EVOTOR_WEBHOOK_SECRET: str = "" HEALTH_CHECK_INTERVAL_SECONDS: int = 600 diff --git a/web/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py b/web/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py new file mode 100644 index 0000000..d2b7671 --- /dev/null +++ b/web/migrations/versions/f6a7b8c9d0e1_evotor_webhook_token_flow.py @@ -0,0 +1,32 @@ +"""evotor webhook token flow: add evotor_user_id, make user_id nullable + +Revision ID: f6a7b8c9d0e1 +Revises: e5f6a7b8c9d0 +Branch Labels: None +Depends On: None + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'f6a7b8c9d0e1' +down_revision = 'e5f6a7b8c9d0' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('evotor_connections', + sa.Column('evotor_user_id', sa.String(255), nullable=True)) + op.create_unique_constraint('uq_evotor_connections_evotor_user_id', + 'evotor_connections', ['evotor_user_id']) + op.create_index('ix_evotor_connections_evotor_user_id', + 'evotor_connections', ['evotor_user_id']) + op.alter_column('evotor_connections', 'user_id', nullable=True) + + +def downgrade() -> None: + op.alter_column('evotor_connections', 'user_id', nullable=False) + op.drop_index('ix_evotor_connections_evotor_user_id', 'evotor_connections') + op.drop_constraint('uq_evotor_connections_evotor_user_id', 'evotor_connections') + op.drop_column('evotor_connections', 'evotor_user_id') diff --git a/web/models.py b/web/models.py index 583c357..390286f 100644 --- a/web/models.py +++ b/web/models.py @@ -33,7 +33,8 @@ class EvotorConnection(Base): __tablename__ = "evotor_connections" id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True) + evotor_user_id = Column(String(255), unique=True, nullable=True, index=True) access_token = Column(Text, nullable=False) store_id = Column(String(255), nullable=True) store_name = Column(String(255), nullable=True) diff --git a/web/routes/evotor.py b/web/routes/evotor.py index 5217e85..8f1a5ad 100644 --- a/web/routes/evotor.py +++ b/web/routes/evotor.py @@ -1,12 +1,11 @@ -import secrets import logging import httpx -from fastapi import APIRouter, Request, Depends - -logger = logging.getLogger(__name__) -from fastapi.responses import RedirectResponse +from datetime import datetime, timedelta +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.responses import RedirectResponse, JSONResponse from fastapi.templating import Jinja2Templates +from pydantic import BaseModel from sqlalchemy.orm import Session from web.auth import get_current_user @@ -14,16 +13,16 @@ from web.config import settings from web.database import get_db from web.models import User, EvotorConnection +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/evotor") templates = Jinja2Templates(directory="web/templates") -EVOTOR_AUTHORIZE_URL = "https://oauth.evotor.ru/oauth/authorize" -EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token" +EVOTOR_LOGIN_URL = "https://market.evotor.ru/#/store/auth/login" EVOTOR_STORES_URL = "https://api.evotor.ru/stores" - -def _redirect_uri() -> str: - return f"{settings.BASE_URL}/evotor/callback" +# Pending connections older than this are ignored during linking +PENDING_LINK_WINDOW_SECONDS = 300 @router.get("") @@ -50,72 +49,46 @@ def evotor_connect(request: Request, user: User | None = Depends(get_current_use if not user: return RedirectResponse("/login", 303) - state = secrets.token_urlsafe(32) - request.session["evotor_oauth_state"] = state + # Record when this user initiated a connect so we can link the incoming webhook + request.session["evotor_connect_user_id"] = user.id + request.session["evotor_connect_at"] = datetime.utcnow().isoformat() - params = ( - f"?client_id={settings.EVOTOR_CLIENT_ID}" - f"&response_type=code" - f"&redirect_uri={_redirect_uri()}" - f"&scope={settings.EVOTOR_SCOPES.replace(' ', '%20')}" - f"&state={state}" - ) - return RedirectResponse(EVOTOR_AUTHORIZE_URL + params, 302) + return RedirectResponse(EVOTOR_LOGIN_URL, 302) -@router.get("/callback") +class EvotorTokenPayload(BaseModel): + userId: str + token: str + + +@router.post("/callback") async def evotor_callback( request: Request, + payload: EvotorTokenPayload, db: Session = Depends(get_db), - user: User | None = Depends(get_current_user), ): - if not user: - return RedirectResponse("/login", 303) + """ + Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."} here + after the user authorizes the app in their Evotor account. + """ + # Verify the Authorization header matches our configured webhook secret + if settings.EVOTOR_WEBHOOK_SECRET: + auth_header = request.headers.get("Authorization", "") + expected = f"Bearer {settings.EVOTOR_WEBHOOK_SECRET}" + if auth_header != expected: + logger.warning("Evotor webhook: invalid Authorization header") + raise HTTPException(status_code=401, detail="Unauthorized") - code = request.query_params.get("code") - state = request.query_params.get("state") - saved_state = request.session.pop("evotor_oauth_state", None) + now = datetime.utcnow() - if not code or not state or state != saved_state: - return RedirectResponse("/evotor?error=invalid_state", 303) - - # Exchange code for access token - try: - async with httpx.AsyncClient() as client: - token_response = await client.post( - EVOTOR_TOKEN_URL, - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": _redirect_uri(), - "client_id": settings.EVOTOR_CLIENT_ID, - "client_secret": settings.EVOTOR_CLIENT_SECRET, - }, - timeout=15, - ) - token_response.raise_for_status() - token_data = token_response.json() - except httpx.HTTPStatusError as e: - logger.error("Evotor token exchange HTTP error %s: %s", e.response.status_code, e.response.text) - return RedirectResponse("/evotor?error=token_exchange", 303) - except Exception as e: - logger.error("Evotor token exchange failed: %s", e, exc_info=True) - return RedirectResponse("/evotor?error=token_exchange", 303) - - access_token = token_data.get("access_token") - refresh_token = token_data.get("refresh_token") - expires_in = token_data.get("expires_in") - if not access_token: - return RedirectResponse("/evotor?error=no_token", 303) - - # Fetch first store to save store info + # Fetch store info using the received token store_id = None store_name = None try: async with httpx.AsyncClient() as client: stores_response = await client.get( EVOTOR_STORES_URL, - headers={"Authorization": f"Bearer {access_token}"}, + headers={"Authorization": f"Bearer {payload.token}"}, timeout=15, ) if stores_response.status_code == 200: @@ -125,34 +98,88 @@ async def evotor_callback( store_id = items[0].get("uuid") or items[0].get("id") store_name = items[0].get("name") except Exception: - pass # Store info is optional; token is still saved + pass # Store info is optional - # Save or update connection - from datetime import datetime, timedelta - now = datetime.utcnow() - token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None + # Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called) + connection = db.query(EvotorConnection).filter( + EvotorConnection.evotor_user_id == payload.userId + ).first() - connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() if connection: - connection.access_token = access_token - connection.refresh_token = refresh_token - connection.token_expires_at = token_expires_at + connection.access_token = payload.token connection.store_id = store_id connection.store_name = store_name connection.is_online = True connection.last_checked_at = now + connection.updated_at = now else: connection = EvotorConnection( - user_id=user.id, - access_token=access_token, - refresh_token=refresh_token, - token_expires_at=token_expires_at, + evotor_user_id=payload.userId, + access_token=payload.token, store_id=store_id, store_name=store_name, is_online=True, last_checked_at=now, ) db.add(connection) + + db.commit() + logger.info("Evotor webhook: saved token for evotor_user_id=%s", payload.userId) + + return JSONResponse({"status": "ok"}) + + +@router.get("/link") +def evotor_link( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + """ + Called when the user returns to our app after authorizing on Evotor. + Links the most recently received unlinked token to this user. + """ + if not user: + return RedirectResponse("/login", 303) + + connect_user_id = request.session.pop("evotor_connect_user_id", None) + connect_at_str = request.session.pop("evotor_connect_at", None) + + if not connect_user_id or connect_user_id != user.id or not connect_at_str: + return RedirectResponse("/evotor?error=session_expired", 303) + + try: + connect_at = datetime.fromisoformat(connect_at_str) + except ValueError: + return RedirectResponse("/evotor?error=session_expired", 303) + + cutoff = connect_at - timedelta(seconds=10) # allow slight clock drift + now = datetime.utcnow() + + if (now - connect_at).total_seconds() > PENDING_LINK_WINDOW_SECONDS: + return RedirectResponse("/evotor?error=link_timeout", 303) + + # Find an unlinked connection received after the user clicked "Connect" + pending = ( + db.query(EvotorConnection) + .filter( + EvotorConnection.user_id.is_(None), + EvotorConnection.connected_at >= cutoff, + ) + .order_by(EvotorConnection.connected_at.desc()) + .first() + ) + + if not pending: + return RedirectResponse("/evotor?error=token_not_received", 303) + + # Detach any existing connection for this user + existing = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + if existing: + db.delete(existing) + db.flush() + + pending.user_id = user.id db.commit() return RedirectResponse("/connections", 303) diff --git a/web/templates/evotor.html b/web/templates/evotor.html index c2a95c4..629c35f 100644 --- a/web/templates/evotor.html +++ b/web/templates/evotor.html @@ -7,12 +7,12 @@ {% if error %}