fix(auth): redirect expired sessions before permission checks
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { RoutePermissionGuard } from "./RoutePermissionGuard";
|
||||||
|
|
||||||
|
jest.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/network-simulation",
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("RoutePermissionGuard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAccessStore.setState({
|
||||||
|
context: null,
|
||||||
|
permissions: [],
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes an expired session over a missing route permission", () => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("正在跳转到登录页面…")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/simulation\.view/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the permission error when the session is still valid", () => {
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard>
|
||||||
|
<div>受保护内容</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { permissionForPath } from "@/lib/permissions";
|
import { permissionForPath } from "@/lib/permissions";
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
export const RoutePermissionGuard = ({
|
export const RoutePermissionGuard = ({
|
||||||
children,
|
children,
|
||||||
@@ -16,8 +17,24 @@ export const RoutePermissionGuard = ({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
const loading = useAccessStore((state) => state.loading);
|
const loading = useAccessStore((state) => state.loading);
|
||||||
|
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||||
const requiredPermission = permissionForPath(pathname);
|
const requiredPermission = permissionForPath(pathname);
|
||||||
|
|
||||||
|
if (sessionExpired) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Alert severity="warning">
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography variant="body1">登录状态已失效</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
正在跳转到登录页面…
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (requiredPermission && loading) {
|
if (requiredPermission && loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
||||||
import { act, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { SessionExpiryDialog } from "./SessionExpiryDialog";
|
import { SessionExpiryDialog } from "./SessionExpiryDialog";
|
||||||
@@ -10,6 +11,8 @@ jest.mock("next-auth/react", () => ({
|
|||||||
|
|
||||||
describe("SessionExpiryDialog", () => {
|
describe("SessionExpiryDialog", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.mocked(signIn).mockReset().mockResolvedValue(undefined);
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
sessionExpired: true,
|
sessionExpired: true,
|
||||||
@@ -19,6 +22,7 @@ describe("SessionExpiryDialog", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
act(() => useAuthStore.getState().clearSessionExpired());
|
act(() => useAuthStore.getState().clearSessionExpired());
|
||||||
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders above every regular application overlay", () => {
|
it("renders above every regular application overlay", () => {
|
||||||
@@ -36,4 +40,40 @@ describe("SessionExpiryDialog", () => {
|
|||||||
zIndex: theme.zIndex.tooltip + 1,
|
zIndex: theme.zIndex.tooltip + 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the expired state before automatically starting login", () => {
|
||||||
|
window.history.replaceState({}, "", "/network-simulation?tab=history");
|
||||||
|
|
||||||
|
render(<SessionExpiryDialog />);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录已过期")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("即将自动跳转到登录页面。"))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(signIn).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signIn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(signIn).toHaveBeenCalledWith("keycloak", {
|
||||||
|
callbackUrl: "/network-simulation?tab=history",
|
||||||
|
redirect: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a retry when starting automatic login fails", async () => {
|
||||||
|
jest.mocked(signIn)
|
||||||
|
.mockRejectedValueOnce(new Error("network unavailable"))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
render(<SessionExpiryDialog />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(2_000);
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重新认证" }));
|
||||||
|
|
||||||
|
expect(signIn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
|
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +18,7 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
const WARNING_WINDOW_MS = 15 * 60 * 1000;
|
const WARNING_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const EXPIRED_REDIRECT_DELAY_MS = 2_000;
|
||||||
|
|
||||||
type SessionExpiryDialogProps = {
|
type SessionExpiryDialogProps = {
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
@@ -31,6 +32,7 @@ export const SessionExpiryDialog = ({
|
|||||||
const reason = useAuthStore((state) => state.sessionExpiryReason);
|
const reason = useAuthStore((state) => state.sessionExpiryReason);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const [warningDismissed, setWarningDismissed] = useState(false);
|
const [warningDismissed, setWarningDismissed] = useState(false);
|
||||||
|
const redirectStartedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
|
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||||
@@ -47,10 +49,27 @@ export const SessionExpiryDialog = ({
|
|||||||
[expiresAt, now, sessionExpired, warningDismissed],
|
[expiresAt, now, sessionExpired, warningDismissed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReauthenticate = () => {
|
const handleReauthenticate = useCallback(() => {
|
||||||
|
if (redirectStartedRef.current) return;
|
||||||
|
redirectStartedRef.current = true;
|
||||||
const callbackUrl = `${window.location.pathname}${window.location.search}`;
|
const callbackUrl = `${window.location.pathname}${window.location.search}`;
|
||||||
void signIn("keycloak", { callbackUrl, redirect: true });
|
void signIn("keycloak", { callbackUrl, redirect: true }).catch(() => {
|
||||||
};
|
redirectStartedRef.current = false;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionExpired) {
|
||||||
|
redirectStartedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(
|
||||||
|
handleReauthenticate,
|
||||||
|
EXPIRED_REDIRECT_DELAY_MS,
|
||||||
|
);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [handleReauthenticate, sessionExpired]);
|
||||||
|
|
||||||
const isOpen = sessionExpired || isExpiringSoon;
|
const isOpen = sessionExpired || isExpiringSoon;
|
||||||
const title = sessionExpired ? "登录已过期" : "登录即将到期";
|
const title = sessionExpired ? "登录已过期" : "登录即将到期";
|
||||||
@@ -77,6 +96,11 @@ export const SessionExpiryDialog = ({
|
|||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
重新认证不会自动重放已失败的写入请求;请在返回后确认内容并再次提交。
|
重新认证不会自动重放已失败的写入请求;请在返回后确认内容并再次提交。
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{sessionExpired && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
即将自动跳转到登录页面。
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|||||||
Reference in New Issue
Block a user