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
+229 -3
View File
@@ -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))]