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:
@@ -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¤cies=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"][¤cy_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(¤cy_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¤cies={}",
|
||||
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"][¤cy_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(¤cy_lower, rate).await;
|
||||
rate
|
||||
};
|
||||
// ----------------------------------------
|
||||
|
||||
let nanotons = Self::calculate_ton_invoice(fiat_amount_minimal, ton_rate)
|
||||
|
||||
Reference in New Issue
Block a user