Files
chatballs/apps/backend/hub_platform/support/token.py
T
Andrey 092553941f feat(support): verify product support token and start session
SPEC-HUB-0011 §12 (алгоритм start) + §14 (error model).

- token.py: HS256 JWT verify через stdlib (hmac/hashlib/base64), envelope claims
  (iss/aud/contract/iat/exp/jti/data). RS256/EdDSA — TODO.
- jsonpath.py: ограниченный JSONPath-subset ($.field, $.a.b, $.arr[0].field).
- session.py: verify_and_resolve (channel policy → token → contract → schema →
  identity/operator/ai/search extraction) + start_support_session (snapshot upsert,
  create-or-continue conversation, audit success/denied). Raw token не сохраняется
  и не логируется — только хэш jti.
- errors.py: машинные коды (TOKEN_*, CONTRACT_*, PAYLOAD_SCHEMA_INVALID, ...) +
  SupportSessionError; публичное сообщение безопасное.
- services.py: register_contract / set_contract_status (зеркало products).
- selectors.py / serializers.py / views.py / urls.py: CRUD контрактов (IsOwner),
  POST /api/v1/support/sessions/ (AllowAny, продукт→Hub), snapshot-by-subject.
- Публичный error envelope {error, message} для start (как OrderIngestView).
2026-07-09 00:55:03 +03:00

126 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проверка Product Support Token (SPEC-HUB-0011 §4).
HS256 JWT через stdlib (hmac/hashlib/base64) — без внешних зависимостей.
Целевой вариант RS256/EdDSA с JWKS — TODO (SPEC §4.3).
Token envelope claims: iss, aud, contract, iat, exp, jti, data.
Подпись проверяется по per-product secret (Product.support_token_secret, Fernet).
Возвращает разобранные claims; raw token не сохраняется и не логируется.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from datetime import UTC
from typing import Any
from hub_platform.support import errors
SUPPORT_AUDIENCE = "edevshub.support"
@dataclass(frozen=True)
class TokenClaims:
iss: str
aud: str
contract: str
iat: int
exp: int
jti: str
data: dict[str, Any]
@property
def jti_hash(self) -> str:
return hashlib.sha256(self.jti.encode("utf-8")).hexdigest()
def _b64url_decode(segment: str) -> bytes:
padding = "=" * (-len(segment) % 4)
return base64.urlsafe_b64decode(segment + padding)
def verify_support_token(*, token: str, secret: str) -> TokenClaims:
"""Проверяет подпись и envelope claims. Поднимает SupportSessionError при ошибке.
Не проверяет product/contract/channel binding — это делает сервис по claims.
"""
if not token:
raise errors.SupportSessionError(errors.TOKEN_MISSING)
parts = token.split(".")
if len(parts) != 3:
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
header_b64, payload_b64, signature_b64 = parts
try:
header = json.loads(_b64url_decode(header_b64))
except (ValueError, json.JSONDecodeError):
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID) from None
if header.get("alg") != "HS256":
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
if not secret:
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
signing_input = f"{header_b64}.{payload_b64}".encode()
expected = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
given = _b64url_decode(signature_b64)
if not hmac.compare_digest(expected, given):
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
try:
payload = json.loads(_b64url_decode(payload_b64))
except (ValueError, json.JSONDecodeError):
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID) from None
return _claims_from_payload(payload)
def _claims_from_payload(payload: dict[str, Any]) -> TokenClaims:
if not isinstance(payload, dict):
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
iss = payload.get("iss")
aud = payload.get("aud")
contract = payload.get("contract")
iat = payload.get("iat")
exp = payload.get("exp")
jti = payload.get("jti")
data = payload.get("data")
if not isinstance(iss, str) or not iss:
raise errors.SupportSessionError(errors.TOKEN_ISSUER_INVALID)
if aud != SUPPORT_AUDIENCE:
raise errors.SupportSessionError(errors.TOKEN_AUDIENCE_INVALID)
if not isinstance(contract, str) or not contract:
raise errors.SupportSessionError(errors.CONTRACT_NOT_FOUND)
if not isinstance(jti, str) or not jti:
raise errors.SupportSessionError(errors.TOKEN_SIGNATURE_INVALID)
if not isinstance(iat, int) or not isinstance(exp, int):
raise errors.SupportSessionError(errors.TOKEN_EXPIRED)
now = int(time.time())
if exp < now:
raise errors.SupportSessionError(errors.TOKEN_EXPIRED)
if not isinstance(data, dict):
raise errors.SupportSessionError(errors.PAYLOAD_SCHEMA_INVALID)
return TokenClaims(
iss=iss, aud=aud, contract=contract, iat=iat, exp=exp, jti=jti, data=data
)
def claims_datetimes(claims: TokenClaims):
"""iat/exp как aware datetime (для snapshot token_issued_at/expires_at)."""
from datetime import datetime
return (
datetime.fromtimestamp(claims.iat, tz=UTC),
datetime.fromtimestamp(claims.exp, tz=UTC),
)