feat: Apple IAP integration with security hardening

This commit is contained in:
Fringg
2026-05-04 05:49:38 +03:00
parent 99648a956e
commit ecde2fb8f0
11 changed files with 1812 additions and 0 deletions
+12
View File
@@ -69,6 +69,13 @@ from .wheel import router as wheel_router
from .withdrawal import router as withdrawal_router
# Conditional imports
try:
from .apple_iap import router as apple_iap_router
except ImportError:
apple_iap_router = None
# Main cabinet router
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
@@ -81,6 +88,11 @@ router.include_router(subscription_router)
router.include_router(multi_tariff_subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
# Apple IAP routes
if apple_iap_router is not None:
router.include_router(apple_iap_router)
router.include_router(partner_application_router)
router.include_router(withdrawal_router)
# Notifications router MUST be before tickets router to avoid route conflict
+267
View File
@@ -0,0 +1,267 @@
"""Apple In-App Purchase cabinet route."""
from datetime import UTC, datetime
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.apple_iap import (
create_apple_transaction,
)
from app.database.crud.transaction import create_transaction as create_trans
from app.database.crud.user import lock_user_for_update
from app.database.models import PaymentMethod, TransactionType, User
from app.external.apple_iap import AppleIAPService
from app.utils.user_utils import format_referrer_info
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse
logger = structlog.get_logger(__name__)
router = APIRouter(tags=['Cabinet Apple IAP'])
def get_apple_iap_service() -> AppleIAPService:
return AppleIAPService()
@router.post('/apple-purchase', response_model=ApplePurchaseResponse)
async def apple_purchase(
request: ApplePurchaseRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
apple_iap_service: AppleIAPService = Depends(get_apple_iap_service),
):
"""Verify an Apple In-App Purchase and credit the user's balance.
The iOS app calls this endpoint after a successful StoreKit transaction.
If the backend returns success=false, the iOS app will NOT finish the
transaction and will retry on next launch.
"""
if not settings.is_apple_iap_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Apple In-App Purchase is not enabled',
)
# Validate product ID
products = settings.get_apple_iap_products()
if request.product_id not in products:
logger.warning(
'Unknown Apple product ID',
product_id=request.product_id,
user_id=user.id,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unknown product ID',
)
amount_kopeks = products[request.product_id]
# Verify transaction with Apple Server API (no DB lock needed).
# verify_transaction automatically falls back Sandbox<->Production.
txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT)
if not txn_info:
logger.warning(
'Apple transaction verification failed',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# Validate transaction fields
validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id)
if validation_error:
logger.warning(
'Apple transaction validation failed',
error=validation_error,
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# FIX 4: appAccountToken is mandatory -- reject if missing
app_account_token = txn_info.get('appAccountToken')
if not app_account_token:
logger.warning(
'Apple appAccountToken missing -- rejecting transaction',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
if app_account_token != str(user.id):
logger.warning(
'Apple appAccountToken mismatch -- possible replay',
expected=str(user.id),
received=app_account_token,
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=False)
# Detect sandbox transactions -- store actual environment from Apple's response
actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
is_sandbox = actual_environment == 'Sandbox'
if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production':
# Sandbox transaction on a production server (e.g. App Review).
# Record it for audit but do NOT credit real balance.
logger.info(
'Apple sandbox transaction on production -- storing without balance credit',
transaction_id=request.transaction_id,
product_id=request.product_id,
user_id=user.id,
)
try:
async with db.begin_nested():
await create_apple_transaction(
db=db,
user_id=user.id,
transaction_id=request.transaction_id,
original_transaction_id=txn_info.get('originalTransactionId'),
product_id=request.product_id,
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
amount_kopeks=amount_kopeks,
environment='Sandbox',
)
except IntegrityError:
pass # already stored
await db.commit()
return ApplePurchaseResponse(success=True)
# Atomically insert transaction record -- unique constraint on transaction_id
# prevents double-spend even under concurrent requests.
apple_txn = None
try:
async with db.begin_nested():
apple_txn = await create_apple_transaction(
db=db,
user_id=user.id,
transaction_id=request.transaction_id,
original_transaction_id=txn_info.get('originalTransactionId'),
product_id=request.product_id,
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
amount_kopeks=amount_kopeks,
environment=actual_environment,
)
except IntegrityError:
logger.info(
'Apple transaction already processed (idempotent)',
transaction_id=request.transaction_id,
user_id=user.id,
)
return ApplePurchaseResponse(success=True)
# Create financial transaction record
transaction = await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f'Пополнение через Apple IAP: {request.product_id}',
payment_method=PaymentMethod.APPLE_IAP,
external_id=request.transaction_id,
is_completed=True,
commit=False,
)
# FIX 9: Link AppleTransaction to financial Transaction via FK
if apple_txn and transaction:
apple_txn.transaction_id_fk = transaction.id
apple_txn.updated_at = datetime.now(UTC)
# Lock user row and credit balance
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += amount_kopeks
# FIX 10: Update user.updated_at when modifying balance
user.updated_at = datetime.now(UTC)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
await db.commit()
# --- Post-payment side-effects (after atomic commit) ---
from app.database.crud.transaction import emit_transaction_side_effects
try:
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=amount_kopeks,
user_id=user.id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.APPLE_IAP,
external_id=request.transaction_id,
)
except Exception as error:
logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, amount_kopeks, bot=None)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Admin notification + cart auto-purchase
try:
from app.bot_factory import create_bot
bot = create_bot()
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error('Ошибка отправки админ уведомления Apple IAP', error=error)
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, amount_kopeks, db, bot)
except Exception as error:
logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error)
finally:
await bot.session.close()
except Exception as error:
logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error)
logger.info(
'Apple IAP purchase credited',
transaction_id=request.transaction_id,
product_id=request.product_id,
amount_kopeks=amount_kopeks,
user_id=user.id,
)
return ApplePurchaseResponse(success=True)
+23
View File
@@ -0,0 +1,23 @@
"""Apple In-App Purchase schemas for cabinet."""
from pydantic import BaseModel, Field, field_validator
class ApplePurchaseRequest(BaseModel):
"""Request to verify and credit an Apple IAP transaction."""
product_id: str = Field(..., description='Apple product ID (e.g. com.bitnet.vpnclient.topup.100)')
transaction_id: str = Field(..., min_length=1, max_length=64, description='Apple StoreKit transaction ID')
@field_validator('transaction_id')
@classmethod
def transaction_id_must_be_numeric(cls, v: str) -> str:
if not v.isdigit():
raise ValueError('transaction_id must contain only digits')
return v
class ApplePurchaseResponse(BaseModel):
"""Response indicating whether the purchase was successfully credited."""
success: bool
+41
View File
@@ -616,6 +616,19 @@ class Settings(BaseSettings):
SEVERPAY_RETURN_URL: str | None = None
SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320
# Apple In-App Purchase
APPLE_IAP_ENABLED: bool = False
APPLE_IAP_KEY_ID: str | None = None
APPLE_IAP_ISSUER_ID: str | None = None
APPLE_IAP_BUNDLE_ID: str = 'com.app.client'
APPLE_IAP_PRIVATE_KEY: str | None = None # .p8 key contents (PEM)
APPLE_IAP_PRIVATE_KEY_PATH: str | None = None # Alternative: path to .p8 file
APPLE_IAP_ENVIRONMENT: str = 'Production' # 'Sandbox' or 'Production'
APPLE_IAP_WEBHOOK_PATH: str = '/apple-iap-webhook'
APPLE_IAP_PRODUCTS: str = (
'{"com.app.client.topup.100":10000,"com.app.client.topup.300":30000,"com.app.client.topup.500":50000}'
)
# PayPear (paypear.ru)
PAYPEAR_ENABLED: bool = False
PAYPEAR_SHOP_ID: str | None = None
@@ -2027,6 +2040,34 @@ class Settings(BaseSettings):
def get_severpay_display_name_html(self) -> str:
return html.escape(self.get_severpay_display_name())
def is_apple_iap_enabled(self) -> bool:
return (
self.APPLE_IAP_ENABLED
and self.APPLE_IAP_KEY_ID is not None
and self.APPLE_IAP_ISSUER_ID is not None
and (self.APPLE_IAP_PRIVATE_KEY is not None or self.APPLE_IAP_PRIVATE_KEY_PATH is not None)
)
def get_apple_iap_products(self) -> dict[str, int]:
"""Return mapping of Apple product ID -> kopeks amount."""
import json as _json
try:
return _json.loads(self.APPLE_IAP_PRODUCTS)
except Exception:
return {}
def get_apple_iap_private_key(self) -> str | None:
"""Return the .p8 private key contents."""
if self.APPLE_IAP_PRIVATE_KEY:
return self.APPLE_IAP_PRIVATE_KEY
if self.APPLE_IAP_PRIVATE_KEY_PATH:
try:
return Path(self.APPLE_IAP_PRIVATE_KEY_PATH).read_text().strip()
except Exception:
return None
return None
def is_paypear_enabled(self) -> bool:
return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None
+83
View File
@@ -0,0 +1,83 @@
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import AppleTransaction
logger = structlog.get_logger(__name__)
async def create_apple_transaction(
db: AsyncSession,
user_id: int,
transaction_id: str,
product_id: str,
bundle_id: str,
amount_kopeks: int,
environment: str,
original_transaction_id: str | None = None,
transaction_id_fk: int | None = None,
) -> AppleTransaction:
apple_txn = AppleTransaction(
user_id=user_id,
transaction_id=transaction_id,
original_transaction_id=original_transaction_id,
product_id=product_id,
bundle_id=bundle_id,
amount_kopeks=amount_kopeks,
environment=environment,
status='verified',
is_paid=True,
paid_at=datetime.now(UTC),
transaction_id_fk=transaction_id_fk,
)
db.add(apple_txn)
await db.flush()
await db.refresh(apple_txn)
logger.info(
'Создана Apple транзакция',
transaction_id=transaction_id,
product_id=product_id,
amount_kopeks=amount_kopeks,
user_id=user_id,
)
return apple_txn
async def get_apple_transaction_by_transaction_id(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
result = await db.execute(select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id))
return result.scalar_one_or_none()
async def get_apple_transaction_by_transaction_id_for_update(
db: AsyncSession, transaction_id: str
) -> AppleTransaction | None:
"""Get apple transaction with FOR UPDATE lock for safe concurrent access."""
result = await db.execute(
select(AppleTransaction).where(AppleTransaction.transaction_id == transaction_id).with_for_update()
)
return result.scalar_one_or_none()
async def mark_apple_transaction_refunded(db: AsyncSession, transaction_id: str) -> AppleTransaction | None:
"""Mark an Apple transaction as refunded. Returns the transaction or None if not found."""
apple_txn = await get_apple_transaction_by_transaction_id(db, transaction_id)
if not apple_txn:
return None
apple_txn.status = 'refunded'
apple_txn.refunded_at = datetime.now(UTC)
await db.flush()
await db.refresh(apple_txn)
logger.info(
'Apple транзакция помечена как возврат',
transaction_id=transaction_id,
user_id=apple_txn.user_id,
)
return apple_txn
+36
View File
@@ -162,6 +162,7 @@ class PaymentMethod(Enum):
KASSA_AI = 'kassa_ai'
RIOPAY = 'riopay'
SEVERPAY = 'severpay'
APPLE_IAP = 'apple_iap'
PAYPEAR = 'paypear'
ROLLYPAY = 'rollypay'
OVERPAY = 'overpay'
@@ -329,6 +330,41 @@ class CryptoBotPayment(Base):
return f'<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>'
class AppleTransaction(Base):
__tablename__ = 'apple_transactions'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
transaction_id = Column(String(64), unique=True, nullable=False, index=True)
original_transaction_id = Column(String(64), nullable=True, index=True)
product_id = Column(String(128), nullable=False)
bundle_id = Column(String(255), nullable=False)
amount_kopeks = Column(Integer, nullable=False)
environment = Column(String(16), nullable=False)
status = Column(String(50), default='verified')
is_paid = Column(Boolean, default=True)
paid_at = Column(AwareDateTime(), nullable=True)
refunded_at = Column(AwareDateTime(), nullable=True)
transaction_id_fk = Column(Integer, ForeignKey('transactions.id'), nullable=True)
metadata_json = Column(JSON, nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
user = relationship('User', backref='apple_transactions')
transaction = relationship('Transaction', backref='apple_transaction')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self):
return f'<AppleTransaction(id={self.id}, txn={self.transaction_id}, product={self.product_id}, status={self.status})>'
class HeleketPayment(Base):
__tablename__ = 'heleket_payments'
+411
View File
@@ -0,0 +1,411 @@
"""Apple App Store Server API client for In-App Purchase verification and webhook handling."""
from __future__ import annotations
import base64
import datetime
import json
import time
from typing import Any
import httpx
import jwt as pyjwt
import structlog
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.x509 import load_der_x509_certificate
from cryptography.x509.oid import ExtensionOID, ObjectIdentifier
from app.config import settings
logger = structlog.get_logger(__name__)
# Apple Root CA - G3 SHA-256 fingerprint for chain pinning
# https://www.apple.com/certificateauthority/
APPLE_ROOT_CA_G3_SHA256 = bytes.fromhex('63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179')
# Apple WWDR Intermediate Certificate OID
APPLE_WWDR_INTERMEDIATE_OID = ObjectIdentifier('1.2.840.113635.100.6.2.1')
PRODUCTION_BASE_URL = 'https://api.storekit.itunes.apple.com'
SANDBOX_BASE_URL = 'https://api.storekit-sandbox.itunes.apple.com'
class AppleIAPService:
"""Service for verifying Apple In-App Purchase transactions and handling notifications."""
def _get_base_url(self, environment: str | None = None) -> str:
env = environment or settings.APPLE_IAP_ENVIRONMENT
if env == 'Sandbox':
return SANDBOX_BASE_URL
return PRODUCTION_BASE_URL
def _generate_jwt(self) -> str:
"""Generate a fresh ES256 JWT for App Store Server API authentication.
Apple recommends generating a new JWT for each request.
"""
private_key = settings.get_apple_iap_private_key()
if not private_key:
raise ValueError('Apple IAP private key is not configured')
now = int(time.time())
payload = {
'iss': settings.APPLE_IAP_ISSUER_ID,
'iat': now,
'exp': now + 3600,
'aud': 'appstoreconnect-v1',
'bid': settings.APPLE_IAP_BUNDLE_ID,
}
headers = {
'alg': 'ES256',
'kid': settings.APPLE_IAP_KEY_ID,
'typ': 'JWT',
}
return pyjwt.encode(payload, private_key, algorithm='ES256', headers=headers)
async def _fetch_transaction(
self,
transaction_id: str,
base_url: str,
) -> httpx.Response | None:
"""Send a GET request to Apple's transaction lookup endpoint."""
url = f'{base_url}/inApps/v1/transactions/{transaction_id}'
token = self._generate_jwt()
async with httpx.AsyncClient(timeout=30) as client:
try:
return await client.get(
url,
headers={'Authorization': f'Bearer {token}'},
)
except httpx.RequestError as e:
logger.error('Apple API request failed', error=str(e), transaction_id=transaction_id)
return None
async def verify_transaction(
self,
transaction_id: str,
environment: str | None = None,
) -> dict[str, Any] | None:
"""Verify a transaction with Apple's App Store Server API.
Follows Apple's recommendation: if the configured environment returns
a 4xx error, retries against the opposite environment. This ensures
Sandbox purchases made during App Review still verify when the server
is configured for Production.
"""
primary_url = self._get_base_url(environment)
# Determine fallback URL (opposite environment)
fallback_url = SANDBOX_BASE_URL if primary_url == PRODUCTION_BASE_URL else PRODUCTION_BASE_URL
for attempt_url in (primary_url, fallback_url):
response = await self._fetch_transaction(transaction_id, attempt_url)
if response is None:
return None # network error -- don't retry
if response.status_code == 200:
return self._parse_transaction_response(response, transaction_id)
# 4xx on primary -> retry on fallback per Apple docs
if 400 <= response.status_code < 500 and attempt_url == primary_url:
logger.info(
'Apple API returned 4xx on primary env, retrying fallback',
status=response.status_code,
primary=attempt_url,
fallback=fallback_url,
transaction_id=transaction_id,
)
continue
# Log the final failure
self._log_api_error(response, transaction_id)
return None
return None
def _parse_transaction_response(
self,
response: httpx.Response,
transaction_id: str,
) -> dict[str, Any] | None:
"""Extract and verify signedTransactionInfo from a 200 response."""
data = response.json()
signed_transaction_info = data.get('signedTransactionInfo')
if signed_transaction_info:
decoded = self._verify_and_decode_jws(signed_transaction_info)
if decoded:
return decoded
logger.warning('Failed to verify signedTransactionInfo', transaction_id=transaction_id)
return None
logger.warning('No signedTransactionInfo in response', transaction_id=transaction_id)
return None
@staticmethod
def _log_api_error(response: httpx.Response, transaction_id: str) -> None:
if response.status_code == 404:
logger.warning('Apple transaction not found', transaction_id=transaction_id)
elif response.status_code == 401:
logger.error('Apple API auth failed -- check key configuration')
elif response.status_code == 429:
logger.warning('Apple API rate limit exceeded')
else:
logger.error(
'Apple API unexpected status',
status=response.status_code,
body=response.text[:500],
transaction_id=transaction_id,
)
def validate_transaction_info(self, txn_info: dict[str, Any], expected_product_id: str) -> str | None:
"""Validate decoded transaction info fields.
Returns None if valid, or an error message string.
"""
bundle_id = txn_info.get('bundleId')
if bundle_id != settings.APPLE_IAP_BUNDLE_ID:
return f'Bundle ID mismatch: {bundle_id}'
product_id = txn_info.get('productId')
if product_id != expected_product_id:
return f'Product ID mismatch: {product_id} != {expected_product_id}'
txn_type = txn_info.get('type')
if txn_type != 'Consumable':
return f'Unexpected transaction type: {txn_type}'
if txn_info.get('revocationDate'):
return f'Transaction was revoked at {txn_info["revocationDate"]}'
return None
def _verify_and_decode_jws(self, jws_token: str) -> dict[str, Any] | None:
"""Verify x5c certificate chain and ES256 signature, then decode the JWS payload.
Returns the decoded payload dict, or None if verification fails.
Used for both outer notification payloads and inner signed data
(signedTransactionInfo, signedRenewalInfo).
"""
try:
parts = jws_token.split('.')
if len(parts) != 3:
logger.warning('Invalid JWS format: expected 3 parts')
return None
# Decode header to get x5c chain
header_b64 = parts[0]
padding = 4 - len(header_b64) % 4
if padding != 4:
header_b64 += '=' * padding
header_json = base64.urlsafe_b64decode(header_b64)
header = json.loads(header_json)
x5c_chain = header.get('x5c', [])
if not x5c_chain:
logger.warning('No x5c certificate chain in JWS header')
return None
# Verify the certificate chain
if not self._verify_x5c_chain(x5c_chain):
logger.warning('x5c certificate chain verification failed')
return None
# Verify the signature using the leaf certificate
leaf_cert_der = base64.b64decode(x5c_chain[0])
leaf_cert = load_der_x509_certificate(leaf_cert_der)
public_key = leaf_cert.public_key()
signing_input = f'{parts[0]}.{parts[1]}'.encode('ascii')
signature_b64 = parts[2]
sig_padding = 4 - len(signature_b64) % 4
if sig_padding != 4:
signature_b64 += '=' * sig_padding
signature = base64.urlsafe_b64decode(signature_b64)
# ES256 signatures from JWS are in raw (r||s) format, convert to DER
if len(signature) == 64:
r = int.from_bytes(signature[:32], 'big')
s = int.from_bytes(signature[32:], 'big')
signature = asym_utils.encode_dss_signature(r, s)
public_key.verify(signature, signing_input, ec.ECDSA(SHA256()))
# Signature valid -- decode payload
return self._decode_jws_payload(jws_token)
except Exception as e:
logger.error('JWS verification failed', error=str(e), exc_info=True)
return None
def verify_notification(self, signed_payload: str) -> dict[str, Any] | None:
"""Verify and decode an App Store Server Notification V2 payload.
Verifies the JWS x5c certificate chain, then returns the decoded payload.
Returns None if verification fails.
"""
return self._verify_and_decode_jws(signed_payload)
def _verify_x5c_chain(self, x5c_chain: list[str]) -> bool:
"""Verify the x5c certificate chain ends with an Apple Root CA."""
try:
if len(x5c_chain) < 2:
logger.warning('x5c chain too short', length=len(x5c_chain))
return False
certs = []
for cert_b64 in x5c_chain:
cert_der = base64.b64decode(cert_b64)
cert = load_der_x509_certificate(cert_der)
certs.append(cert)
# Check certificate validity periods
now = datetime.datetime.now(datetime.UTC)
for i, cert in enumerate(certs):
if now < cert.not_valid_before_utc:
logger.warning('x5c cert not yet valid', index=i, not_before=str(cert.not_valid_before_utc))
return False
if now > cert.not_valid_after_utc:
logger.warning('x5c cert expired', index=i, not_after=str(cert.not_valid_after_utc))
return False
# Pin the root (last) certificate by SHA-256 fingerprint
root_cert = certs[-1]
root_fingerprint = root_cert.fingerprint(SHA256())
if root_fingerprint != APPLE_ROOT_CA_G3_SHA256:
logger.warning(
'Root CA fingerprint mismatch -- not genuine Apple Root CA - G3',
got=root_fingerprint.hex(),
)
return False
# Verify each certificate is signed by the next one in the chain
for i in range(len(certs) - 1):
child = certs[i]
parent = certs[i + 1]
parent_public_key = parent.public_key()
parent_public_key.verify(
child.signature,
child.tbs_certificate_bytes,
ec.ECDSA(child.signature_hash_algorithm),
)
# FIX 3: Validate Apple WWDR intermediate OID
# The intermediate cert (index 1) must contain the Apple WWDR OID
# to ensure it is a genuine Apple WWDR intermediate certificate.
if len(certs) >= 2:
intermediate_cert = certs[1]
try:
# Check for the Apple WWDR OID in certificate extensions
found_apple_oid = False
for ext in intermediate_cert.extensions:
if ext.oid == ExtensionOID.CERTIFICATE_POLICIES:
for policy in ext.value:
if policy.policy_identifier == APPLE_WWDR_INTERMEDIATE_OID:
found_apple_oid = True
break
if found_apple_oid:
break
if not found_apple_oid:
logger.warning(
'Intermediate cert missing Apple WWDR OID',
oid=str(APPLE_WWDR_INTERMEDIATE_OID),
)
return False
except x509.ExtensionNotFound:
logger.warning('Intermediate cert has no certificate policies extension')
return False
return True
except Exception as e:
logger.error('x5c chain verification error', error=str(e))
return False
def _decode_jws_payload(self, jws_token: str) -> dict[str, Any] | None:
"""Decode the payload from a JWS token without signature verification.
Use only after the signature has already been verified.
"""
try:
parts = jws_token.split('.')
if len(parts) != 3:
return None
payload_b64 = parts[1]
# Add base64url padding
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += '=' * padding
payload_json = base64.urlsafe_b64decode(payload_b64)
return json.loads(payload_json)
except Exception as e:
logger.error('Failed to decode JWS payload', error=str(e))
return None
async def send_consumption_info(
self,
transaction_id: str,
customer_consented: bool,
consumption_status: int = 0,
delivery_status: int = 0,
lifetime_dollars_purchased: int = 0,
lifetime_dollars_refunded: int = 0,
platform: int = 1,
play_time: int = 0,
sample_content_provided: bool = False,
user_status: int = 0,
environment: str | None = None,
refund_preference: int | None = None,
) -> bool:
"""Send consumption information to Apple in response to CONSUMPTION_REQUEST.
Must be sent within 12 hours of receiving the notification.
"""
base_url = self._get_base_url(environment)
url = f'{base_url}/inApps/v2/transactions/consumption/{transaction_id}'
token = self._generate_jwt()
body: dict[str, Any] = {
'customerConsented': customer_consented,
'consumptionStatus': consumption_status,
'deliveryStatus': delivery_status,
'lifetimeDollarsPurchased': lifetime_dollars_purchased,
'lifetimeDollarsRefunded': lifetime_dollars_refunded,
'platform': platform,
'playTime': play_time,
'sampleContentProvided': sample_content_provided,
'userStatus': user_status,
}
if refund_preference is not None:
body['refundPreference'] = refund_preference
async with httpx.AsyncClient(timeout=30) as client:
try:
response = await client.put(
url,
json=body,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
},
)
except httpx.RequestError as e:
logger.error('Apple consumption API request failed', error=str(e))
return False
if response.status_code == 202:
logger.info('Consumption info sent to Apple', transaction_id=transaction_id)
return True
logger.error(
'Apple consumption API error',
status=response.status_code,
body=response.text[:500],
transaction_id=transaction_id,
)
return False
+338
View File
@@ -57,6 +57,9 @@ class WebhookServer:
self.app.router.add_get('/health', self._health_check)
if settings.is_apple_iap_enabled():
self.app.router.add_post(settings.APPLE_IAP_WEBHOOK_PATH, self._apple_iap_webhook_handler)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
if settings.is_mulenpay_enabled():
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
@@ -64,6 +67,8 @@ class WebhookServer:
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
if settings.is_freekassa_enabled():
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
if settings.is_apple_iap_enabled():
self.app.router.add_options(settings.APPLE_IAP_WEBHOOK_PATH, self._options_handler)
logger.info('Webhook сервер настроен:')
logger.info('Tribute webhook: POST', TRIBUTE_WEBHOOK_PATH=settings.TRIBUTE_WEBHOOK_PATH)
@@ -76,6 +81,8 @@ class WebhookServer:
logger.info('CryptoBot webhook: POST', CRYPTOBOT_WEBHOOK_PATH=settings.CRYPTOBOT_WEBHOOK_PATH)
if settings.is_freekassa_enabled():
logger.info('Freekassa webhook: POST', FREEKASSA_WEBHOOK_PATH=settings.FREEKASSA_WEBHOOK_PATH)
if settings.is_apple_iap_enabled():
logger.info('Apple IAP webhook: POST', APPLE_IAP_WEBHOOK_PATH=settings.APPLE_IAP_WEBHOOK_PATH)
logger.info(' - Health check: GET /health')
return self.app
@@ -491,3 +498,334 @@ class WebhookServer:
except Exception as e:
logger.error('Критическая ошибка обработки Freekassa webhook', error=e, exc_info=True)
return web.Response(text='NO', status=500)
async def _apple_iap_webhook_handler(self, request: web.Request) -> web.Response:
"""Handle Apple App Store Server Notifications V2."""
try:
logger.info('Получен Apple IAP webhook', method=request.method, path=request.path)
raw_body = await request.read()
if not raw_body:
logger.warning('Пустой Apple IAP webhook')
return web.Response(status=400)
try:
body = json.loads(raw_body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
logger.error('Ошибка парсинга Apple IAP webhook', error=e)
return web.Response(status=400)
signed_payload = body.get('signedPayload')
if not signed_payload:
logger.warning('No signedPayload in Apple webhook')
return web.Response(status=400)
# Verify and decode the notification
from app.external.apple_iap import AppleIAPService
apple_service = AppleIAPService()
notification = apple_service.verify_notification(signed_payload)
if not notification:
logger.warning('Apple webhook signature verification failed')
return web.Response(status=403)
notification_type = notification.get('notificationType', '')
subtype = notification.get('subtype', '')
# Verify notification environment matches our config
# FIX 11: removed dead initial assignment of expected_envs
notif_env = notification.get('data', {}).get('environment', '')
if settings.APPLE_IAP_ENVIRONMENT == 'Production':
expected_envs = {'Production', 'Sandbox'} # Sandbox for App Review
else:
expected_envs = {'Sandbox'}
if notif_env and notif_env not in expected_envs:
logger.warning(
'Apple webhook environment mismatch',
expected=settings.APPLE_IAP_ENVIRONMENT,
received=notif_env,
)
return web.Response(status=200) # ACK but ignore
logger.info(
'Apple notification received',
notification_type=notification_type,
subtype=subtype,
environment=notif_env,
)
# Handle notification types
if notification_type == 'TEST':
logger.info('Apple TEST notification received -- OK')
return web.Response(status=200)
if notification_type == 'REFUND':
await self._handle_apple_refund(notification, apple_service)
return web.Response(status=200)
if notification_type == 'REFUND_REVERSED':
await self._handle_apple_refund_reversed(notification)
return web.Response(status=200)
if notification_type == 'CONSUMPTION_REQUEST':
await self._handle_apple_consumption_request(notification, apple_service)
return web.Response(status=200)
if notification_type in ('ONE_TIME_CHARGE', 'REFUND_DECLINED'):
logger.info('Apple notification logged', notification_type=notification_type)
return web.Response(status=200)
logger.info('Unhandled Apple notification type', notification_type=notification_type)
return web.Response(status=200)
except Exception as e:
logger.error('Критическая ошибка обработки Apple IAP webhook', error=e, exc_info=True)
return web.Response(status=500)
async def _handle_apple_refund(self, notification: dict, apple_service) -> None:
"""Handle REFUND notification -- deduct credited balance."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in REFUND notification')
return
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify REFUND transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
original_txn_id = str(txn_info.get('originalTransactionId') or '')
product_id = txn_info.get('productId', '')
from app.database.crud.apple_iap import (
get_apple_transaction_by_transaction_id,
mark_apple_transaction_refunded,
)
from app.database.crud.user import lock_user_for_pricing
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
lookup_id = original_txn_id or apple_txn_id
async with AsyncSessionLocal() as db:
apple_txn = await get_apple_transaction_by_transaction_id(db, lookup_id)
if not apple_txn:
# Try the other ID
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
if not apple_txn:
logger.warning(
'Apple REFUND: transaction not found',
transaction_id=apple_txn_id,
original_transaction_id=original_txn_id,
)
return
if apple_txn.status == 'refunded':
logger.info('Apple REFUND: already refunded', transaction_id=lookup_id)
return
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
logger.info(
'Apple REFUND: ignoring sandbox refund on production',
transaction_id=lookup_id,
user_id=apple_txn.user_id,
)
return
# FIX 6: Lock user row with FOR UPDATE before reading balance
# to prevent race condition in min() balance cap calculation
user = await lock_user_for_pricing(db, apple_txn.user_id)
if not user:
logger.error('Apple REFUND: user not found', user_id=apple_txn.user_id)
return
# Cap deduction to current balance to prevent negative balance
refund_amount = min(apple_txn.amount_kopeks, user.balance_kopeks)
if refund_amount < apple_txn.amount_kopeks:
logger.warning(
'Apple REFUND: partial balance deduction (user already spent funds)',
full_amount=apple_txn.amount_kopeks,
deducted=refund_amount,
user_balance=user.balance_kopeks,
user_id=user.id,
)
# Disable active subscriptions -- funds were spent and refunded
from app.database.crud.subscription import (
deactivate_subscription,
get_active_subscriptions_by_user_id,
)
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
for sub in active_subs:
await deactivate_subscription(db, sub, commit=False)
logger.warning(
'Apple REFUND: disabled subscription due to insufficient balance',
subscription_id=sub.id,
user_id=user.id,
)
if refund_amount > 0:
from app.database.crud.user import subtract_user_balance
await subtract_user_balance(
db=db,
user=user,
amount_kopeks=refund_amount,
description=f'Возврат Apple IAP: {product_id}',
create_transaction=True,
payment_method=PaymentMethod.APPLE_IAP,
transaction_type=TransactionType.REFUND,
commit=False,
)
await mark_apple_transaction_refunded(db, apple_txn.transaction_id)
await db.commit()
logger.info(
'Apple REFUND processed',
transaction_id=apple_txn.transaction_id,
amount_kopeks=apple_txn.amount_kopeks,
user_id=user.id,
)
except Exception as e:
logger.error('Error handling Apple REFUND', error=e, exc_info=True)
async def _handle_apple_refund_reversed(self, notification: dict) -> None:
"""Handle REFUND_REVERSED -- re-credit balance that was previously deducted."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in REFUND_REVERSED notification')
return
from app.external.apple_iap import AppleIAPService
apple_service = AppleIAPService()
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify REFUND_REVERSED transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
original_txn_id = str(txn_info.get('originalTransactionId') or '')
product_id = txn_info.get('productId', '')
from app.database.crud.apple_iap import (
get_apple_transaction_by_transaction_id_for_update,
)
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod
lookup_id = original_txn_id or apple_txn_id
async with AsyncSessionLocal() as db:
# FIX 7: Use FOR UPDATE lock on apple_transactions row
# before checking status to prevent idempotency race
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, lookup_id)
if not apple_txn:
apple_txn = await get_apple_transaction_by_transaction_id_for_update(db, apple_txn_id)
if not apple_txn:
logger.warning(
'Apple REFUND_REVERSED: transaction not found',
transaction_id=apple_txn_id,
)
return
if apple_txn.status != 'refunded':
logger.info(
'Apple REFUND_REVERSED: transaction not in refunded state',
transaction_id=lookup_id,
status=apple_txn.status,
)
return
if apple_txn.environment == 'Sandbox' and settings.APPLE_IAP_ENVIRONMENT == 'Production':
logger.info(
'Apple REFUND_REVERSED: ignoring sandbox on production',
transaction_id=lookup_id,
)
return
user = await get_user_by_id(db, apple_txn.user_id)
if not user:
logger.error('Apple REFUND_REVERSED: user not found', user_id=apple_txn.user_id)
return
# Re-credit the balance
await add_user_balance(
db=db,
user=user,
amount_kopeks=apple_txn.amount_kopeks,
description=f'Отмена возврата Apple IAP: {product_id}',
payment_method=PaymentMethod.APPLE_IAP,
commit=False,
)
apple_txn.status = 'verified'
apple_txn.refunded_at = None
await db.commit()
logger.info(
'Apple REFUND_REVERSED processed -- balance re-credited',
transaction_id=lookup_id,
amount_kopeks=apple_txn.amount_kopeks,
user_id=user.id,
)
except Exception as e:
logger.error('Error handling Apple REFUND_REVERSED', error=e, exc_info=True)
async def _handle_apple_consumption_request(self, notification: dict, apple_service) -> None:
"""Handle CONSUMPTION_REQUEST -- send consumption info to Apple."""
try:
data = notification.get('data', {})
signed_txn_info = data.get('signedTransactionInfo')
if not signed_txn_info:
logger.warning('No signedTransactionInfo in CONSUMPTION_REQUEST')
return
txn_info = apple_service._verify_and_decode_jws(signed_txn_info)
if not txn_info:
logger.warning('Failed to verify CONSUMPTION_REQUEST transaction info')
return
apple_txn_id = str(txn_info.get('transactionId') or '')
environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
from app.database.crud.apple_iap import get_apple_transaction_by_transaction_id
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
apple_txn = await get_apple_transaction_by_transaction_id(db, apple_txn_id)
# Determine if balance was consumed (spent on subscriptions)
# consumptionStatus: 0 = undeclared, 1 = not consumed, 2 = partially consumed, 3 = fully consumed
consumption_status = 0
if apple_txn and apple_txn.status == 'verified':
consumption_status = 3 # Balance was credited and likely spent
# customerConsented must be false -- we cannot prompt the user
# in a server-to-server webhook. Apple accepts the response
# regardless, but the consumption data weight may be lower.
await apple_service.send_consumption_info(
transaction_id=apple_txn_id,
customer_consented=False,
consumption_status=consumption_status,
delivery_status=0, # 0 = delivered
platform=1, # 1 = Apple
environment=environment,
)
logger.info('Apple CONSUMPTION_REQUEST handled', transaction_id=apple_txn_id)
except Exception as e:
logger.error('Error handling Apple CONSUMPTION_REQUEST', error=e, exc_info=True)
+3
View File
@@ -31,6 +31,7 @@ from app.database.models import (
AdminRole,
AdvertisingCampaign,
AdvertisingCampaignRegistration,
AppleTransaction,
AuraPayPayment,
BroadcastHistory,
ButtonClickLog,
@@ -204,6 +205,7 @@ class BackupService:
RollyPayPayment,
OverpayPayment,
AuraPayPayment,
AppleTransaction,
SavedPaymentMethod,
# --- Settings/content ---
PaymentMethodConfig,
@@ -1515,6 +1517,7 @@ class BackupService:
'rollypay_payments',
'overpay_payments',
'aurapay_payments',
'apple_transactions',
'saved_payment_methods',
# --- Content/config ---
'pinned_messages',