All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m10s
116 lines
7 KiB
Python
116 lines
7 KiB
Python
"""Строгий типизированный разбор нотификации Т-Банк — ПОСЛЕ проверки подписи.
|
||
|
||
Вызывать `parse_notification()` только когда `token.verify_notification_token(...)`
|
||
уже вернул `True`. Разбор здесь НЕ проверяет подпись повторно — он только
|
||
превращает уже доверенный (по подписи) `dict` в типизированный объект, чтобы
|
||
сырой `dict` не утекал дальше в бизнес-логику (статус-машину платежа, запись
|
||
в БД).
|
||
|
||
ПОЧЕМУ строгий разбор — самостоятельный слой защиты, а не формальность:
|
||
алгоритм подписи Т-Банка конкатенирует значения полей БЕЗ разделителя между
|
||
ними (см. `token.py`, docstring модуля, шаг 4). Из-за этого символы могут
|
||
"перекладываться" между лексикографически соседними ключами так, что итоговая
|
||
строка для SHA-256 не меняется, хотя значения полей — меняются. Проверено
|
||
живым расчётом на официальном эталонном векторе: `Amount=1111, CardId="000000"`
|
||
даёт тот же Token, что и `Amount=11, CardId="11000000"` (доп. `1` "перетекла"
|
||
из `Amount` в начало `CardId`, потому что `Amount` < `CardId` лексикографически
|
||
и обе стоят подряд в конкатенации). Значит подпись сама по себе НЕ гарантирует,
|
||
что банк прислал именно ту сумму, которую записал у себя платёжный сервис —
|
||
это СВОЙСТВО алгоритма банка, менять его нельзя (мы не управляем форматом
|
||
Token, который реально пришлёт банк на проде).
|
||
|
||
КОНТРАКТ ДЛЯ PR-D (публичная ручка нотификации) — единственная реальная защита
|
||
от описанного выше перекладывания: `amount_kopecks` из `parse_notification()`
|
||
ОБЯЗАН быть сверен с уже сохранённым `payments.amount_kopecks` в БД (запись,
|
||
созданная на `init_payment()`, найденная по `order_id`/`payment_id` из этой же
|
||
нотификации) ДО того, как нотификация будет принята как валидное событие.
|
||
Если сумма из нотификации не совпадает с суммой в БД — это либо подделанная
|
||
нотификация (перекладывание символов дало другой `OrderId`/`Amount`-ключ и
|
||
подпись всё равно сошлась), либо рассинхронизация, но НЕ штатный кейс — то и
|
||
другое должно быть отказом, а не «примерно похоже, примем».
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
|
||
class NotificationParseError(ValueError):
|
||
"""Поле нотификации не соответствует ожидаемому типу — отказ, не догадка."""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class TBankNotification:
|
||
"""Типизированное тело нотификации Т-Банка ПОСЛЕ успешной проверки подписи.
|
||
|
||
`amount_kopecks` здесь — то, что ПРИСЛАЛ банк в текущем HTTP-запросе, а
|
||
НЕ подтверждённый источник истины сам по себе. См. docstring модуля —
|
||
сверка с `payments.amount_kopecks` в БД обязательна на вызывающей стороне.
|
||
"""
|
||
|
||
success: bool
|
||
status: str
|
||
order_id: str
|
||
payment_id: str
|
||
terminal_key: str
|
||
amount_kopecks: int
|
||
|
||
|
||
def parse_notification(payload: dict[str, Any]) -> TBankNotification:
|
||
"""Строгий разбор `payload` в `TBankNotification`.
|
||
|
||
Вызывать ТОЛЬКО после `token.verify_notification_token(payload, password)
|
||
is True` — эта функция подпись не проверяет.
|
||
|
||
Правила (без исключений, без «примерно разберём»):
|
||
- `Success` — только настоящий `bool` (не строка `"true"`, не `1`);
|
||
- `Amount` — только `int`; `bool` — подкласс `int` в Python
|
||
(`isinstance(True, int) is True`), поэтому проверяется и отсекается
|
||
ДО проверки на `int`, иначе `Success`-подобное поле молча прошло бы
|
||
как сумма;
|
||
- `Status`, `OrderId`, `PaymentId`, `TerminalKey` — только непустой `str`.
|
||
|
||
Любое несоответствие — `NotificationParseError` с указанием поля,
|
||
ожидаемого типа и того, что реально пришло.
|
||
"""
|
||
if not isinstance(payload, dict):
|
||
raise NotificationParseError(f"payload должен быть dict, получено {type(payload).__name__}")
|
||
|
||
return TBankNotification(
|
||
success=_require_strict_bool(payload, "Success"),
|
||
status=_require_nonempty_str(payload, "Status"),
|
||
order_id=_require_nonempty_str(payload, "OrderId"),
|
||
payment_id=_require_nonempty_str(payload, "PaymentId"),
|
||
terminal_key=_require_nonempty_str(payload, "TerminalKey"),
|
||
amount_kopecks=_require_strict_int(payload, "Amount"),
|
||
)
|
||
|
||
|
||
def _require_strict_bool(payload: dict[str, Any], key: str) -> bool:
|
||
value = payload.get(key)
|
||
if not isinstance(value, bool):
|
||
raise NotificationParseError(
|
||
f"{key} должен быть bool, получено {type(value).__name__}={value!r}"
|
||
)
|
||
return value
|
||
|
||
|
||
def _require_strict_int(payload: dict[str, Any], key: str) -> int:
|
||
value = payload.get(key)
|
||
# bool — подкласс int в Python: проверяем и отсекаем ДО isinstance(value, int),
|
||
# иначе True/False молча прошли бы как Amount=1/Amount=0.
|
||
if isinstance(value, bool) or not isinstance(value, int):
|
||
raise NotificationParseError(
|
||
f"{key} должен быть int (не bool/str/float), получено {type(value).__name__}={value!r}"
|
||
)
|
||
return value
|
||
|
||
|
||
def _require_nonempty_str(payload: dict[str, Any], key: str) -> str:
|
||
value = payload.get(key)
|
||
if not isinstance(value, str) or not value:
|
||
raise NotificationParseError(
|
||
f"{key} должен быть непустой str, получено {type(value).__name__}={value!r}"
|
||
)
|
||
return value
|