feat(auth): 完善API网关JWT认证和权限控制功能

- 实现网关侧JWT工具类和权限规则匹配器
- 集成JWT认证流程,支持Bearer Token验证
- 添加基于路径和HTTP方法的权限控制机制
- 配置白名单路由规则,优化认证性能
- 更新前端受保护路由组件,实现权限验证
- 添加403禁止访问页面和权限检查逻辑
- 重构登录页面,集成实际认证API调用
- 实现用户信息获取和权限加载功能
- 优化全局异常处理器中的认证错误状态码
- 集成FastJSON2和JJWT依赖库支持
This commit is contained in:
2026-02-06 13:11:08 +08:00
parent 719f54bf2e
commit 056cee11cc
33 changed files with 1462 additions and 89 deletions

View File

@@ -0,0 +1,24 @@
import React from "react";
import { Button, Result } from "antd";
import { useNavigate } from "react-router";
const ForbiddenPage: React.FC = () => {
const navigate = useNavigate();
return (
<div className="h-screen w-full flex items-center justify-center bg-[#050b14]">
<Result
status="403"
title="403"
subTitle="你当前账号没有访问该页面的权限。"
extra={
<Button type="primary" onClick={() => navigate("/data/management")}>
</Button>
}
/>
</div>
);
};
export default ForbiddenPage;

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { Button, Drawer, Menu, Popover } from "antd";
import {
CloseOutlined,
@@ -14,6 +14,7 @@ import SettingsPage from "../SettingsPage/SettingsPage";
import { useAppSelector, useAppDispatch } from "@/store/hooks";
import { showSettings, hideSettings } from "@/store/slices/settingsSlice";
import { logout } from "@/store/slices/authSlice";
import { hasPermission } from "@/auth/permissions";
const isPathMatch = (currentPath: string, targetPath: string) =>
currentPath === targetPath || currentPath.startsWith(`${targetPath}/`);
@@ -25,13 +26,36 @@ const AsiderAndHeaderLayout = () => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [taskCenterVisible, setTaskCenterVisible] = useState(false);
const settingVisible = useAppSelector((state) => state.settings.visible);
const permissions = useAppSelector((state) => state.auth.permissions);
const dispatch = useAppDispatch();
const visibleMenuItems = useMemo(
() =>
menuItems
.map((item) => ({
...item,
children: item.children?.filter((subItem) =>
hasPermission(permissions, (subItem as { permissionCode?: string }).permissionCode)
),
}))
.filter((item) => {
const selfVisible = hasPermission(
permissions,
(item as { permissionCode?: string }).permissionCode
);
if (item.children && item.children.length > 0) {
return selfVisible;
}
return selfVisible;
}),
[permissions]
);
// Initialize active item based on current pathname
const initActiveItem = useCallback(() => {
const dataPath = pathname.startsWith("/data/") ? pathname.slice(6) : pathname;
for (let index = 0; index < menuItems.length; index++) {
const element = menuItems[index];
for (let index = 0; index < visibleMenuItems.length; index++) {
const element = visibleMenuItems[index];
if (element.children) {
for (const subItem of element.children) {
if (isPathMatch(dataPath, subItem.id)) {
@@ -44,7 +68,8 @@ const AsiderAndHeaderLayout = () => {
return;
}
}
}, [pathname]);
setActiveItem(visibleMenuItems[0]?.id ?? "");
}, [pathname, visibleMenuItems]);
useEffect(() => {
initActiveItem();
@@ -100,7 +125,7 @@ const AsiderAndHeaderLayout = () => {
<Menu
mode="inline"
inlineCollapsed={!sidebarOpen}
items={menuItems.map((item) => ({
items={visibleMenuItems.map((item) => ({
key: item.id,
label: item.title,
icon: item.icon ? <item.icon className="w-4 h-4" /> : null,

View File

@@ -13,6 +13,7 @@ import {
// Store,
// Merge,
} from "lucide-react";
import { PermissionCodes } from "@/auth/permissions";
export const menuItems = [
// {
@@ -26,6 +27,7 @@ export const menuItems = [
id: "management",
title: "数集管理",
icon: FolderOpen,
permissionCode: PermissionCodes.dataManagementRead,
description: "创建、导入和管理数据集",
color: "bg-blue-500",
},
@@ -33,6 +35,7 @@ export const menuItems = [
id: "annotation",
title: "数据标注",
icon: Tag,
permissionCode: PermissionCodes.dataAnnotationRead,
description: "对数据进行标注和标记",
color: "bg-green-500",
},
@@ -40,6 +43,7 @@ export const menuItems = [
id: "content-generation",
title: "内容生成",
icon: Sparkles,
permissionCode: PermissionCodes.contentGenerationUse,
description: "智能内容生成与创作",
color: "bg-purple-500",
},
@@ -47,6 +51,7 @@ export const menuItems = [
id: "knowledge-management",
title: "知识管理",
icon: Shield,
permissionCode: PermissionCodes.knowledgeManagementRead,
description: "管理知识集与知识条目",
color: "bg-indigo-500",
},

View File

@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import React from 'react';
import { useNavigate, useLocation } from 'react-router';
import { Form, Input, Button, Typography, message, Card } from 'antd';
import { Form, Input, Button, Typography, message } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { loginLocal } from '@/store/slices/authSlice';
import { loginUser } from '@/store/slices/authSlice';
const { Title, Text } = Typography;
@@ -11,19 +11,20 @@ const LoginPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const dispatch = useAppDispatch();
const { loading, error } = useAppSelector((state) => state.auth);
const { loading } = useAppSelector((state) => state.auth);
const [messageApi, contextHolder] = message.useMessage();
const from = location.state?.from?.pathname || '/data';
const onFinish = (values: any) => {
dispatch(loginLocal(values));
// The reducer updates state synchronously.
if (values.username === 'admin' && values.password === '123456') {
messageApi.success('登录成功');
navigate(from, { replace: true });
} else {
messageApi.error('账号或密码错误');
const onFinish = async (values: { username: string; password: string }) => {
try {
await dispatch(loginUser(values)).unwrap();
messageApi.success('登录成功');
navigate(from, { replace: true });
} catch (loginError) {
const messageText =
typeof loginError === 'string' ? loginError : '账号或密码错误';
messageApi.error(messageText);
}
};
@@ -59,9 +60,9 @@ const LoginPage: React.FC = () => {
</Text>
</div>
<Form
<Form<{ username: string; password: string }>
name="login"
initialValues={{ remember: true, username: 'admin', password: '123456' }}
initialValues={{ username: 'admin', password: '123456' }}
onFinish={onFinish}
layout="vertical"
size="large"