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:
+109
-96
@@ -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 = "/";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user