Unify auth into refresh-token cookie sessions; server-authoritative nodes and Cyberhack

Auth was effectively broken for every login path: the Telegram handler
built a Set-Cookie header but never attached it to the response, and
Ghost Protocol (seed) login never set a cookie at all — so billing, the
game, and admin actions never actually worked once the frontend was fixed
to stop trying to read an HttpOnly cookie from JS. Replaced the bare
10-minute JWT with AuthService::issue_session: a 15-minute access cookie
plus a rotating 30-day refresh cookie (opaque token, only its SHA-256
hash stored in the new refresh_sessions table); reusing an already-
rotated refresh token now revokes every session for that user. CSRF/CORS
origin allowlists moved from a single hardcoded domain to env-configured
lists so the same session works across the landing and account subdomains.
The bot's WebApp deep links (Личный Кабинет/Тарифы/Синдикат) now bridge
through a short-lived bootstrap token exchanged via POST /auth/exchange
instead of a bare access token in the URL.

Nodes: /nodes/routing now serializes the tunnel_* fields, public_key,
sni_domain and a one-time session token gated on an active subscription
instead of a stub ip/port/protocol list; node provisioning returns 202
immediately and finishes in the background instead of blocking the
request for the whole SSH run; a new background task TCP-pings nodes
every 60s and flips online/offline itself; admin can now force-restart a
node over SSH.

Billing: TON exchange rate is cached in Redis (60s) instead of hitting
TonAPI on every invoice, and the request/response now actually uses the
requested currency and TonAPI's real (uppercase) key casing — previously
only USD/RUB were ever requested and the lookup used the wrong case, so
non-USD/RUB plans could never price correctly.

Cyberhack: the server now generates and stores the board itself and
replays the client's raw click path to compute the score, instead of
clamping a client-reported number — closes the "final score is whatever
the browser sends" hole flagged in the security audit.

Frontend: api.ts no longer tries to read the HttpOnly session cookie from
JS (that never worked) and instead relies on same-origin credentials plus
a silent refresh-and-retry on 401; AdminDashboard's restart/delete actions
are wired up; Auth.tsx drops the artificial delays and requires an
explicit seed download/confirmation before continuing, since a lost Ghost
seed is unrecoverable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:29:51 +07:00
parent 588adbb92b
commit 52531dd41a
41 changed files with 2219 additions and 508 deletions
+71 -21
View File
@@ -6,13 +6,22 @@ use crate::{
},
error::AppError,
};
use redis::AsyncCommands;
use rust_decimal::prelude::FromPrimitive;
use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
/// Курс TON меняется медленно относительно времени жизни одного инвойса —
/// 60с кеша достаточно, чтобы не бить TonAPI на каждый `initiate_payment`,
/// но не настолько долго, чтобы курс успел заметно уйти от рынка.
const TON_RATE_CACHE_TTL_SEC: u64 = 60;
pub struct BillingService {
repo: Arc<dyn AppRepository>,
providers: HashMap<&'static str, Box<dyn PaymentProvider>>,
// Redis — только кеш, не источник истины: если он недоступен, просто
// ходим в TonAPI напрямую на каждый запрос (как было раньше).
redis: Option<redis::aio::ConnectionManager>,
}
// Добавляем Clone вручную, так как dyn Traits не поддерживают derive(Clone) легко.
@@ -35,12 +44,13 @@ impl Clone for BillingService {
Self {
repo: self.repo.clone(),
providers,
redis: self.redis.clone(),
}
}
}
impl BillingService {
pub fn new(repo: Arc<dyn AppRepository>) -> Self {
pub fn new(repo: Arc<dyn AppRepository>, redis: Option<redis::aio::ConnectionManager>) -> Self {
// Читаем адрес из переменной окружения
let master_wallet = std::env::var("TON_MASTER_WALLET")
.expect("КРИТИЧЕСКАЯ ОШИБКА: TON_MASTER_WALLET не задан в .env!");
@@ -51,7 +61,30 @@ impl BillingService {
Box::new(super::providers::ton::TonProvider { master_wallet }),
);
Self { repo, providers }
Self {
repo,
providers,
redis,
}
}
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
let mut conn = self.redis.clone()?;
let cache_key = format!("ton_rate:{}", currency_lower);
let cached: Option<String> = conn.get(&cache_key).await.ok()?;
cached.and_then(|v| v.parse().ok())
}
async fn cache_ton_rate(&self, currency_lower: &str, rate: rust_decimal::Decimal) {
let Some(mut conn) = self.redis.clone() else {
return;
};
let cache_key = format!("ton_rate:{}", currency_lower);
let _: Result<(), _> = conn
.set_ex(&cache_key, rate.to_string(), TON_RATE_CACHE_TTL_SEC)
.await;
}
pub async fn get_subscription(&self, user_id: Uuid) -> Result<Option<Subscription>, AppError> {
@@ -82,27 +115,44 @@ impl BillingService {
AppError::BusinessLogic("Тариф недоступен для данного региона".into())
})?;
// --- ДИНАМИЧЕСКИЙ ЗАПРОС КУРСА TON ---
// TODO: синхронный HTTP-запрос к TonAPI на каждый вызов initiate_payment
// тормозит создание инвойса и делает эндпоинт зависимым от доступности
// внешнего сервиса. Вынести в фоновый воркер с кешем в Redis (уже подключён).
let client = reqwest::Client::new();
let rate_res: serde_json::Value = client
.get("https://tonapi.io/v2/rates?tokens=ton&currencies=usd,rub")
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
// --- КУРС TON: сначала кеш (Redis, TTL 60с), иначе синхронный запрос к TonAPI ---
let currency_lower = currency.to_lowercase();
let rate_f64 = rate_res["rates"]["TON"]["prices"][&currency_lower]
.as_f64()
.ok_or_else(|| AppError::Internal("TonAPI не вернул курс для этой валюты".into()))?;
let currency_upper = currency.to_uppercase();
let ton_rate = rust_decimal::Decimal::from_f64(rate_f64)
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
let ton_rate = if let Some(cached) = self.get_cached_ton_rate(&currency_lower).await {
cached
} else {
let client = reqwest::Client::new();
// TonAPI и запрашивать, и отдавать валюту нужно по одному и тому же
// коду: раньше здесь были жёстко зашиты "usd,rub" в запросе (курс для
// любой другой валюты из plan_prices — CNY/TRY/IRR — никогда бы не
// нашёлся), а ответ разбирался по НИЖНЕМУ регистру, хотя TonAPI
// возвращает ключи валют в ВЕРХНЕМ (`prices.USD`, не `prices.usd`).
let url = format!(
"https://tonapi.io/v2/rates?tokens=ton&currencies={}",
currency_lower
);
let rate_res: serde_json::Value = client
.get(&url)
.send()
.await
.map_err(|e| AppError::Internal(format!("Ошибка сети при запросе курса: {}", e)))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Ошибка парсинга курса TON: {}", e)))?;
let rate_f64 = rate_res["rates"]["TON"]["prices"][&currency_upper]
.as_f64()
.ok_or_else(|| {
AppError::Internal("TonAPI не вернул курс для этой валюты".into())
})?;
let rate = rust_decimal::Decimal::from_f64(rate_f64)
.ok_or_else(|| AppError::Internal("Ошибка конвертации f64 в Decimal".into()))?;
self.cache_ton_rate(&currency_lower, rate).await;
rate
};
// ----------------------------------------
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)