security: fix CSRF/ownership/timing vulns; add billing reconciliation

- auth/guard: добавить CSRF-проверку Origin/Referer для cookie-мутаций,
  исправить инвертированную логику admin_guard (пускал всех, блокировал админов),
  перевести admin_guard на from_fn_with_state
- auth/service: timing-safe сравнение HMAC через verify_slice вместо != по hex;
  криптостойкий Seed через Alphanumeric (был цифровой 0-9)
- billing: confirm_payment проверяет owner (caller_id == invoice.user_id);
  активация триала только для Telegram-аккаунтов (Sybil-защита);
  реализована reconcile_pending_payments — сверка blockchain->pending-инвойсы
- billing/providers: get_recent_transactions перенесён в трейт PaymentProvider
- nodes/service: unwrap() -> map_err по всему SSH-пути; убран TcpStream import
- docker: multi-stage build с cargo-chef, non-root user, healthcheck;
  prod-compose добавляет one-shot migrate-сервис, убирает проброс порта БД
- deploy.yml: параметр image_tag, set -e, pull всего стека вместо только app
- users/service: обработка NO_TICKETS / NO_USER из БД вместо catch-all

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kirill
2026-06-29 17:58:24 +07:00
parent 26d6f2db8a
commit f06cc48763
19 changed files with 605 additions and 177 deletions
+81 -2
View File
@@ -115,9 +115,11 @@ impl BillingService {
provider.create_checkout_session(&invoice_data).await
}
/// Этап 2: Вебхук или ручная проверка оплаты юзером
/// Этап 2: Вебхук или ручная проверка оплаты юзером.
/// `caller_id` — кто инициировал подтверждение (для проверки владения инвойсом).
pub async fn confirm_payment(
&self,
caller_id: Uuid,
invoice_id: Uuid,
external_tx_id: &str,
) -> Result<(), AppError> {
@@ -128,6 +130,14 @@ impl BillingService {
.await?
.ok_or_else(|| AppError::NotFound("Инвойс не найден".into()))?;
// Подтверждать инвойс может только его владелец. Иначе чужой юзер мог бы
// инициировать обращения к блокчейну по произвольным invoice_id (перебор/DoS).
if invoice.user_id != caller_id {
return Err(AppError::Unauthorized(
"Этот инвойс принадлежит другому оперативнику.".into(),
));
}
if invoice.status == "paid" {
return Ok(());
}
@@ -169,7 +179,76 @@ impl BillingService {
Ok(())
}
/// Фоновая сверка блокчейна: находит pending-инвойсы, по которым уже прошла
/// оплата на мастер-кошелёк (но вебхук/ручное подтверждение потерялись),
/// и доводит их до статуса `paid`. Возвращает число добитых инвойсов.
pub async fn reconcile_pending_payments(&self) -> Result<u64, AppError> {
let pending = self
.repo
.get_pending_invoices()
.await
.map_err(AppError::Database)?;
if pending.is_empty() {
return Ok(0);
}
let provider = self
.providers
.get("TON")
.ok_or_else(|| AppError::Internal("TON provider не сконфигурирован".into()))?;
// Снимок последних транзакций кошелька: comment(invoice_id) -> tx_hash
let txs = provider.get_recent_transactions().await?;
let mut hash_by_comment: HashMap<String, String> = HashMap::new();
for tx in &txs {
let comment = tx["in_msg"]["decoded_body"]["text"].as_str().unwrap_or("");
let hash = tx["hash"].as_str().unwrap_or("");
if !comment.is_empty() && !hash.is_empty() {
hash_by_comment
.entry(comment.to_string())
.or_insert_with(|| hash.to_string());
}
}
let mut confirmed = 0u64;
for invoice in pending {
if let Some(tx_hash) = hash_by_comment.get(&invoice.id.to_string()) {
// confirm_payment повторно валидирует сумму/адрес/коммент on-chain,
// так что ложное срабатывание по совпадению комментария исключено.
// Сверка системная — владельцем выступает сам пользователь инвойса.
match self
.confirm_payment(invoice.user_id, invoice.id, tx_hash)
.await
{
Ok(_) => {
confirmed += 1;
tracing::info!(
"[{}] Реконсиляция: добит потерянный платёж по инвойсу {}",
provider.provider_id(),
invoice.id
);
}
Err(e) => {
tracing::warn!("Реконсиляция инвойса {} не удалась: {:?}", invoice.id, e)
}
}
}
}
Ok(confirmed)
}
pub async fn activate_trial(&self, user: &User) -> Result<(), AppError> {
// Защита от Sybil-атаки: анонимные Ghost-аккаунты (вход по Seed) генерируются
// бесплатно и без лимита, поэтому им триал не положен — иначе любой скрипт
// штампует новые Seed и крутит бесконечный бесплатный VPN. Триал привязан к
// Telegram-личности (tg_id), которую так дёшево не размножить.
if user.tg_id.is_none() {
return Err(AppError::BusinessLogic(
"Пробный период доступен только для аккаунтов, привязанных к Telegram.".into(),
));
}
if user.has_used_trial {
return Err(AppError::TrialAlreadyUsed);
}
@@ -190,7 +269,7 @@ impl BillingService {
fiat_amount_minimal: i64, // В копейках/центах из БД
ton_rate_to_currency: rust_decimal::Decimal, // Полный путь до типа, чтобы не было конфликтов имён
) -> Result<i64, String> {
use rust_decimal::prelude::{ToPrimitive, Zero};
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
// 1. Создаем константу 100 безопасным способом (100.00)