Skip to content

后台管理系统的登录与权限控制

难度等级:🟡 中级(3-5年)
分类:工程化
归档日期:2026-09-02


📚 理论知识

核心概念

后台管理系统的权限方案通常拆成四层,从下到上依次是:

  1. 登录鉴权:确认"你是谁"(Authentication)
  2. 权限模型:确认"你的角色能做什么"(Authorization,通常是 RBAC——基于角色的访问控制)
  3. 菜单/路由权限:控制"你能看到哪些页面"
  4. 按钮/操作权限:控制"你在页面里能做哪些操作"

前三层决定用户能不能"进入"某个功能,第四层决定进入之后"能不能点"。


一、登录怎么实现

典型流程(Token 方案,最常见):

用户输入账号密码


前端调 /login 接口(密码一般前端做非对称加密或至少 HTTPS 传输)


后端校验账号密码 → 生成 accessToken(JWT) + refreshToken


前端拿到 token:
  - accessToken 存内存/localStorage,有效期短(如 2h)
  - refreshToken 存 httpOnly Cookie 或 localStorage,有效期长(如 7天)


之后每个请求的 header 带上 Authorization: Bearer <accessToken>


axios/fetch 请求拦截器自动注入 token
响应拦截器统一处理 401:
  - 尝试用 refreshToken 静默刷新 accessToken,刷新成功则重发原请求
  - 刷新失败(refreshToken 也过期)→ 清空登录态,跳转登录页

关键实现点:

  • 路由守卫router.beforeEach 里判断本地是否有 token,没有则重定向到登录页;登录页判断已登录则不允许再进登录页。
  • 无感刷新:并发多个请求同时 401 时,要用一个"锁 + 请求队列"防止发出多次 refresh 请求(常见坑)。
  • 单点登录/多标签页同步:可以监听 storage 事件,一个 tab 退出登录,其他 tab 也同步退出。
  • 也有纯 Session + Cookie 的传统方案(后端 session,前端只存 sessionId),后台系统里 JWT 更常见,因为无状态、方便水平扩展、天然支持前后端分离/多端。

二、权限怎么分配(RBAC 模型)

行业标准做法是 RBAC(Role-Based Access Control,基于角色的访问控制)

用户 (User) ──属于──> 角色 (Role) ──拥有──> 权限点 (Permission)
   张三            管理员/运营/普通员工      菜单权限 + 按钮权限 + 接口权限
  • 不直接把权限分配给用户,而是分配给角色,用户再绑定角色 —— 这样新增一个用户只需要"打标签",不用重新配置一遍权限,角色权限变更时所有该角色用户自动生效。
  • 权限点通常用一个**权限码(permission code)**表示,例如:
    • 菜单权限码:system:user:view(查看用户管理菜单)
    • 按钮权限码:system:user:deletesystem:user:export
  • 登录成功后,后端不仅返回 token,还会返回该用户当前角色能访问的菜单树 + 权限码列表,前端存进 Vuex/Pinia/Redux 这类全局 store,页面渲染时全靠查这份数据,不需要前端自己判断"谁是管理员"这种硬编码逻辑。

复杂一点的系统还会做数据权限(同样是查看订单,普通员工只能看自己的,主管能看全部门的),这个一般在接口层用 dataScope 字段控制,前端不用关心。


三、导航栏菜单权限控制

核心思路:菜单是数据驱动的,不是写死在前端代码里的。

  1. 后端返回一份树形菜单数据(登录接口或单独的 /getMenus 接口):
json
[
  {
    "path": "/system",
    "name": "System",
    "meta": { "title": "系统管理", "icon": "setting" },
    "children": [
      {
        "path": "/system/user",
        "name": "SystemUser",
        "component": "system/user/index",
        "meta": { "title": "用户管理", "permission": "system:user:view" }
      }
    ]
  }
]
  1. 前端拿到这份数据后有两种做法生成真正可跳转的路由:

    • 前端过滤式:前端维护一份"全量路由表"(写死在代码里,带 meta.permission),登录后用后端返回的权限码去做交集过滤,能匹配上的才通过 router.addRoute() 动态挂载。
    • 后端返回式:路由的 component 字段是一个字符串路径(如上面 JSON 里的 "system/user/index"),前端拿到后通过一个 component: () => import(@/views/${item.component}.vue) 的动态 import 映射表转成真正的组件,完全由后端决定"有哪些页面、每页对应哪个组件"。中大型中后台(若依、vue-element-admin 这类脚手架)常用这种,改权限不用前端发版。
  2. 侧边栏组件只需要拿这份(已经是"用户能看到的")菜单树,递归渲染成 <el-menu> / <a-menu> 即可,天然只显示有权限的菜单,不需要额外判断。

  3. 关键细节:没有权限的路由不能只是"隐藏菜单入口",必须在路由层面就不注册,或者进入时做 403 拦截——否则用户直接输 URL 也能绕过去访问到页面(这是最容易被面试官追问出的坑)。


四、页面功能按钮权限控制

思路和菜单一样:拿登录时下发的按钮权限码数组,在渲染按钮的地方去判断"这个码在不在数组里"。

Vue 常见做法:自定义指令 v-permission

js
// directives/permission.js
import { useUserStore } from '@/store/user'

export default {
  mounted(el, binding) {
    const { value } = binding // 例如 v-permission="'system:user:delete'"
    const permissions = useUserStore().permissions // 登录时存下来的权限码数组

    if (value && !permissions.includes(value)) {
      el.parentNode?.removeChild(el) // 直接移除,而不是 v-if display:none
    }
  },
}
vue
<el-button v-permission="'system:user:delete'" @click="handleDelete">删除</el-button>

React 常见做法:封装组件或自定义 Hook

jsx
function usePermission(code) {
  const permissions = useSelector(state => state.user.permissions)
  return permissions.includes(code)
}

function PermissionButton({ code, children, ...rest }) {
  const has = usePermission(code)
  return has ? <Button {...rest}>{children}</Button> : null
}

// <PermissionButton code="system:user:delete" onClick={handleDelete}>删除</PermissionButton>

关键点:

  • 优先用从 DOM 里移除而不是 disabled/v-show,避免用户改 DOM 或改 CSS 就能强行点到。
  • 除了指令/组件方式,也常封装一个 hasPermission(code) 的工具函数,用在 v-if 里做更复杂的组合判断(比如"有 A 权限或 B 权限才显示")。
  • 最重要的一条:前端按钮权限只是"体验层面"的控制,防止误导用户看到点不了/不该看的功能;真正的安全防线必须在后端接口上——每个接口都要独立校验当前用户是否有对应权限码,不能假设"前端隐藏了按钮=用户调不了接口"。前端权限和接口权限必须双保险。

常见误区

问题要点
前端存 token 用 localStorage 还是 CookielocalStorage 方便但有 XSS 风险;httpOnly Cookie 更安全但要单独处理 CSRF,无法被 JS 读取
只做菜单隐藏,不做路由拦截输入 URL 直接访问未授权页面,必须在路由守卫里二次校验 meta.permission
按钮权限用 disabled 而不是移除 DOM用户可以改 disabled 属性或直接调接口,形同虚设
权限码直接判断用户名/角色名(硬编码 if role === 'admin'换个角色名字或加个新角色就要改代码,应该判断权限码而不是角色本身
accessToken 永久有效无法及时收回权限/踢人下线,应该短有效期 + refreshToken 机制

❌ 错误答法示范

错误示例:把角色判断写死在业务代码里

js
// ❌ 错误写法
if (user.role === 'admin') {
  showDeleteButton = true
}

❌ 问题:角色和权限耦合死了,运营新增一个"审核员"角色也需要能删除时,就得改这行代码。应该判断权限码:hasPermission('system:user:delete'),权限码到角色的映射交给后端配置。


🎤 模拟面试作答

这块我按登录、权限模型、菜单权限、按钮权限四块来说。

登录这块,我们用的是 JWT 方案:用户登录成功后后端下发一个 accessToken 和 refreshToken,accessToken 有效期比较短,之后每个请求都在 header 里带上,用 axios 的请求拦截器统一注入。响应拦截器里专门处理 401,如果是 accessToken 过期就用 refreshToken 去换新的,换成功了把原来失败的请求重发一遍,如果 refreshToken 也过期了就清空登录态跳登录页。这里有个坑是并发请求同时 401 的时候不能重复发好几次刷新请求,需要加个锁和请求队列。

权限这块我们用的是标准的 RBAC 模型,用户绑角色,角色绑权限点,不会直接把权限点挂在用户身上,这样调整权限只要改角色配置,不用一个个改用户。登录接口返回的数据里除了 token,还有这个用户当前角色能访问的菜单树和一份权限码数组,前端全部存进 store 里。

菜单权限方面,菜单不是写死在前端路由表里的,是后端根据角色下发的一份树形数据,前端拿到之后动态 addRoute,没有权限的菜单对应的路由压根不会被注册,侧边栏直接递归渲染这份数据就行。这里要强调一点,光在菜单上隐藏是不够的,必须路由层面也做拦截,不然用户直接输 URL 还是能进去。

按钮权限就是更细粒度的控制,我们用自定义指令 v-permission,在按钮上写 v-permission="'system:user:delete'",指令内部拿登录时存的权限码数组做比对,没有权限直接把这个 DOM 节点从父节点里移除掉,而不是简单加个 disabled,因为 disabled 用户改改 DOM 或者直接调接口还是能绕过去。

最后我会补一句:前端这一整套权限控制,本质上都是为了用户体验——不让用户看到不该看的东西、点不该点的按钮,但真正的安全边界必须落在后端,每个接口都要独立校验权限,不能信任前端传来的任何东西。

……如果面试官感兴趣,我还可以展开讲讲动态路由 addRoute 具体怎么和 vue-router 的 4.x API 配合,或者讲讲数据权限(同一个按钮,看的数据范围不一样)怎么设计。


❓ 面试官追问预测

追问 1:如果用户直接改浏览器本地存储的权限码数组,把自己伪造成有删除权限,会怎么样?

应对思路:前端权限码只影响 UI 展示,不影响真实能力。即使伪造出按钮点了删除,请求打到后端接口时,后端会用 token 解析出真实用户 → 查真实角色权限 → 校验不通过直接返回 403,前端拿到 403 提示"无权限"即可。前端的判断只是"少走一次无效请求、体验更好",不是安全边界。

追问 2:accessToken 和 refreshToken 都存在前端,会不会都被 XSS 偷走?

应对思路:确实存在风险,所以更安全的做法是 accessToken 放内存里(页面刷新就丢,用 refreshToken 重新换),refreshToken 放 httpOnly Cookie(JS 读不到),配合后端做 CSRF token 校验;退而求其次才是都放 localStorage,这种情况下要格外注意做好 XSS 防护(比如不用 v-html 渲染不可信内容、CSP 策略等)。

追问 3:菜单权限和按钮权限的权限码,前端要怎么组织和管理,会不会很乱?

应对思路:权限码通常按"模块:资源:操作"的三段式命名(如 system:user:delete),由后端在权限管理后台里统一维护、生成,前端不手动编号,只是消费这份数据;前端本地一般会封装一个统一的 hasPermission(code) 工具函数或指令,业务代码里全部通过这一个入口去查,不会散落各处直接操作 store。

追问 4:动态路由 addRoute 是怎么和路由守卫配合,保证刷新页面权限不丢失的?

应对思路:SPA 刷新页面会丢失内存里通过 addRoute 加的路由,所以要在 router.beforeEach 里做一次判断:如果 store 里没有菜单/权限数据(说明是刷新导致丢失),先重新请求一次用户信息和菜单接口,拿到数据后重新执行一遍 addRoute,再用 next({ ...to, replace: true }) 重定向到原本要去的页面,保证不会因为刷新就跳回登录页或 404。

个人面试题归档,持续更新中