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
+109 -96
View File
@@ -1,73 +1,102 @@
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
export const TOKEN_KEY = "nrxp_token";
export const removeTokenCookie = () => {
document.cookie = `${TOKEN_KEY}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
};
// Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит
// и не должен: раньше здесь читался `document.cookie` в расчёте на токен,
// который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а
// все "авторизованные" запросы ниже реально уходили без токена вообще.
export const getTokenCookie = (): string | null => {
const match = document.cookie.match(
new RegExp("(^| )" + TOKEN_KEY + "=([^;]+)"),
);
return match ? match[2] : null;
};
let refreshInFlight: Promise<boolean> | null = null;
/// Один раз пробует тихо обновить сессию через `/auth/refresh` (ротация
/// refresh-cookie); параллельные вызовы схлопываются в один запрос.
function refreshSession(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
credentials: "same-origin",
})
.then((res) => res.ok)
.catch(() => false)
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
function toRequestInit(options: RequestInit): RequestInit {
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (options.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
return { ...options, credentials: "same-origin", headers };
}
/// Единая обёртка над fetch для авторизованных запросов: всегда с cookie,
/// при первом 401 пробует silent-refresh и повторяет запрос один раз, при
/// провале — чистит сторону клиента и уводит на экран логина.
export async function apiFetch(
path: string,
options: RequestInit = {},
): Promise<Response> {
const init = toRequestInit(options);
const res = await fetch(`${API_BASE}${path}`, init);
if (res.status !== 401) return res;
const refreshed = await refreshSession();
if (!refreshed) {
window.location.href = "/";
return res;
}
return fetch(`${API_BASE}${path}`, init);
}
async function parseOrThrow(res: Response, fallbackMessage: string) {
if (!res.ok) {
const errData = await res.json().catch(() => ({}) as { error?: string });
throw new Error(errData.error || fallbackMessage);
}
return res.json();
}
// --- API Запросы ---
export const fetchProfile = async () => {
const res = await fetch(`${API_BASE}/users/me`);
if (res.status === 401) {
window.location.href = "/"; // Сервер сам "выкинул" куку или она протухла
return;
}
if (!res.ok) throw new Error("Failed to fetch profile");
return res.json();
const res = await apiFetch("/users/me");
return parseOrThrow(res, "Failed to fetch profile");
};
export const submitHackResult = async (score: number) => {
const token = getTokenCookie();
if (!token) throw new Error("No token found");
export interface HackSessionStart {
session_id: string;
matrix: string[][];
targets: string[][];
base_value: number;
time_limit: number;
}
const res = await fetch(`${API_BASE}/users/hack/result`, {
/// Списывает билет и получает от сервера сгенерированную им доску — сервер
/// хранит эту же доску и позже сверит присланный путь, не доверяя очкам,
/// которые мог бы подсчитать сам клиент.
export const startHackSession = async (): Promise<HackSessionStart> => {
const res = await apiFetch("/users/hack/start", { method: "POST" });
return parseOrThrow(res, "Failed to start breach sequence");
};
export const submitHackResult = async (
sessionId: string,
path: [number, number][],
) => {
const res = await apiFetch("/users/hack/result", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ score }),
body: JSON.stringify({ session_id: sessionId, path }),
});
if (res.status === 401) {
removeTokenCookie();
window.location.href = "/";
return;
}
if (!res.ok) throw new Error("Failed to sync breach data");
return res.json();
return parseOrThrow(res, "Failed to sync breach data");
};
export const activateTrial = async () => {
const token = getTokenCookie();
if (!token) throw new Error("No token found");
const res = await fetch(`${API_BASE}/billing/trial`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
removeTokenCookie();
window.location.href = "/";
return;
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || res.statusText || "Activation failed");
}
return res.json();
const res = await apiFetch("/billing/trial", { method: "POST" });
return parseOrThrow(res, "Activation failed");
};
export const initiatePayment = async (
@@ -75,56 +104,40 @@ export const initiatePayment = async (
provider: string,
currency: string,
) => {
const token = getTokenCookie();
if (!token) throw new Error("No token found");
const res = await fetch(`${API_BASE}/billing/pay/initiate`, {
const res = await apiFetch("/billing/pay/initiate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ plan, provider, currency }),
});
if (res.status === 401) {
removeTokenCookie();
window.location.href = "/";
return;
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || "Initiate payment failed");
}
return res.json();
return parseOrThrow(res, "Initiate payment failed");
};
export const confirmPayment = async (
invoice_id: string,
external_tx_id: string,
) => {
const token = getTokenCookie();
if (!token) throw new Error("No token found");
const res = await fetch(`${API_BASE}/billing/pay/confirm`, {
const res = await apiFetch("/billing/pay/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ invoice_id, external_tx_id }),
});
if (res.status === 401) {
removeTokenCookie();
window.location.href = "/";
return;
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || "Payment verification failed");
}
return res.json();
return parseOrThrow(res, "Payment verification failed");
};
/// Обменивает короткоживущий bootstrap-токен из URL (мост из бота, см.
/// `App.tsx`) на полноценную cookie-сессию.
export const exchangeBootstrapToken = async (token: string): Promise<boolean> => {
const res = await fetch(`${API_BASE}/auth/exchange`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
return res.ok;
};
export const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
credentials: "same-origin",
}).catch(() => {});
window.location.href = "/";
};