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:
+229
-3
@@ -39,10 +39,48 @@ pub trait AppRepository: Send + Sync {
|
||||
async fn create_seed_user(&self, seed_hash: &str) -> Result<User, sqlx::Error>;
|
||||
async fn get_user_by_seed_hash(&self, hash: &str) -> Result<Option<User>, sqlx::Error>;
|
||||
|
||||
// --- REFRESH-СЕССИИ (единая cookie-авторизация) ---
|
||||
async fn create_refresh_session(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
token_hash: &str,
|
||||
user_agent: Option<&str>,
|
||||
expires_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn get_refresh_session_by_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<RefreshSession>, sqlx::Error>;
|
||||
async fn revoke_refresh_session(&self, token_hash: &str) -> Result<(), sqlx::Error>;
|
||||
async fn revoke_all_refresh_sessions(&self, user_id: Uuid) -> Result<(), sqlx::Error>;
|
||||
|
||||
// --- БАЛАНС И ЭКОНОМИКА (CYBERHACK) ---
|
||||
async fn add_balance(&self, user_id: Uuid, amount: i64) -> Result<(), sqlx::Error>;
|
||||
async fn consume_ticket_and_reward(&self, user_id: Uuid, reward: i64) -> Result<bool, String>;
|
||||
|
||||
/// Server-authoritative Cyberhack: списывает билет (та же блокировка/rate-limit,
|
||||
/// что и `consume_ticket_and_reward`, но БЕЗ начисления награды — награда
|
||||
/// начисляется позже, в `consume_hack_session`, по серверно пересчитанному
|
||||
/// результату) и сохраняет сгенерированную сервером доску на `ttl_seconds`.
|
||||
async fn start_hack_session(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
matrix: &[Vec<String>],
|
||||
targets: &[Vec<String>],
|
||||
base_value: i32,
|
||||
ttl_seconds: i64,
|
||||
) -> Result<Uuid, String>;
|
||||
|
||||
/// Атомарно помечает сессию использованной и возвращает её доску — второй
|
||||
/// вызов с тем же `session_id` вернёт `None` (защита от повторной сдачи
|
||||
/// одного и того же результата на двойную награду).
|
||||
#[allow(clippy::type_complexity)]
|
||||
async fn consume_hack_session(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<(Vec<Vec<String>>, Vec<Vec<String>>, i32)>, sqlx::Error>;
|
||||
|
||||
// --- БИЛЛИНГ, ИНВОЙСЫ И ПОДПИСКИ (V2) ---
|
||||
async fn get_plan_price(
|
||||
&self,
|
||||
@@ -87,8 +125,19 @@ pub trait AppRepository: Send + Sync {
|
||||
async fn apply_promo_code(&self, user_id: Uuid, code: &str) -> Result<i64, String>;
|
||||
|
||||
// --- VPN УЗЛЫ (CONTROL PLANE) ---
|
||||
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
|
||||
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
|
||||
/// Все ноды независимо от статуса — для админки (иначе `provisioning`/`offline`
|
||||
/// ноды не видны в дашборде вообще, только "online").
|
||||
async fn get_all_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
|
||||
async fn get_node_by_id(&self, node_id: Uuid) -> Result<Option<VpnNode>, sqlx::Error>;
|
||||
async fn add_vpn_node(&self, p: CreateNodePayload) -> Result<VpnNode, sqlx::Error>;
|
||||
/// Вставляет ноду сразу в статусе `provisioning` — используется, пока реальный
|
||||
/// SSH-провижининг идёт в фоне (см. `NodeService::add_node`).
|
||||
async fn add_vpn_node_pending(&self, p: CreateNodePayload) -> Result<VpnNode, sqlx::Error>;
|
||||
/// Обновляет статус ноды (health-check воркер, завершение провижининга).
|
||||
/// `last_seen` двигается вперёд только при переходе в `online`.
|
||||
async fn update_node_health(&self, node_id: Uuid, status: &str) -> Result<(), sqlx::Error>;
|
||||
async fn delete_vpn_node(&self, node_id: Uuid) -> Result<(), sqlx::Error>;
|
||||
}
|
||||
|
||||
@@ -173,6 +222,66 @@ impl AppRepository for PgRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self, token_hash))]
|
||||
async fn create_refresh_session(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
token_hash: &str,
|
||||
user_agent: Option<&str>,
|
||||
expires_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_sessions (id, user_id, token_hash, user_agent, expires_at) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(user_id)
|
||||
.bind(token_hash)
|
||||
.bind(user_agent)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self, token_hash))]
|
||||
async fn get_refresh_session_by_hash(
|
||||
&self,
|
||||
token_hash: &str,
|
||||
) -> Result<Option<RefreshSession>, sqlx::Error> {
|
||||
sqlx::query_as::<_, RefreshSession>(
|
||||
"SELECT id, user_id, token_hash, expires_at, revoked_at \
|
||||
FROM refresh_sessions WHERE token_hash = $1",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self, token_hash))]
|
||||
async fn revoke_refresh_session(&self, token_hash: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE refresh_sessions SET revoked_at = NOW() \
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(token_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn revoke_all_refresh_sessions(&self, user_id: Uuid) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE refresh_sessions SET revoked_at = NOW() \
|
||||
WHERE user_id = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_aggregated_profile(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -268,6 +377,85 @@ impl AppRepository for PgRepository {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, matrix, targets))]
|
||||
async fn start_hack_session(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
matrix: &[Vec<String>],
|
||||
targets: &[Vec<String>],
|
||||
base_value: i32,
|
||||
ttl_seconds: i64,
|
||||
) -> Result<Uuid, String> {
|
||||
let mut tx = self.pool.begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Та же блокировка/rate-limit, что и consume_ticket_and_reward, но без
|
||||
// начисления награды здесь — награда начисляется на сдаче результата,
|
||||
// по серверно пересчитанному score, а не тому, что попросит клиент.
|
||||
let row: Option<(i32, Option<chrono::DateTime<chrono::Utc>>)> =
|
||||
sqlx::query_as("SELECT game_tickets, last_hack_at FROM users WHERE id = $1 FOR UPDATE")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (tickets, last_hack_at) = row.ok_or_else(|| "NO_USER".to_string())?;
|
||||
|
||||
if tickets <= 0 {
|
||||
return Err("NO_TICKETS".to_string());
|
||||
}
|
||||
if let Some(last) = last_hack_at {
|
||||
if chrono::Utc::now() - last < chrono::Duration::seconds(45) {
|
||||
return Err("TOO_FREQUENT".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users SET game_tickets = game_tickets - 1, last_hack_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds);
|
||||
sqlx::query(
|
||||
"INSERT INTO hack_sessions (id, user_id, matrix, targets, base_value, expires_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(user_id)
|
||||
.bind(Json(matrix))
|
||||
.bind(Json(targets))
|
||||
.bind(base_value)
|
||||
.bind(expires_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
tx.commit().await.map_err(|e| e.to_string())?;
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn consume_hack_session(
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<(Vec<Vec<String>>, Vec<Vec<String>>, i32)>, sqlx::Error> {
|
||||
let row: Option<(Json<Vec<Vec<String>>>, Json<Vec<Vec<String>>>, i32)> = sqlx::query_as(
|
||||
"UPDATE hack_sessions SET used_at = NOW() \
|
||||
WHERE id = $1 AND user_id = $2 AND used_at IS NULL AND expires_at > NOW() \
|
||||
RETURNING matrix, targets, base_value",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|(matrix, targets, base_value)| (matrix.0, targets.0, base_value)))
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// БИЛЛИНГ И ПОДПИСКИ (V2)
|
||||
// =====================================================================
|
||||
@@ -535,13 +723,51 @@ impl AppRepository for PgRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn get_all_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error> {
|
||||
sqlx::query_as::<_, VpnNode>("SELECT * FROM vpn_nodes ORDER BY created_at DESC")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn get_node_by_id(&self, node_id: Uuid) -> Result<Option<VpnNode>, sqlx::Error> {
|
||||
sqlx::query_as::<_, VpnNode>("SELECT * FROM vpn_nodes WHERE id = $1")
|
||||
.bind(node_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn add_vpn_node(&self, p: CreateNodePayload) -> Result<VpnNode, sqlx::Error> {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query_as::<_, VpnNode>(
|
||||
r#"INSERT INTO vpn_nodes (id, name, country_code, ip_address, port, status, last_seen, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'online', NOW(), NOW()) RETURNING *"#
|
||||
).bind(id).bind(p.name).bind(p.country_code).bind(p.ip_address).bind(p.port).fetch_one(&self.pool).await
|
||||
r#"INSERT INTO vpn_nodes (id, name, country_code, ip_address, port, status, public_key, sni_domain, last_seen, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'online', $6, $7, NOW(), NOW()) RETURNING *"#
|
||||
).bind(id).bind(p.name).bind(p.country_code).bind(p.ip_address).bind(p.port).bind(p.public_key).bind(p.sni_domain).fetch_one(&self.pool).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn add_vpn_node_pending(&self, p: CreateNodePayload) -> Result<VpnNode, sqlx::Error> {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query_as::<_, VpnNode>(
|
||||
r#"INSERT INTO vpn_nodes (id, name, country_code, ip_address, port, status, public_key, sni_domain, last_seen, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'provisioning', $6, $7, NOW(), NOW()) RETURNING *"#
|
||||
).bind(id).bind(p.name).bind(p.country_code).bind(p.ip_address).bind(p.port).bind(p.public_key).bind(p.sni_domain).fetch_one(&self.pool).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn update_node_health(&self, node_id: Uuid, status: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE vpn_nodes SET status = $1, \
|
||||
last_seen = CASE WHEN $1 = 'online' THEN NOW() ELSE last_seen END \
|
||||
WHERE id = $2",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(node_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
|
||||
Reference in New Issue
Block a user