fix(panel): 收到角色拒绝的 403 时限频重读身份,被降权的管理员随即失去管理页
This commit is contained in:
4 files changed
+89
-7
No files matched your search
@@ -12,7 +12,7 @@ vi.mock("./config", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Imported after the mock so api.ts picks up the mocked loadConfig.
|
// Imported after the mock so api.ts picks up the mocked loadConfig.
|
||||||
const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost, clientError } =
|
const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, ACCESS_REFUSED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost, clientError } =
|
||||||
await import("./api");
|
await import("./api");
|
||||||
|
|
||||||
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
|
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
|
||||||
@@ -1099,6 +1099,21 @@ describe("session and connection signals", () => {
|
|||||||
expect(seen).toHaveLength(0);
|
expect(seen).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("announces a role refusal, and no other 403", async () => {
|
||||||
|
const seen = listen(ACCESS_REFUSED_EVENT);
|
||||||
|
const refuse = async (status: number, code: string) => {
|
||||||
|
vi.stubGlobal("fetch", fakeFetch({ error: { code, message: "x" } }, { ok: false, status }));
|
||||||
|
await expect(api.myServers()).rejects.toMatchObject({ status, code });
|
||||||
|
};
|
||||||
|
for (const code of ["reauth_required", "setup_required", "quota_exceeded", "local_auth_disabled"]) await refuse(403, code);
|
||||||
|
await refuse(409, "forbidden");
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
|
||||||
|
await refuse(403, "forbidden");
|
||||||
|
await refuse(403, "not_admin");
|
||||||
|
expect(seen).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a fetch that got no response, and the next one that did", async () => {
|
it("reports a fetch that got no response, and the next one that did", async () => {
|
||||||
const seen = listen(CONNECTION_EVENT);
|
const seen = listen(CONNECTION_EVENT);
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
|
|||||||
@@ -79,6 +79,18 @@ function announceSessionExpired(err: ApiError, path: string): void {
|
|||||||
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
|
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 403 forbidden or not_admin is the role gate refusing the call. The account
|
||||||
|
// may have been demoted since /me was read, which leaves admin pages and buttons
|
||||||
|
// up that every call now refuses. TierProvider hears this and re-reads /me, which
|
||||||
|
// takes them down; a refusal the current role explains changes nothing there.
|
||||||
|
export const ACCESS_REFUSED_EVENT = "felis:access-refused";
|
||||||
|
const ROLE_REFUSALS = new Set(["forbidden", "not_admin"]);
|
||||||
|
|
||||||
|
function announceAccessRefused(err: ApiError): void {
|
||||||
|
if (err.status !== 403 || !ROLE_REFUSALS.has(err.code) || typeof window === "undefined") return;
|
||||||
|
window.dispatchEvent(new Event(ACCESS_REFUSED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
// CONNECTION_EVENT reports when requests stop reaching the API (detail.ok =
|
// CONNECTION_EVENT reports when requests stop reaching the API (detail.ok =
|
||||||
// false) and when one gets through again (true). A fetch that rejects never
|
// false) and when one gets through again (true). A fetch that rejects never
|
||||||
// saw a response: the network is down, or Cloudflare Access redirected the
|
// saw a response: the network is down, or Cloudflare Access redirected the
|
||||||
@@ -178,6 +190,7 @@ function failed(path: string, status: number, statusText: string, text: string):
|
|||||||
};
|
};
|
||||||
announceSetupRequired(err);
|
announceSetupRequired(err);
|
||||||
announceSessionExpired(err, path);
|
announceSessionExpired(err, path);
|
||||||
|
announceAccessRefused(err);
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { act, render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { TierProvider, useTier } from "./tier";
|
import { ACCESS_RECHECK_MS, TierProvider, useTier } from "./tier";
|
||||||
|
import { ACCESS_REFUSED_EVENT } from "./api";
|
||||||
import type { Identity } from "./types";
|
import type { Identity } from "./types";
|
||||||
|
|
||||||
const calls = vi.hoisted(() => ({ me: vi.fn() }));
|
const calls = vi.hoisted(() => ({ me: vi.fn() }));
|
||||||
@@ -31,6 +32,9 @@ async function boot() {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
calls.me.mockReset();
|
calls.me.mockReset();
|
||||||
});
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
describe("TierProvider after a /me that failed with anything but a 401", () => {
|
describe("TierProvider after a /me that failed with anything but a 401", () => {
|
||||||
it("exposes the failure, and a revalidate that succeeds clears it without a reload", async () => {
|
it("exposes the failure, and a revalidate that succeeds clears it without a reload", async () => {
|
||||||
@@ -67,3 +71,42 @@ describe("TierProvider after a /me that failed with anything but a 401", () => {
|
|||||||
expect(tier.unauthenticated).toBe(false);
|
expect(tier.unauthenticated).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An admin demoted while the page is open keeps the admin pages up until /me is
|
||||||
|
// read again; the first refused call does that, at most once per interval.
|
||||||
|
describe("TierProvider after a role refusal", () => {
|
||||||
|
const demoted: Identity = { ...admin, role: "user", is_admin: false };
|
||||||
|
const refuse = () =>
|
||||||
|
act(async () => {
|
||||||
|
window.dispatchEvent(new Event(ACCESS_REFUSED_EVENT));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads /me and takes the admin view down once the account was demoted", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
calls.me.mockResolvedValue(admin);
|
||||||
|
await boot();
|
||||||
|
expect(screen.getByText("admin")).toBeTruthy();
|
||||||
|
|
||||||
|
calls.me.mockResolvedValue(demoted);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS));
|
||||||
|
await refuse();
|
||||||
|
expect(await screen.findByText("not admin")).toBeTruthy();
|
||||||
|
expect(calls.me).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads at most once per interval", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
calls.me.mockResolvedValue(admin);
|
||||||
|
await boot();
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS / 2));
|
||||||
|
await refuse();
|
||||||
|
expect(calls.me).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(ACCESS_RECHECK_MS / 2));
|
||||||
|
await refuse();
|
||||||
|
await refuse();
|
||||||
|
expect(calls.me).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.getByText("admin")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-4
@@ -8,7 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { Identity } from "./types";
|
import type { Identity } from "./types";
|
||||||
import { api, SESSION_EXPIRED_EVENT } from "./api";
|
import { ACCESS_REFUSED_EVENT, api, SESSION_EXPIRED_EVENT } from "./api";
|
||||||
import { deriveAuth, isUnauthorized, type AuthState } from "./auth";
|
import { deriveAuth, isUnauthorized, type AuthState } from "./auth";
|
||||||
|
|
||||||
// TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing
|
// TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing
|
||||||
@@ -32,10 +32,12 @@ import { deriveAuth, isUnauthorized, type AuthState } from "./auth";
|
|||||||
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
||||||
//
|
//
|
||||||
// 4. Session-aware while open: a 401 from any protected call (api.ts announces
|
// 4. Session-aware while open: a 401 from any protected call (api.ts announces
|
||||||
// SESSION_EXPIRED_EVENT), the tab coming back into view, or the window
|
// SESSION_EXPIRED_EVENT), a role refusal (ACCESS_REFUSED_EVENT, at most once
|
||||||
|
// per ACCESS_RECHECK_MS), the tab coming back into view, or the window
|
||||||
// regaining focus re-reads /me through `revalidate()`. It leaves `loading`
|
// regaining focus re-reads /me through `revalidate()`. It leaves `loading`
|
||||||
// alone, so the app stays mounted during the check; only a 401 changes what
|
// alone, so the app stays mounted during the check; a 401 signs the person
|
||||||
// is shown, and `sessionEnded` tells the login page why the person landed there.
|
// out, a changed role redraws the gates (a demoted admin loses the admin
|
||||||
|
// pages), and `sessionEnded` tells the login page why the person landed there.
|
||||||
//
|
//
|
||||||
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
||||||
|
|
||||||
@@ -56,6 +58,9 @@ export interface TierState extends AuthState {
|
|||||||
// A tab returning to view re-checks /me at most this often; a 401 from a real
|
// A tab returning to view re-checks /me at most this often; a 401 from a real
|
||||||
// call re-checks at once.
|
// call re-checks at once.
|
||||||
const REVALIDATE_EVERY_MS = 60_000;
|
const REVALIDATE_EVERY_MS = 60_000;
|
||||||
|
// A role refusal from a real call re-checks at most this often, so a page that
|
||||||
|
// keeps polling a refused route does not re-read /me on every tick.
|
||||||
|
export const ACCESS_RECHECK_MS = 10_000;
|
||||||
|
|
||||||
const TierContext = createContext<TierState>({
|
const TierContext = createContext<TierState>({
|
||||||
identity: null,
|
identity: null,
|
||||||
@@ -153,11 +158,17 @@ export function TierProvider({ children }: { children: ReactNode }) {
|
|||||||
if (Date.now() - lastCheck.current < REVALIDATE_EVERY_MS) return;
|
if (Date.now() - lastCheck.current < REVALIDATE_EVERY_MS) return;
|
||||||
void revalidate();
|
void revalidate();
|
||||||
};
|
};
|
||||||
|
const onRefused = () => {
|
||||||
|
if (Date.now() - lastCheck.current < ACCESS_RECHECK_MS) return;
|
||||||
|
void revalidate();
|
||||||
|
};
|
||||||
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||||
|
window.addEventListener(ACCESS_REFUSED_EVENT, onRefused);
|
||||||
window.addEventListener("focus", onReturn);
|
window.addEventListener("focus", onReturn);
|
||||||
document.addEventListener("visibilitychange", onReturn);
|
document.addEventListener("visibilitychange", onReturn);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||||
|
window.removeEventListener(ACCESS_REFUSED_EVENT, onRefused);
|
||||||
window.removeEventListener("focus", onReturn);
|
window.removeEventListener("focus", onReturn);
|
||||||
document.removeEventListener("visibilitychange", onReturn);
|
document.removeEventListener("visibilitychange", onReturn);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user