mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 10:40:46 +08:00
Harden auth with revocable sessions, cookies, and single-login default.
Issue short-lived access JWTs backed by AuthSession rows, HttpOnly cookies with refresh rotation, idle timeout, session management UI, WebCRT ownership caps, and optional Redis login rate limits; new logins revoke other sessions by default. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ba33ab5c4f
commit
20c2fcd496
34 changed files with 1462 additions and 149 deletions
|
|
@ -50,36 +50,40 @@ import type {
|
|||
|
||||
|
||||
export const AUTH_TOKEN_KEY = "netx_access_token";
|
||||
export const AUTH_REFRESH_KEY = "netx_refresh_token";
|
||||
|
||||
export const getAuthToken = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setAuthToken = (token: string): void => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, String(token || ""));
|
||||
};
|
||||
|
||||
/** Clear legacy localStorage tokens; browser auth now uses HttpOnly cookies. */
|
||||
export const clearAuthToken = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(AUTH_REFRESH_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
/** @deprecated Cookie session — always null for browser UI. */
|
||||
export const getAuthToken = (): string | null => null;
|
||||
|
||||
/** No-op kept for call-site compatibility during cookie migration. */
|
||||
export const setAuthToken = (_token: string): void => {
|
||||
clearAuthToken();
|
||||
};
|
||||
|
||||
export const setAuthTokens = (_access: string, _refresh?: string | null): void => {
|
||||
clearAuthToken();
|
||||
};
|
||||
|
||||
const fetchCreds: RequestCredentials = "include";
|
||||
|
||||
const authHeaders = (extra?: Record<string, string>): Record<string, string> => {
|
||||
const h: Record<string, string> = { accept: "application/json", ...(extra || {}) };
|
||||
const tok = getAuthToken();
|
||||
if (tok) h.authorization = `Bearer ${tok}`;
|
||||
return h;
|
||||
// Browser JWT rides HttpOnly cookies (credentials: include).
|
||||
// Authorization Bearer is only needed for non-browser API tokens if ever injected.
|
||||
return { accept: "application/json", ...(extra || {}) };
|
||||
};
|
||||
|
||||
const handleUnauthorized = (path: string): void => {
|
||||
if (path.startsWith("/v1/auth/login")) return;
|
||||
if (path.startsWith("/v1/auth/login") || path.startsWith("/v1/auth/refresh")) return;
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
||||
const next = `${window.location.pathname}${window.location.search || ""}`;
|
||||
|
|
@ -87,6 +91,38 @@ const handleUnauthorized = (path: string): void => {
|
|||
}
|
||||
};
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
const tryRefreshAccessToken = async (): Promise<boolean> => {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
// Refresh token comes from HttpOnly cookie when body is empty.
|
||||
const res = await fetch("/v1/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
clearAuthToken();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
clearAuthToken();
|
||||
return false;
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
const shouldAttemptRefresh = (path: string): boolean =>
|
||||
!path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/refresh");
|
||||
|
||||
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
|
|
@ -139,7 +175,10 @@ export function formatApiDetail(detail: unknown): string {
|
|||
}
|
||||
|
||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
let res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
}
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
|
|
@ -149,11 +188,17 @@ export const apiGet = async <T,>(path: string): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -164,11 +209,17 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "PATCH",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -179,7 +230,10 @@ export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { method: "DELETE", headers: authHeaders() });
|
||||
let res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds });
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -190,11 +244,17 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "PUT",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -499,7 +559,7 @@ export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") =>
|
|||
|
||||
export const downloadManagedNeImportTemplate = async (format: "xlsx" | "csv" = "xlsx"): Promise<void> => {
|
||||
const path = managedNeImportTemplateUrl(format);
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
@ -527,7 +587,7 @@ export const importManagedNe = async (file: File): Promise<ManagedNeImportResult
|
|||
form.append("file", file);
|
||||
const path = "/v1/managed-ne/import";
|
||||
// Do not set content-type — browser must add multipart boundary.
|
||||
const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form });
|
||||
const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form, credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
@ -673,7 +733,7 @@ export function closeWebcrtSessionsKeepalive(sessionIds: string[]): void {
|
|||
for (const id of ids) {
|
||||
const path = `/v1/webcrt/sessions/${encodeURIComponent(id)}`;
|
||||
try {
|
||||
void fetch(path, { method: "DELETE", headers, keepalive: true });
|
||||
void fetch(path, { method: "DELETE", headers, credentials: fetchCreds, keepalive: true });
|
||||
} catch {
|
||||
/* ignore unload failures */
|
||||
}
|
||||
|
|
@ -772,6 +832,7 @@ async function webcrtSftpDownloadOnce(
|
|||
const path = "/v1/webcrt/sftp/download";
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
signal: opts?.signal,
|
||||
|
|
@ -863,8 +924,7 @@ function webcrtSftpUploadOnce(
|
|||
opts.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
xhr.open("POST", path);
|
||||
const tok = getAuthToken();
|
||||
if (tok) xhr.setRequestHeader("Authorization", `Bearer ${tok}`);
|
||||
xhr.withCredentials = true;
|
||||
xhr.responseType = "text";
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (!opts?.onProgress) return;
|
||||
|
|
@ -1414,7 +1474,7 @@ export const downloadNeConfigSnapshot = async (
|
|||
const path =
|
||||
`/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}` +
|
||||
`/download?field=${encodeURIComponent(field)}`;
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue