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:
oliver 2026-08-06 14:13:37 +08:00
parent ba33ab5c4f
commit 20c2fcd496
34 changed files with 1462 additions and 149 deletions

View file

@ -54,6 +54,9 @@ const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default:
const ApiTokensPage = lazy(() =>
import("./pages/ApiTokensPage").then((m) => ({ default: m.ApiTokensPage })),
);
const SessionsPage = lazy(() =>
import("./pages/SessionsPage").then((m) => ({ default: m.SessionsPage })),
);
/** Preserve query when redirecting legacy /network/webcrt → /webcrt. */
function NetworkWebcrtRedirect() {
@ -129,6 +132,7 @@ function ProtectedApp() {
<Route path="logs" element={<AuditPage />} />
</Route>
<Route path="/api-keys" element={<ApiTokensPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>

View file

@ -7,14 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import {
AUTH_TOKEN_KEY,
apiGet,
apiPost,
clearAuthToken,
getAuthToken,
setAuthToken,
} from "../services/api";
import { apiGet, apiPost, clearAuthToken } from "../services/api";
export type AuthUser = {
id: string;
@ -44,25 +37,19 @@ const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [token, setToken] = useState<string | null>(() => getAuthToken());
// token is opaque for UI; cookie session means we only care about user presence.
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [scopes, setScopes] = useState<string[]>([]);
const refreshMe = useCallback(async () => {
const tok = getAuthToken();
if (!tok) {
setToken(null);
setUser(null);
setScopes([]);
return;
}
clearAuthToken(); // drop any legacy localStorage tokens
try {
const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me");
setToken(tok);
setToken("cookie");
setUser(data.user);
setScopes(data.scopes || data.user.scopes || []);
} catch {
clearAuthToken();
setToken(null);
setUser(null);
setScopes([]);
@ -76,40 +63,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})();
}, [refreshMe]);
// Other tabs keep React auth state until they hear localStorage change.
// `storage` fires only in *other* documents — used to sync logout/login.
useEffect(() => {
const onStorage = (ev: StorageEvent) => {
if (ev.storageArea && ev.storageArea !== localStorage) return;
if (ev.key !== null && ev.key !== AUTH_TOKEN_KEY) return;
if (ev.key === null || ev.newValue == null || ev.newValue === "") {
setToken(null);
setUser(null);
setScopes([]);
return;
}
void refreshMe();
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [refreshMe]);
const login = useCallback(async (username: string, password: string) => {
const data = await apiPost<{ access_token: string; user: AuthUser }>("/v1/auth/login", {
const data = await apiPost<{ user: AuthUser }>("/v1/auth/login", {
username,
password,
});
setAuthToken(data.access_token);
setToken(data.access_token);
clearAuthToken();
setToken("cookie");
setUser(data.user);
setScopes(data.user.scopes || []);
}, []);
const logout = useCallback(async () => {
try {
if (getAuthToken()) {
await apiPost("/v1/auth/logout", {});
}
await apiPost("/v1/auth/logout", {});
} catch {
// ignore
}

View file

@ -130,6 +130,16 @@ export const MODULES: readonly ModuleDefinition[] = [
iconKind: "key",
titleKey: "layout.titleApiKeys",
},
{
moduleId: "sessions",
path: "/sessions",
section: "system",
labelKey: "workbench.cards.sessions",
descKey: "workbench.cards.sessionsDesc",
iconTone: "slate",
iconKind: "key",
titleKey: "layout.titleSessions",
},
] as const satisfies readonly ModuleDefinition[];
export function getModuleById(moduleId: string): ModuleDefinition | undefined {

View file

@ -61,6 +61,7 @@ const en = {
logs: "Audit logs",
users: "User admin",
apiKeys: "API keys",
sessions: "Sessions",
},
announce: {
a1: "Dark workbench shell is live — other modules follow the same palette.",
@ -97,6 +98,8 @@ const en = {
auditDesc: "Live task overview and operation logs",
apiKeys: "API Keys",
apiKeysDesc: "Issue MCP/script tokens per user with expiry",
sessions: "Login sessions",
sessionsDesc: "Review and revoke logins on other devices",
},
},
network: {
@ -519,6 +522,7 @@ const en = {
titleUsers: "Users",
titleAudit: "Audit",
titleApiKeys: "API Keys",
titleSessions: "Login sessions",
navUme: "UME",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
@ -538,6 +542,18 @@ const en = {
loggingIn: "Signing in…",
loginFailed: "Login failed",
logout: "Sign out",
sessionsTitle: "Login sessions",
sessionsHint: "Manage browser/device logins for this account. Revoked sessions must sign in again.",
sessionsEmpty: "No active sessions.",
revokeOtherSessions: "Revoke other sessions",
revokeSession: "Revoke",
sessionCurrent: "Current",
sessionRevoked: "Session revoked",
sessionsRevokedOthers: "Revoked {{count}} other session(s)",
revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?",
colSession: "Session",
colLastSeen: "Last seen",
colCreated: "Created",
usersTitle: "User management",
usersHint: "Only admins can create and manage local accounts.",
addUser: "Add user",
@ -614,7 +630,7 @@ const en = {
confirmPassword: "Confirm new password",
savePassword: "Save new password",
savingPassword: "Saving…",
passwordTooShort: "New password must be at least 6 characters",
passwordTooShort: "New password must be at least 8 characters",
passwordMismatch: "New passwords do not match",
passwordMustChange: "New password must differ from the default/old password",
},

View file

@ -61,6 +61,7 @@ const zh = {
logs: "操作日志",
users: "用户管理",
apiKeys: "API Key",
sessions: "登录会话",
},
announce: {
a1: "深色工作台已上线,其它模块将沿用同一套深色体系。",
@ -97,6 +98,8 @@ const zh = {
auditDesc: "任务概览与操作日志",
apiKeys: "API Key",
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
sessions: "登录会话",
sessionsDesc: "查看并踢掉其他设备上的登录",
},
},
network: {
@ -515,6 +518,7 @@ const zh = {
titleUsers: "用户管理",
titleAudit: "操作审计",
titleApiKeys: "API Key",
titleSessions: "登录会话",
navUme: "UME 对接",
netxApi: "netx api",
oclawBridge: "oclaw WSS",
@ -534,6 +538,18 @@ const zh = {
loggingIn: "登录中…",
loginFailed: "登录失败",
logout: "退出",
sessionsTitle: "登录会话",
sessionsHint: "管理当前账号在各浏览器/设备上的登录。踢掉会话后对方需重新登录。",
sessionsEmpty: "当前没有活跃会话。",
revokeOtherSessions: "踢掉其他会话",
revokeSession: "踢掉",
sessionCurrent: "当前",
sessionRevoked: "会话已吊销",
sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话",
revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?",
colSession: "会话",
colLastSeen: "最近活动",
colCreated: "创建时间",
usersTitle: "用户管理",
usersHint: "仅管理员可创建与管理本地账号。",
addUser: "添加用户",
@ -609,7 +625,7 @@ const zh = {
confirmPassword: "确认新密码",
savePassword: "保存新密码",
savingPassword: "保存中…",
passwordTooShort: "新密码至少 6 位",
passwordTooShort: "新密码至少 8 位",
passwordMismatch: "两次输入的新密码不一致",
passwordMustChange: "新密码不能与默认/旧密码相同",
},

View file

@ -16,7 +16,7 @@ export function ForceChangePasswordPage() {
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (newPassword.length < 6) {
if (newPassword.length < 8) {
setError(t("auth.passwordTooShort"));
return;
}
@ -77,7 +77,7 @@ export function ForceChangePasswordPage() {
onChange={(e) => setNewPassword(e.target.value)}
disabled={busy}
required
minLength={6}
minLength={8}
/>
</label>
<label className="login-card__label">
@ -90,7 +90,7 @@ export function ForceChangePasswordPage() {
onChange={(e) => setConfirm(e.target.value)}
disabled={busy}
required
minLength={6}
minLength={8}
/>
</label>
{error ? (

View file

@ -0,0 +1,118 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { apiDelete, apiGet, apiPost } from "../services/api";
import { formatSystemTime } from "../utils/time";
type SessionRow = {
id: string;
created_at: string | null;
expires_at: string | null;
refresh_expires_at: string | null;
last_seen_at: string | null;
client_ip: string;
user_agent: string;
current: boolean;
};
export function SessionsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const qc = useQueryClient();
const sessionsQuery = useQuery({
queryKey: ["auth-sessions"],
queryFn: () => apiGet<{ items: SessionRow[]; total: number }>("/v1/auth/sessions"),
});
const revokeMut = useMutation({
mutationFn: (id: string) => apiDelete(`/v1/auth/sessions/${encodeURIComponent(id)}`),
onSuccess: () => {
showOk(t("auth.sessionRevoked"));
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
},
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
});
const revokeOthersMut = useMutation({
mutationFn: () => apiPost<{ revoked: number }>("/v1/auth/sessions/revoke-others", {}),
onSuccess: (data) => {
showOk(t("auth.sessionsRevokedOthers", { count: data.revoked ?? 0 }));
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
},
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
});
const items = sessionsQuery.data?.items || [];
return (
<div className="page">
<header className="page-header">
<h1>{t("auth.sessionsTitle")}</h1>
<p className="panel__hint">{t("auth.sessionsHint")}</p>
</header>
<div className="panel">
<div className="filter-inline" style={{ marginBottom: 12 }}>
<button
type="button"
disabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
onClick={() => revokeOthersMut.mutate()}
>
{t("auth.revokeOtherSessions")}
</button>
<button type="button" onClick={() => void sessionsQuery.refetch()} disabled={sessionsQuery.isFetching}>
{t("common.refresh")}
</button>
</div>
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !sessionsQuery.isLoading ? (
<p className="muted">{t("auth.sessionsEmpty")}</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>{t("auth.colSession")}</th>
<th>{t("auth.colIp")}</th>
<th>{t("auth.colLastSeen")}</th>
<th>{t("auth.colCreated")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
<td>
<code title={row.user_agent}>{row.id.slice(0, 10)}…</code>
{row.current ? (
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
{t("auth.sessionCurrent")}
</span>
) : null}
</td>
<td>{row.client_ip || "—"}</td>
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
<td>
<button
type="button"
disabled={revokeMut.isPending}
onClick={() => {
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
revokeMut.mutate(row.id);
}}
>
{t("auth.revokeSession")}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View file

@ -83,7 +83,7 @@ export function UsersPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
minLength={8}
/>
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="user">{t("auth.roleUser")}</option>
@ -155,7 +155,7 @@ export function UsersPage() {
/>
<button
type="button"
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 6}
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
onClick={() => {
const pwd = resetPwd[u.id];
patchMut.mutate({ id: u.id, body: { password: pwd } });

View file

@ -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");