权限系统是绝大多数企业应用的基石。无论是后台管理系统、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:list、user:add、user:delete,便于在代码中通过注解声明所需权限。
27.1.3 认证(Authentication)
认证的核心任务是验证用户的身份凭证,并生成对应的安全令牌。常见的方案有基于 Session 的会话管理以及基于 JWT 的无状态认证,本节采用 JWT(JSON Web Token) 实现前后端分离下的认证流程。
1. 认证流程概要
- 用户提交用户名和密码。
- 服务端查询数据库,验证用户是否存在、是否被禁用,并比对密码哈希。
- 校验通过后,生成 Access Token(短期有效)和 Refresh Token(长期有效,用于续期),同时将用户的基础信息及拥有的角色、权限列表封装到 Token 的载荷中或保存至 Redis。
- 前端将 Access Token 存入内存(或 HttpOnly Cookie),并在每次请求的
Authorization头中携带。 - 后端通过 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 一些实践建议
- 密码安全:必须使用自适应哈希算法(BCrypt、Argon2),切不可明文存储或使用简单 MD5。
- Token 续期:JWT 本身不可撤销,因此 Access Token 有效期应尽可能短(如 15 分钟),配合 Refresh Token 存储在 HttpOnly Cookie 中以减少 XSS 攻击风险。或者干脆把 Token 存一份在 Redis,并以此为准验证有效性。
- 权限粒度不宜过细:避免为每个接口都定义一个单独的权限编码,这样会使管理和配置失控。建议按功能模块和操作类型(CRUD)划分,例如
user:read、user:write。 - 超级管理员特殊处理:超级管理员通常拥有所有权限,可以在代码中硬编码判断角色编码或用户 ID,直接赋予所有资源访问权,避免为其配置上千条资源。
- 前后端双重鉴权:前端菜单隐藏仅仅是提升用户体验,后端必须对所有敏感接口进行权限校验,绝不可假设前端能控制所有访问。
至此,我们从数据库设计、认证、鉴权到前端菜单动态生成,完整地实现了一个可落地的用户权限系统。这套实现模式已经在大量真实项目中得到验证,你可以在此基础之上融入多租户、数据权限等更高级的功能,但核心的 RBAC 骨架基本不变。