项目:xiaoye-admin-react | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
本文聚焦框架的鉴权与授权体系:登录态与 token 的全生命周期、菜单与按钮两级权限的防线设计、redirect 回跳的安全校验,以及生产环境下权限失败时的安全取舍。权限链路的数据结构(useAuthStore 四字段派生)与流程图见《04-数据模型》2.2 与《05-核心流程》流程二,请求层拦截器的完整管线见《03-接口设计》1.3,本文不重复展开,只讲"安全语义"这一条线。
在展开任何机制之前必须先校准认知:本框架中所有前端权限控制(菜单隔离、按钮隐藏、路由守卫)都是体验层防护,不是安全边界。真正决定"能不能改数据"的是后端接口的鉴权——前端能做的是让未授权用户"看不到、点不到、甚至加载不到"对应的功能,而无法阻止一个拿着开发者工具的攻击者直接向接口发请求。源码对这一定位的落实方式很有代表性:它不追求在前端做任何"加密",而是把力气花在三件确定性更强的事情上——结构性隔离(未授权页面连组件代码都不注册路由)、失败从紧(任何权限获取失败都收窄而非放宽权限)、清理彻底(任何登出路径都把权限数据清干净)。理解这个定位后,评估本框架安全性时的正确问法不是"前端能不能被绕过"(能),而是"绕过前端后后端还剩几道防线"。
在这一前提下,框架的权限体系由四个层次组成:凭证层(token 的产生、携带与销毁)、路由层(菜单驱动注册的结构性隔离)、视图层(按钮权限码的渲染控制)、兜底层(失败语义与重定向安全)。以下逐一展开。
token 的产生与流转由 src/stores/modules/user.ts(useUserStore)和 src/api/index.ts(请求拦截器)两个文件承担。完整生命周期是:登录成功后 LoginForm 把 ResLogin.access_token 写入 store(setToken,经 persist 同步到 localStorage 键 xiaoye-user);此后每次请求由拦截器实时读取 useUserStore.getState().token 写入请求头 x-access-token——注意这是前后端约定的自定义头名而非标准 Authorization,接入真实后端时需按此头名鉴权(见《03-接口设计》1.5);页面刷新后 persist 的 rehydrate 机制把 token 从 localStorage 恢复进内存,这是"刷新不掉线"的基础;登出或 401 时 setToken("") 清内存、persist 写回空串,登录态终止。
三个设计细节值得记录。其一,token 无过期时间:tokenUpdatedAt 字段(写入时间戳)在源码注释中明确"仅作记录用,不参与强制登出判断"——它只是为将来做"登录态新鲜度校验"预留的钩子,当前前端不会主动让 token 失效,有效期完全由后端裁决。其二,存储载体的取舍:token 存 localStorage 而非 Cookie,配合 withCredentials: false(src/api/index.ts),整个鉴权链路不依赖 Cookie,天然免疫 CSRF(攻击者无法借浏览器自动携带的凭证伪造请求);代价是 token 对同源下的 XSS 完全暴露,因此 XSS 防护的责任落在渲染层——React 的默认转义、且全项目未使用 dangerouslySetInnerHTML(迁移指南对排版类的约定),这一对取舍组合是自洽的。其三,多标签页同步缺失:token 变更不广播 storage 事件监听,一个标签页登出后另一标签页仍持旧 token,直到下一次请求被后端判 401 才被拉齐——这是单机演示场景可接受的简化,接生产时建议补一个跨标签同步。
项目当前没有实现 refresh token / 无感刷新:源码中不存在刷新接口、刷新逻辑或并发请求队列,任何 401 一律走"清登录态 + 跳登录页"的硬登出。这是脚手架的刻意简化——无感刷新涉及请求重放、并发去重、刷新失败降级等复杂度,放在模板里反而干扰使用者理解主干。
但扩展点是被精心预留的:唯一的改动位置就是 src/api/index.ts 响应拦截器的业务 401 分支(data.code == ResultEnum.OVERDUE)。当前该分支执行"清 token → resetAuth → 弹错 → 跳登录";要实现无感刷新,就是把这个分支改写为单飞(single-flight)模式:第一个遇到 401 的请求触发刷新请求(后端需提供刷新端点),后续 401 请求进入等待队列;刷新成功后用新 token 重放队列中的原请求,刷新失败才执行现有的清理与跳转。实现时有三个坑需要预先知道:一是重放必须重建请求(axios 没有内置重放),且要带上原请求的 loading/skipErrorHandler 自定义配置;二是刷新接口自身要标记跳过该分支,否则会递归刷新;三是 fetchAuthButtonsWithRetry 等启动期请求也走同一拦截器,刷新窗口期要避免它们被重复弹出错误提示。若后端采用"短 access token + refresh token"方案,refresh token 的存储建议放 Cookie(HttpOnly)而非 localStorage,与本框架现有的 localStorage 方案形成双轨。
登录与改密均以 md5() 摘要提交(src/views/login/components/LoginForm.tsx、src/api/modules/login.ts 注释"与登录一致,密码均传 md5 摘要")。必须明确:md5 摘要不是加密,且此做法对安全的实际增益有限——它在明文 HTTP 时代能防"链路嗅探直接得到密码原文",但 md5 无盐、可被彩虹表反推,且在现代部署(HTTPS 全站,如 nas-deploy 链路的 certbot 证书)下传输层已有保护。它的剩余价值主要是避免服务端日志意外记录明文密码。接入真实后端时应沿用后端的既有约定(若后端要求明文走 HTTPS,去掉前端 md5 即可;若沿用摘要,建议后端加盐),不要把"前端 md5"误当成安全特性。演示环境 mock 的密码校验逻辑(src/mocks/modules/auth.ts 的 currentPasswordMd5)与该约定保持一致。
这是本框架权限设计中最有辨识度的一层,其核心语义是"不在菜单 = 不注册路由 = 组件代码不加载"。执行链是:useAuthStore 的 flatMenuList 是路由注册的唯一依据(src/routers/helper/ConvertRouter.tsx 逐条翻译成 RouteObject),用户手输一个未授权的 URL 时,react-router 匹配不到任何已注册路由,落入 * 兜底(菜单就绪后渲染 404)——不是"页面渲染出来后被守卫挡住",而是路由表里根本没有这一条。
这种结构性隔离与常见的"全量注册路由 + 守卫校验"方案相比有三个实际差异。第一,泄露面更小:未授权页面的 chunk 文件名、组件结构、接口调用方式对无权限用户完全不可见(代码根本没被浏览器请求过),攻击者无法从 bundle 中分析出未授权功能的实现细节。第二,没有"记住合法路径"的绕过面:守卫校验方案中,守卫逻辑一旦有分支疏漏(比如漏判某个 meta 组合),未授权页面就完整可达;结构性隔离没有"可达但被挡"的中间态,路径要么存在要么不存在。第三,代价是灵活性:权限变更必须重新拉菜单并重建路由(本框架通过监听 authMenuList 引用变化重建 router 实现),运行时无法做到"秒级吊销某个页面",吊销在下一次刷新或权限重取时生效。
两个容易误解的边界要澄清。其一,meta.isHide 的语义是"可访问但不在菜单显示"(getShowMenuList 只是渲染过滤,路由照常注册),它服务于动态详情页这类"不希望出现在导航里"的场景,绝不能当成权限控制手段——隐藏 ≠ 无权限。其二,静态路由(登录页、403/404/500)不参与权限体系,/500 还在 ROUTER_WHITE_LIST(src/config/index.ts)白名单中可离线访问;RouterGuard 对已登录用户访问 /login 会弹回首页,防止已登录状态下登录页被用于二次钓鱼。
按钮权限的数据形态是 { [menuKey]: string[] }(src/assets/json/authButtonList.json,如 authButton: ["add","edit","delete","import","export"]),消费端有两个:声明式 <AuthButton authority="add">(src/components/AuthButton/index.tsx,无权限渲染 null)与命令式 useAuthButton() 返回 BUTTONS 映射(供逻辑分支判断,如列表页的批量操作是否显示)。
安全语义上有四点必须强调。第一,页面级命名空间:权限码永远只在当前页 meta.key 对应的数组内查找(页面身份来自 RouteMetaContext 注入而非全局 URL 反查,保证 KeepAlive 缓存页隐藏期间归属正确),同名权限码在不同页面互不干扰,前端不做任何跨页合并——这决定了"全局按钮权限"若要做,应约定特殊键而非改数据形态。第二,数组是"与"语义:authority 传数组时要求全部满足才渲染(authority.every(...)),"或"语义需业务侧自行多次判定。第三,权限码是弱保护:AuthButton 不渲染只是视觉层,接口层面的操作按钮(如删除)仍必须由后端鉴权,前端权限码的唯一作用是减少无意义请求与误导性 UI。第四,默认拒绝:authButtonList[menuKey] 不存在、为 undefined、接口失败回填 {} 时,一切按钮都不渲染(includes 对 undefined 安全返回 false)——失败从紧的原则在视图层同样成立。
src/utils/safeRedirect.ts 的 getSafeRedirectUrl 是防 Open Redirect 的唯一关卡,三条拒绝规则:拒绝空值与非 / 开头的地址(挡住 https://evil.com、javascript:alert(1) 等外链与伪协议);拒绝 // 开头的协议相对地址(浏览器会按当前协议解析到其他站点,是 Open Redirect 最经典的变体);拒绝用 new URL(url, window.location.origin) 解析后 origin 不同的地址(兜住 /\evil.com、////evil.com 这类解析器差异型变体)。校验不通过返回 null,调用方(登录成功后的 LoginForm)回退 HOME_URL。
配套的 buildLoginUrlWithRedirect 服务于非 React 场景(axios 拦截器 401 处理):把当前地址编码进 ?redirect=,且已在登录页时原样返回不带参数——防止"redirect 指向登录页自身"造成的跳转死循环。这两个函数是全项目安全语义测试覆盖最完整的部分(8 个用例,见《09-测试与质量保障》3.2),二次开发时不要绕开它们自行拼接 redirect。
src/hooks/usePermissions.ts 的生产分支有一条铁律:菜单接口失败时执行 setAuthMenuList([]) + setToken("") + 提示登录过期,由 RouterGuard 统一跳登录页——绝不回退 src/assets/json/authMenuList.json 的静态全量菜单。源码注释(P0-2 标注)把原因写得很直白:回退静态菜单等同于放行全部动态路由,让无权限用户获得全站访问能力;历史版本确实存在这个漏洞(docs/bug-audit.md P0-2:接口失败 catch 分支与"后端返回空数组"两个路径都会拿到全量菜单),修复后"空菜单"被明确为合法结果——该账号确无任何权限,同样不做兜底。
围绕这条铁律还有两条配套设计。其一,菜单与按钮权限用 Promise.allSettled 并行请求(P0-3 修复):顺序 await 时按钮接口的失败会抛异常连带吞掉菜单请求;并行后按钮失败按空权限处理({}),菜单失败才触发登出,两类失败互不放大。其二,401 清理的唯一出口是 resetAuth()(src/stores/modules/auth.ts):业务 401(HTTP 200 + code 401)与 HTTP 401(checkStatus)两条路径都执行"清 token + resetAuth",把菜单、按钮权限一并清空——这修复了历史缺陷 P1-4(仅清 token 不清菜单导致的权限串号),保证账号切换后绝无上一个账号的权限残留。这两条"从紧"的分支是安全边界,任何"接口失败就给全量菜单"的"修复"都是权限漏洞(规约见《01-系统架构》第 6 节规约六)。
以下是当前源码中真实存在、尚未处理的与安全相关的事项,均标注了位置,供评估与排期参考。
| 隐患 | 位置 | 性质与建议 |
|---|---|---|
| 菜单数量三处口径不一(实际 77,README 写 80,mock 注释写 81) | src/assets/json/authMenuList.json(解析 77 节点/14 顶级)、README.md、src/mocks/modules/auth.ts 注释 | 文档口径问题而非功能缺陷,但涉及"全量菜单放行多少条路由"的安全叙述时数字必须以 JSON 实际解析为准;建议统一为 77(本套文档 2026-10-06 回刷后已统一按 77 叙述,见《00-项目总览》4;源码侧 README 与注释的失实数字仍待修订,登记于《13-技术债务与演进建议》D1) |
| token 长期驻留 localStorage 无过期策略 | src/stores/modules/user.ts | XSS 得手即可长期窃用;建议接入 tokenUpdatedAt 做新鲜度校验或后端短有效期 + 刷新机制 |
| 开发态预置 dev-token | src/main.tsx | 已用 !import.meta.env.PROD 守卫(P1-3 修复),--mode development/test 构建不会误预置;但保留为"演示便利项",生产构建产物不含此逻辑 |
| 演示账密 admin/123456 公开 | nas-deploy/README.md、src/mocks/modules/auth.ts | 仅 mock 内存数据、无真实风险,但演示站仍保持 HTTPS 唯一入口 |
| resetPassword 接口返回密码明文 | src/api/modules/system.ts(注释"仅演示用") | 真实系统应改为一次性链接或强制改密流程 |
window.$navigate 在 render 期写入 | src/routers/helper/RouterGuard.tsx | 单实例应用无碍;理论上的多实例覆盖问题见《13-技术债务与演进建议》 |
| 权限数据仅启动时获取一次 | src/hooks/usePermissions.ts | 会话内权限变更(如管理员改了某用户菜单)对在线用户不生效,需刷新;属体验取舍而非漏洞 |
收束成五条可执行的规约。一,新增页面必须走菜单数据,绝不手写路由对象绕过权限(见《11-扩展开发指南》场景一);二,生产权限失败分支只能从紧不能从宽,改 usePermissions 前先读 P0-2 注释;三,登出语义必须收敛到 resetAuth,新增任何退出/切换账号入口都不得只清 token;四,redirect 一律过 getSafeRedirectUrl,包括新增的"登录后跳转""分享回跳"类功能;五,按钮权限码以后端下发为准,前端 JSON 只是演示形态,接生产后 authButtonList 的每个键值都应来自权限接口。安全不是一份配置,而是这五条规约在每一次改动中的持续执行。