人人都会AI编程

27.1 用户权限系统:认证、鉴权、角色菜单管理

更新时间:2026-07-11

权限系统是绝大多数企业应用的基石。无论是后台管理系统、SaaS 平台还是电商运营端,都需要一套可靠的机制来回答两个核心问题:“你是谁”(认证)以及“你能做什么”(鉴权)。与此同时,对于带有前端界面的系统,还需要根据用户的角色动态生成菜单,控制页面入口的可见性。本节将基于 Spring Boot + Spring Security 构建一套经典的 RBAC(基于角色的访问控制)权限模型,覆盖从数据库设计到前后端协同的完整链路。

27.1.1 整体模型设计

RBAC 的核心思想是“用户 -> 角色 -> 权限”的间接授权模式。我们不直接把权限赋予用户,而是让用户拥有若干角色,角色再关联具体权限。这样做的好处是,当公司新增一个岗位(如“运营主管”)时,只需创建一个新角色并分配权限,然后给用户绑定该角色即可,无需逐个为用户配置几十条权限。

本系统涉及的实体关系如下:

  • 用户(User):系统的登录主体。
  • 角色(Role):角色的集合,如“超级管理员”、“普通编辑”、“只读用户”。
  • 菜单/资源(Menu):既包括前端可见的菜单项(目录、页面、按钮),也包括后端 API 资源。本设计中,我们将菜单和权限统一为一张“资源表”,通过类型字段区分目录、菜单和按钮(操作权限)。
  • 用户-角色关联表角色-资源关联表:实现多对多关系。

注意:这里将菜单和操作权限统一为一张表,优点在于方便动态生成菜单树,同时控制按钮级别的可见性。

27.1.2 数据库表结构

接下来给出核心表的设计,以 MySQL 为例。

-- 用户表
CREATE TABLE sys_user (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    username    VARCHAR(50)  NOT NULL UNIQUE,
    password    VARCHAR(255) NOT NULL,            -- 加密后的密码
    real_name   VARCHAR(50),
    email       VARCHAR(100),
    enabled     TINYINT(1)   NOT NULL DEFAULT 1, -- 1:启用 0:禁用
    create_time DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 角色表
CREATE TABLE sys_role (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    role_name   VARCHAR(50)  NOT NULL UNIQUE,
    role_code   VARCHAR(50)  NOT NULL UNIQUE,    -- 编码,如 ADMIN、EDITOR
    description VARCHAR(200),
    create_time DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 资源表(菜单 + 权限)
CREATE TABLE sys_resource (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    parent_id   BIGINT       DEFAULT 0,          -- 上级资源ID,0表示根
    res_name    VARCHAR(50)  NOT NULL,           -- 名称(如“用户管理”)
    res_code    VARCHAR(100),                    -- 权限标识(如 user:list、user:add)
    res_type    VARCHAR(20)  NOT NULL,           -- 类型:CATALOG(目录)、MENU(菜单)、BUTTON(按钮)
    path        VARCHAR(200),                    -- 前端路由路径
    icon        VARCHAR(100),                    -- 图标
    sort_order  INT          DEFAULT 0,
    enabled     TINYINT(1)   NOT NULL DEFAULT 1,
    create_time DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 用户角色关联表
CREATE TABLE sys_user_role (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

-- 角色资源关联表
CREATE TABLE sys_role_resource (
    role_id     BIGINT NOT NULL,
    resource_id BIGINT NOT NULL,
    PRIMARY KEY (role_id, resource_id)
);

几点设计说明:

  • 密码使用 BCrypt 等哈希算法存储,不可逆。
  • 资源类型分为三种:CATALOG 仅作为菜单树中的分组目录;MENU 对应可点击的页面;BUTTON 对应页面内的操作按钮(如新增、删除),用于前端按钮权限控制。
  • 权限标识(res_code)是鉴权的核心。典型格式如 user:listuser:adduser:delete,便于在代码中通过注解声明所需权限。

27.1.3 认证(Authentication)

认证的核心任务是验证用户的身份凭证,并生成对应的安全令牌。常见的方案有基于 Session 的会话管理以及基于 JWT 的无状态认证,本节采用 JWT(JSON Web Token) 实现前后端分离下的认证流程。

1. 认证流程概要

  1. 用户提交用户名和密码。
  2. 服务端查询数据库,验证用户是否存在、是否被禁用,并比对密码哈希。
  3. 校验通过后,生成 Access Token(短期有效)和 Refresh Token(长期有效,用于续期),同时将用户的基础信息及拥有的角色、权限列表封装到 Token 的载荷中或保存至 Redis。
  4. 前端将 Access Token 存入内存(或 HttpOnly Cookie),并在每次请求的 Authorization 头中携带。
  5. 后端通过 Spring Security 过滤器解析 Token,还原认证信息并设置安全上下文。

2. Spring Security 核心配置

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // 开启方法级权限控制
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()  // 登录、注册等公开接口
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter(),
                             UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // JwtAuthenticationFilter 是自定义过滤器,负责解析 Token
}

3. 自定义 JWT 过滤器

过滤器在 UsernamePasswordAuthenticationFilter 之前执行,从请求头中提取 Token,解析并验证有效性。

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String token = resolveToken(request);
        if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
            Authentication auth = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

JwtTokenProvider 负责生成、解析 Token。为简化示例,这里将用户权限列表直接编码在 JWT 中(适合权限信息量不大的情况)。实际项目中,权限数据较多时建议在每次请求时从 Redis 或缓存中加载。

public class JwtTokenProvider {

    private final String secretKey = "a-256-bit-long-secret-key-here";
    private final long accessTokenValidityMs = 15 * 60 * 1000; // 15分钟

    public String createToken(Long userId, String username, List<String> permissions) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + accessTokenValidityMs);

        return Jwts.builder()
            .setSubject(String.valueOf(userId))
            .claim("username", username)
            .claim("permissions", permissions)
            .setIssuedAt(now)
            .setExpiration(expiration)
            .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
            .compact();
    }

    public Authentication getAuthentication(String token) {
        Claims claims = parseClaims(token);
        List<String> permissions = claims.get("permissions", List.class);
        // 将权限转换为 GrantedAuthority 集合
        List<SimpleGrantedAuthority> authorities = permissions.stream()
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());
        UserDetails principal = new org.springframework.security.core.userdetails.User(
            claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    // ... 省略 parseClaims 和 validateToken 方法
}

4. 登录接口

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private JwtTokenProvider tokenProvider;
    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
        // 1. 校验用户名密码
        authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );
        // 2. 加载用户实体与权限
        UserDetailsImpl userDetails = (UserDetailsImpl) userService.loadUserByUsername(request.getUsername());
        List<String> permissions = userDetails.getPermissions();
        // 3. 生成Token
        String token = tokenProvider.createToken(userDetails.getId(),
                                                 userDetails.getUsername(), permissions);
        return ResponseEntity.ok(new LoginResponse(token));
    }
}

至此,认证链路已建立。每个经过认证的请求在 Spring Security 上下文中都会携带相应的权限信息。

27.1.4 鉴权(Authorization)

鉴权解决的是“用户拿到身份后,能访问哪些资源”的问题。Spring Security 提供了多层次、颗粒度的鉴权机制。

1. 方法级权限注解

借助 @EnableMethodSecurity,可以在服务层方法上使用注解声明所需权限或角色。这是最灵活且推荐的方式。

  • @PreAuthorize:在方法执行前校验,支持 SpEL 表达式。
  • @PostAuthorize:在方法执行后校验,可访问返回结果。
  • @Secured:简单角色判断。
  • @RolesAllowed:类似 @Secured,但属于 JSR-250 标准。

典型用法:

@RestController
@RequestMapping("/api/users")
public class UserController {

    @PreAuthorize("hasAuthority('user:list')")
    @GetMapping
    public R listUsers() { /* 返回用户列表 */ }

    @PreAuthorize("hasAuthority('user:add')")
    @PostMapping
    public R addUser(@RequestBody UserDto dto) { /* 新增用户 */ }

    @PreAuthorize("hasAuthority('user:delete') and #id != authentication.principal.id")
    @DeleteMapping("/{id}")
    public R deleteUser(@PathVariable Long id) { /* 不允许删除自己 */ }
}

这里 hasAuthority('user:list') 检查当前用户是否拥有 user:list 权限(即之前 JWT 中存放的 permissions 列表中的一项)。当用户权限不足时,Spring Security 会直接抛出 AccessDeniedException,并返回 403 状态码,无需手工编写 if-else 判断。

2. 角色与权限结合

在 RBAC 模型中,鉴权通常基于权限编码,而非角色。但有时也需要简单的角色判断(如“仅管理员可访问”)。可以通过逻辑组合:

@PreAuthorize("hasRole('ADMIN') or hasAuthority('user:list')")

其中 hasRole 会自动为角色名称添加 ROLE_ 前缀,需要确保权限名称与之匹配。

3. 全局异常处理

为了给前端提供友好的错误提示,可以添加全局异常处理器:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public R handleAccessDenied() {
        return R.fail(403, "您没有访问该资源的权限");
    }
}

4. 自定义权限校验逻辑

当注解表达式无法满足复杂业务(如“只能操作自己部门的数据”)时,可以注入自定义 Bean:

@Service("authz")
public class AuthorizationService {
    public boolean isDeptLeader(Long userId, Long deptId) {
        // 查询数据库判断用户是否该部门的负责人
    }
}

// 使用时
@PreAuthorize("@authz.isDeptLeader(authentication.principal.id, #deptId)")
public void updateDept(Long deptId, DeptDto dto) {}

27.1.5 角色菜单管理

在后台管理系统中,前端侧边栏菜单需要根据用户角色动态生成。角色菜单管理的本质就是将角色与资源表关联,根据当前用户角色查出所有类型为 CATALOG 或 MENU 的资源,并组装成树形结构返回给前端。前端根据返回的菜单 JSON 渲染侧边栏并在路由中进行拦截。

1. 查询用户菜单树

菜单接口不应要求任何权限(登录即可),但它只返回当前用户可访问的菜单。

@RestController
@RequestMapping("/api/menu")
public class MenuController {

    @Autowired
    private SysResourceService resourceService;

    @GetMapping("/user-tree")
    public R getCurrentUserMenuTree() {
        Long userId = SecurityUtils.getCurrentUserId();
        List<SysResource> menus = resourceService.findUserMenus(userId);
        List<MenuNode> tree = buildTree(menus);
        return R.ok(tree);
    }

    private List<MenuNode> buildTree(List<SysResource> menus) {
        // 遍历并递归构建父子结构,此处省略具体递归代码
        // 通常使用 Map<parentId, List<MenuNode>> 组装
    }
}

Service 层查询逻辑:

public List<SysResource> findUserMenus(Long userId) {
    // 1. 查询该用户关联的所有角色
    List<Long> roleIds = userRoleMapper.selectRoleIdsByUserId(userId);
    // 2. 查询这些角色关联的资源ID
    List<Long> resourceIds = roleResourceMapper.selectResourceIdsByRoleIds(roleIds);
    // 3. 查询这些资源中类型为 CATALOG 和 MENU 的,并按排序号排列
    return resourceMapper.selectByIdsAndTypes(resourceIds,
                       Arrays.asList(ResourceTypeEnum.CATALOG.name(),
                                     ResourceTypeEnum.MENU.name()));
}

MenuNode 是一个简单的树节点对象,通常包含 id、name、path、icon、children 等字段,符合前端 UI 库(如 Element UI)的数据格式要求。

2. 前端权限控制要点

  • 路由动态注册:前端在用户登录后,调用获取菜单树的接口,将返回的菜单数据转换成路由配置并通过 router.addRoute 动态添加。同时,将路由映射存储起来用于页面中的权限判断。
  • 按钮权限:对于按钮级权限,可以使用自定义指令(如 Vue 的 v-permission="'user:add'"),根据当前用户权限列表决定是否渲染该按钮。这避免了将权限判断直接散落在模板代码中。
  • 菜单高亮与权限同步:当后端修改了角色的资源关联后,用户重新登录或前端定期刷新菜单即可获取最新权限,保证了动态性。

3. 管理界面

通常需要提供一套后台管理页面来维护角色与菜单/权限的关系。角色详情页通过树形组件展示所有资源(目录+菜单+按钮),管理员勾选后保存。保存时前端将选中的资源 ID 列表提交,后端直接全量更新 sys_role_resource 表。

@Transactional
public void assignResources(Long roleId, List<Long> resourceIds) {
    // 先删除原有关联
    roleResourceMapper.deleteByRoleId(roleId);
    // 批量插入新关联
    if (CollectionUtils.isNotEmpty(resourceIds)) {
        roleResourceMapper.insertBatch(roleId, resourceIds);
    }
}

27.1.6 一些实践建议

  1. 密码安全:必须使用自适应哈希算法(BCrypt、Argon2),切不可明文存储或使用简单 MD5。
  2. Token 续期:JWT 本身不可撤销,因此 Access Token 有效期应尽可能短(如 15 分钟),配合 Refresh Token 存储在 HttpOnly Cookie 中以减少 XSS 攻击风险。或者干脆把 Token 存一份在 Redis,并以此为准验证有效性。
  3. 权限粒度不宜过细:避免为每个接口都定义一个单独的权限编码,这样会使管理和配置失控。建议按功能模块和操作类型(CRUD)划分,例如 user:readuser:write
  4. 超级管理员特殊处理:超级管理员通常拥有所有权限,可以在代码中硬编码判断角色编码或用户 ID,直接赋予所有资源访问权,避免为其配置上千条资源。
  5. 前后端双重鉴权:前端菜单隐藏仅仅是提升用户体验,后端必须对所有敏感接口进行权限校验,绝不可假设前端能控制所有访问。

至此,我们从数据库设计、认证、鉴权到前端菜单动态生成,完整地实现了一个可落地的用户权限系统。这套实现模式已经在大量真实项目中得到验证,你可以在此基础之上融入多租户、数据权限等更高级的功能,但核心的 RBAC 骨架基本不变。