Files
evo-sync/web/routes/evotor_webhooks.py
mguschin cd777d2bc1 fix: always update password on re-registration in /user/create
When Evotor sends a new /user/create for an existing userId, the new
password should replace the old one so /user/verify stays in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:43:05 +03:00

353 lines
13 KiB
Python

"""
Evotor webhook endpoints.
POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token.
POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface.
POST /user/token — Evotor sends us its own API token for the user.
POST /user/install — Evotor notifies about app install or uninstall for a user.
"""
import json
import logging
import secrets
from datetime import datetime, timezone, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import hash_password, verify_password
from web.config import settings
from web.database import get_db
from web.models.connections import EvotorConnection
from web.models.user import User, UserRoleEnum, UserStatusEnum
from web.notifications.tasks import send_email_task
logger = logging.getLogger(__name__)
router = APIRouter()
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
def _verify_secret(request: Request) -> bool:
secret = settings.EVOTOR_WEBHOOK_SECRET
if not secret:
return True # dev mode: no secret configured
auth = request.headers.get("Authorization", "")
return auth == f"Bearer {secret}"
def _parse_custom_fields(raw: Any) -> dict:
"""Extract known fields from Evotor customField (may be JSON string or dict)."""
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, ValueError):
pass
return {}
def _upsert_evotor_connection(
db: Session,
user_id: int | None,
evotor_user_id: str,
access_token: str | None = None,
) -> str:
"""Create or update an evotor_connections row; always regenerates api_token."""
api_token = secrets.token_urlsafe(32)
conn = db.query(EvotorConnection).filter(
or_(
EvotorConnection.evotor_user_id == evotor_user_id,
EvotorConnection.user_id == user_id,
)
).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.api_token = api_token
if user_id is not None:
conn.user_id = user_id
if evotor_user_id:
conn.evotor_user_id = evotor_user_id
if access_token:
conn.access_token = access_token
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user_id,
evotor_user_id=evotor_user_id,
access_token=access_token or "",
api_token=api_token,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.flush()
return api_token
@router.post("/user/create")
async def user_create(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
if not evotor_user_id:
return JSONResponse({"error": "userId required"}, status_code=400)
custom = _parse_custom_fields(body.get("customField"))
# Evotor sends fields both at top level and inside customField
email = (body.get("email") or custom.get("email") or "").strip().lower() or None
phone = (body.get("phone_number") or body.get("phone") or custom.get("phone_number") or custom.get("phone") or "").strip() or None
first_name = (body.get("first_name") or body.get("firstName") or custom.get("first_name") or custom.get("firstName") or "").strip() or None
last_name = (body.get("second_name") or body.get("last_name") or body.get("lastName") or custom.get("second_name") or custom.get("last_name") or custom.get("lastName") or "").strip() or None
middle_name = (body.get("middle_name") or custom.get("middle_name") or "").strip() or None
password = (body.get("password") or custom.get("password") or "").strip() or None
# Try to find existing user
user: User | None = None
# 1. By evotor_user_id
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
# 2. By email
if user is None and email:
user = db.query(User).filter(User.email == email).first()
# 3. By phone
if user is None and phone:
user = db.query(User).filter(User.phone == phone).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user:
# Link Evotor to existing user; update name fields if Evotor provided them
user.evotor_user_id = evotor_user_id
user.evotor_meta = body
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
if middle_name:
user.middle_name = middle_name
if password:
user.password_hash = hash_password(password)
if user.status == UserStatusEnum.pending:
user.status = UserStatusEnum.active
db.flush()
else:
# Create new user; active immediately if password provided, else pending
user = User(
first_name=first_name or "",
last_name=last_name or "",
middle_name=middle_name,
email=email or f"{evotor_user_id}@evotor.invalid",
phone=phone or None,
password_hash=hash_password(password) if password else None,
role=UserRoleEnum.user,
status=UserStatusEnum.active if password else UserStatusEnum.pending,
evotor_user_id=evotor_user_id,
evotor_meta=body,
created_at=now,
updated_at=now,
)
db.add(user)
db.flush() # get user.id
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id)
if not password:
# No password provided — send invite link so user can set one
invite_token = secrets.token_urlsafe(32)
user.invite_token = invite_token
user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
db.commit()
if email:
invite_url = f"{settings.BASE_URL}/invite?token={invite_token}"
html = (
f"<p>Здравствуйте!</p>"
f"<p>Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
send_email_task.delay(email, "Приглашение в ЭВОСИНК", html)
else:
logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s",
evotor_user_id, settings.BASE_URL, invite_token)
else:
db.commit()
return JSONResponse({"userId": evotor_user_id, "token": api_token})
@router.post("/user/verify")
async def user_verify(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
logger.info("user/verify body=%s", {k: v for k, v in body.items() if k != "password"})
evotor_user_id: str = body.get("userId", "")
username: str = body.get("username", "").strip()
phone: str = body.get("phone", "").strip()
password: str = body.get("password", "")
login = username or phone
if not password:
return JSONResponse({"error": "password required"}, status_code=400)
# 1. Match by evotor_user_id (most reliable — Evotor always sends userId)
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() if evotor_user_id else None
# 2. Fall back to email or phone
if not user and login:
user = db.query(User).filter(
or_(User.email == login, User.phone == login)
).first()
if not user:
return JSONResponse({"error": "Неверные данные"}, status_code=401)
if user.status == UserStatusEnum.suspended:
return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403)
if not user.password_hash:
# First auth with password — save it and activate the account
user.password_hash = hash_password(password)
if user.status == UserStatusEnum.pending:
user.status = UserStatusEnum.active
elif not verify_password(password, user.password_hash):
return JSONResponse({"error": "Неверные данные"}, status_code=401)
# Get or create connection to retrieve api_token
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == (user.evotor_user_id or evotor_user_id)
).first()
if not conn:
# Auto-link: create connection with Evotor userId from request
if evotor_user_id and not user.evotor_user_id:
user.evotor_user_id = evotor_user_id
db.flush()
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id or (user.evotor_user_id or ""))
db.commit()
else:
api_token = conn.api_token or secrets.token_urlsafe(32)
if not conn.api_token:
conn.api_token = api_token
db.commit()
return JSONResponse({"userId": user.evotor_user_id or evotor_user_id, "token": api_token})
@router.post("/user/token")
async def user_token(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
evotor_token: str = body.get("token", "")
if not evotor_user_id or not evotor_token:
return JSONResponse({"error": "userId and token required"}, status_code=400)
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
if not user:
return JSONResponse({"error": "User not found"}, status_code=404)
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.access_token = evotor_token
conn.is_online = True
conn.last_checked_at = now
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user.id,
evotor_user_id=evotor_user_id,
access_token=evotor_token,
api_token=secrets.token_urlsafe(32),
is_online=True,
last_checked_at=now,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.commit()
return JSONResponse({})
@router.post("/user/install")
async def user_install(request: Request, db: Session = Depends(get_db)):
"""Handle app install / uninstall events from Evotor."""
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
# userId is nested inside "data"; type is e.g. "ApplicationInstalled" / "ApplicationUninstalled"
data: dict = body.get("data", {})
evotor_user_id: str = data.get("userId", "") or body.get("userId", "")
event_type: str = body.get("type", "").lower() # "applicationinstalled" / "applicationuninstalled"
if not evotor_user_id:
logger.warning("user/install missing userId, body=%s", body)
return JSONResponse({"error": "userId required"}, status_code=400)
logger.info("user/install event type=%s userId=%s", event_type, evotor_user_id)
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
if not user:
# Unknown user — nothing to act on, but acknowledge the event
return JSONResponse({})
now = datetime.now(timezone.utc).replace(tzinfo=None)
if event_type == "applicationuninstalled":
user.status = UserStatusEnum.suspended
user.updated_at = now
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
if conn:
conn.is_online = False
conn.updated_at = now
db.commit()
logger.info("user suspended on uninstall: userId=%s", evotor_user_id)
elif event_type == "applicationinstalled":
if user.status == UserStatusEnum.suspended:
user.status = UserStatusEnum.active
user.updated_at = now
db.commit()
logger.info("user reactivated on reinstall: userId=%s", evotor_user_id)
return JSONResponse({})