第六章 基于角色的权限控制、权限拦截注解与自定义无权限页面
本章在上一章的 RBAC 基础上,完成"认证之后如何授权"的闭环:先讲清基于角色的权限控制原理,再落地
@PreAuthorize注解实现方法级拦截,最后处理无权限时的用户体验------自定义 403 页面。你将得到一套从"角色装载"到"方法级拦截"再到"异常优雅降级"的完整实践路径,同时规避ROLE_前缀重复拼接、hasRole与hasAuthority混用、@PreAuthorize不生效等高频坑位。
前五章解决了"谁能登录"和"数据库里怎么存角色权限"。本章解决"登录后谁能做什么",以及"做不了时的友好提示"。
一、问题切入
在 ss05 中我们已经做到:
Users实现了UserDetails,登录时自动校验四个账户状态Roles表有role字段存角色编码(如ROLE_ADMIN)UserServiceImpl可从数据库加载用户的角色列表
但实际项目还有三个缺口:
- 角色如何参与接口拦截?------角色存了,但没有用于控制谁能调哪个接口
- 细粒度控制怎么做?------URL 级规则不够灵活,同一个 Controller 里不同方法需要不同权限
- 无权限时怎么办?------默认白页(Whitelabel Error Page)用户看不懂,需要自定义 403 页面
本章逐一解决。
二、基于角色的权限控制原理
2.1 认证与授权的分工
Spring Security 的安全体系分两大阶段:
- 认证(Authentication):确认"你是谁"------用户名密码校验
- 授权(Authorization):确认"你能做什么"------角色/权限校验
ss05 完成了认证阶段。授权阶段的核心问题是:当前用户持有的 GrantedAuthority 集合,是否满足接口要求的角色/权限条件。
2.2 角色(Role)在 Spring Security 中的表示
Spring Security 对"角色"有一个特殊约定:角色编码以 ROLE_ 前缀开头。
这不是强制要求,但框架内部很多方法会自动处理这个前缀:
hasRole("ADMIN")→ 实际匹配的 authority 是ROLE_ADMINhasAuthority("ROLE_ADMIN")→ 按完整字符串匹配
因此,建议数据库中的 roles.role 字段直接存 ROLE_ADMIN、ROLE_USER 这种格式,保持与框架约定一致。
2.3 角色装载流程
在本章中,UserServiceImpl.loadUserByUsername() 的流程是:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Users users = usersMapper.selectByLoginAct(username);
if(users == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 查询该用户的所有角色
List<Roles> rolesList = rolesMapper.selectByUserId(users.getId());
users.setRolesList(rolesList);
return users;
}
selectByUserId 通过联表查询 user_roles 获取用户关联的所有角色:
<select id="selectByUserId" parameterType="java.lang.Long" resultMap="BaseResultMap">
select r.*
from roles r
left join user_roles ur on r.id = ur.role_id
left join users u on u.id = ur.user_id
where u.id = #{userId,jdbcType=INTEGER}
</select>
然后 Users.getAuthorities() 把 rolesList 转为 GrantedAuthority:
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(Roles roles : this.rolesList) {
authorities.add(new SimpleGrantedAuthority(roles.getRole()));
}
return authorities;
}
这样,登录成功后,用户的 Authentication 对象中就持有了所有角色对应的 GrantedAuthority。后续无论用 URL 规则还是方法注解做授权,都是在匹配这个集合。
2.4 为什么建议用 List<GrantedAuthority> 而不是 Collection<? extends GrantedAuthority>
你在代码中可能会写成:
Collection<? extends GrantedAuthority> authorities = new ArrayList<>();
然后调用 authorities.add(...) 时编译报错。原因在于 Java 泛型的协变规则:Collection<? extends GrantedAuthority> 表示"某种 GrantedAuthority 子类型的集合",编译器无法保证你 add 进去的类型是同一个子类型,所以禁止 add。
正确做法:
// 声明用具体类型,返回时用协变类型
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
// 返回类型仍然是 Collection<? extends GrantedAuthority>,兼容接口
return authorities;
三、开启方法级权限拦截
3.1 URL 级 vs 方法级
Spring Security 提供两种授权方式:
| 方式 | 位置 | 粒度 | 适用场景 |
|---|---|---|---|
| URL 规则 | SecurityConfig.authorizeHttpRequests |
路径级 | 粗粒度,如"所有 /admin/** 需 ADMIN 角色" |
| 方法注解 | Controller/Service 方法上 | 方法级 | 细粒度,如"删除操作需 ADMIN,查看只需 USER" |
两种方式可以共存。URL 规则先执行(Filter 层),方法注解后执行(AOP 层)。
3.2 第一步:在 SecurityConfig 上添加 @EnableMethodSecurity
这是方法级授权的"总开关"。不加这个注解,@PreAuthorize 不会生效:
@EnableMethodSecurity // 开启方法级安全,使 @PreAuthorize 生效
@Configuration
public class SecurityConfig {
// ...
}
为什么需要显式开启? Spring Security 出于性能和安全性考虑,方法级 AOP 拦截默认关闭。只有开发者明确需要时才开启,避免在不需要方法级控制的场景产生不必要的代理开销。
3.3 @EnableMethodSecurity vs 旧版 @EnableGlobalMethodSecurity
如果你查资料看到 @EnableGlobalMethodSecurity,那是 Spring Security 5.x 的写法,已废弃 。Spring Security 6.x 统一使用 @EnableMethodSecurity,区别在于:
- 旧版需要指定
prePostEnabled = true才能启用@PreAuthorize - 新版默认启用,无需额外参数
3.4 第二步:在 Controller 方法上使用 @PreAuthorize
本章中的 ClueController 提供了完整的实战示例:
@Controller
@RequestMapping("/api/clue")
public class ClueController {
// 只有 USER 角色可以查看线索菜单
@PreAuthorize(value = "hasRole('USER')")
@RequestMapping(value = "/menu")
public String clueMenu(){
return "clueMenu";
}
// 只有 USER 角色可以查看线索列表
@PreAuthorize(value = "hasRole('USER')")
@RequestMapping(value = "/list")
public String clueList(){
return "clueList";
}
// 只有 USER 角色可以查看线索详情
@PreAuthorize(value = "hasRole('USER')")
@RequestMapping(value = "/view")
public String clueView(){
return "clueView";
}
// 只有 ADMIN 角色可以删除线索
@PreAuthorize(value = "hasRole('ADMIN')")
@RequestMapping(value = "/del")
public String clueDel(){
return "clueDel";
}
// ADMIN 或 MODERATOR 角色可以导出线索
@PreAuthorize(value = "hasAnyRole('ADMIN','MODERATOR')")
@RequestMapping(value = "/export")
public String clueExport(){
return "clueExport";
}
// 无注解 = 只需登录即可访问
@RequestMapping(value = "/index")
public String clueIndex(){
return "clueIndex";
}
}
3.5 @PreAuthorize 的执行原理
@PreAuthorize 本质上是 Spring Security 基于 Spring AOP 实现的:
- 当
@EnableMethodSecurity生效后,Spring 会为标注了@PreAuthorize的 Bean 创建代理对象 - 调用目标方法前,代理会执行
AuthorizationManager的检查逻辑 - 检查逻辑从当前
SecurityContext获取Authentication,取出其中持有的GrantedAuthority集合 - 将集合与注解中的表达式(如
hasRole('USER'))进行匹配 - 匹配通过 → 放行执行目标方法;不通过 → 抛出
AccessDeniedException
这就是为什么你在 UserServiceImpl 中装载的角色信息,最终能在 @PreAuthorize 处生效------它们走的是同一条数据链路。
3.6 hasRole 与 hasAuthority 的区别
这是 Spring Security 最容易踩的坑之一:
| 表达式 | 匹配逻辑 | 数据库中应存的值 |
|---|---|---|
hasRole('ADMIN') |
自动补 ROLE_ 前缀,匹配 ROLE_ADMIN |
ROLE_ADMIN |
hasAuthority('ROLE_ADMIN') |
不补前缀,按完整字符串匹配 | ROLE_ADMIN |
hasAuthority('content:moderate') |
不补前缀,按完整字符串匹配 | content:moderate |
hasAnyRole('ADMIN','MODERATOR') |
满足任意一个即可,同样自动补 ROLE_ |
ROLE_ADMIN 或 ROLE_MODERATOR |
实践建议:
- 角色 → 用
hasRole,注解里写ROLE_后面的部分 - 权限点 → 用
hasAuthority,写完整权限编码 - 不要在
hasRole里再写ROLE_前缀,否则会变成ROLE_ROLE_ADMIN
3.7 ⚠️ 重要坑位:hasRole('ROLE_USER') 会导致双重前缀
当前 ClueController 中写的是:
@PreAuthorize(value = "hasRole('ROLE_USER')")
这里有一个隐含 bug :hasRole() 会自动在参数前加 ROLE_,所以实际匹配的是 ROLE_ROLE_USER,而数据库里存的是 ROLE_USER,永远匹配不上。
正确写法:
// 方式一:hasRole 不写 ROLE_ 前缀(推荐)
@PreAuthorize(value = "hasRole('USER')")
// 方式二:hasAuthority 写完整字符串
@PreAuthorize(value = "hasAuthority('ROLE_USER')")
同理,hasAnyRole 也不要写 ROLE_ 前缀:
// 错误:会匹配 ROLE_ROLE_ADMIN 和 ROLE_ROLE_MODERATOR
@PreAuthorize(value = "hasAnyRole('ROLE_ADMIN','ROLE_MODERATOR')")
// 正确:
@PreAuthorize(value = "hasAnyRole('ADMIN','MODERATOR')")
四、自定义无权限页面
4.1 默认行为的问题
当用户访问没有权限的接口时,Spring Security 默认返回:
- 前后端不分离项目:一个简陋的 Whitelabel Error Page(HTTP 403)
- 前后端分离项目:一个空白的 403 响应
这两种都不友好。企业项目通常需要:
- 传统项目:自定义 403 页面,引导用户返回或联系管理员
- 前后端分离项目:返回 JSON 格式的错误信息
4.2 方案一:配置自定义 403 页面(适用于 Thymeleaf 项目)
在 SecurityConfig 的 securityFilterChain 中添加:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) {
return httpSecurity
.formLogin(formLogin -> { /* ... */ })
.authorizeHttpRequests(auth -> { /* ... */ })
// 自定义异常处理
.exceptionHandling(exceptions -> exceptions
// 未登录访问受保护资源 → 401
.authenticationEntryPoint((request, response, authException) -> {
response.sendRedirect("/tologin");
})
// 已登录但无权限 → 403
.accessDeniedPage("/403")
)
.addFilterBefore(captchaFliter, UsernamePasswordAuthenticationFilter.class)
.build();
}
然后在 UserController 中添加 403 页面映射:
@RequestMapping(value = "/403")
public String accessDenied() {
return "403"; // 对应 templates/403.html
}
创建 src/main/resources/templates/403.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>无访问权限</title>
</head>
<body>
<h1>403 - 无访问权限</h1>
<p>抱歉,您没有权限访问此页面。</p>
<a th:href="@{/tologin}">返回登录页</a>
|
<a th:href="@{/}">返回首页</a>
</body>
</html>
4.3 方案二:返回 JSON 格式的错误信息(适用于前后端分离项目)
如果项目是前后端分离架构,应该返回 JSON 而不是页面:
.exceptionHandling(exceptions -> exceptions
// 未登录 → 返回 401 JSON
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("""
{
"code": 401,
"message": "未登录或登录已过期,请重新登录"
}
""");
})
// 无权限 → 返回 403 JSON
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("""
{
"code": 403,
"message": "您没有权限执行此操作"
}
""");
})
)
4.4 两种方案的适用场景
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| Thymeleaf 模板项目 | 自定义 403 页面 | 用户可直接看到友好提示 |
| 前后端分离项目 | JSON 响应 | 前端根据 code 跳转或弹提示 |
| 混合项目 | 两者兼容 | 根据请求头 Accept 或 X-Requested-With 判断 |
4.5 authenticationEntryPoint 与 accessDeniedHandler 的区别
这两个处理器对应不同的异常场景:
| 处理器 | 触发条件 | 典型场景 |
|---|---|---|
authenticationEntryPoint |
未认证用户访问受保护资源 | 没登录就访问 /admin/** |
accessDeniedHandler |
已认证但权限不足 | 普通用户访问 ADMIN 专属接口 |
很多初学者混淆这两者。简单记忆:401 归 authenticationEntryPoint,403 归 accessDeniedHandler。
五、当前登录用户信息获取
5.1 通过 SecurityContextHolder 获取
本章已有的 LoginInfoUtil:
public class LoginInfoUtil {
public static Users getCurrentLoginUser(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (Users) authentication.getPrincipal();
}
}
这是最常用的方式,但当前实现有隐患:
authentication可能为null(如过滤器链中某些阶段)- 匿名访问时
principal是字符串"anonymousUser",强制转型会ClassCastException - 异步线程默认拿不到主线程的
SecurityContext
5.2 推荐增强版
public class LoginInfoUtil {
public static Users getCurrentLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return null;
}
Object principal = authentication.getPrincipal();
if (principal instanceof Users users) {
return users;
}
return null; // 匿名用户或非 Users 类型
}
}
5.3 通过 Controller 方法参数注入
Spring Security 支持直接在 Controller 方法参数中注入 Principal 或 Authentication:
@RequestMapping(value = "/welcome")
@ResponseBody
public Object welcome(Principal principal){
return principal;
}
@RequestMapping(value = "/welcome2")
@ResponseBody
public Object welcome2(Authentication authentication){
// authentication.getAuthorities() 可直接拿到权限集合
return authentication;
}
两种方式对比:
| 方式 | 适用层 | 优势 | 不足 |
|---|---|---|---|
SecurityContextHolder |
任意位置 | 全局可用 | 需手动判空和类型转换 |
| Controller 参数注入 | 仅 Controller | 简洁、类型安全 | 只能在 Controller 层用 |
建议 :Controller 层用参数注入,Service/Util 层用 SecurityContextHolder。
六、完整 SecurityConfig 改造参考
综合以上内容,一份更完整的 SecurityConfig 应该是这样:
@EnableMethodSecurity // 开启 @PreAuthorize 支持
@Configuration
public class SecurityConfig {
@Resource
CaptchaFliter captchaFliter;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) {
return httpSecurity
.formLogin(formLogin -> {
formLogin.loginProcessingUrl("/user/login")
.loginPage("/tologin")
.successForwardUrl("/welcome");
})
.authorizeHttpRequests(auth -> {
auth
.requestMatchers("/tologin", "/common/captcha", "/error", "/403").permitAll()
// URL 级粗粒度规则(与方法级注解互补)
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated();
})
// 自定义异常处理
.exceptionHandling(exceptions -> exceptions
.accessDeniedPage("/403") // 无权限跳转自定义页面
)
.addFilterBefore(captchaFliter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
关键改造点:
- 添加
@EnableMethodSecurity开启方法级拦截 permitAll()中加入/403和/error,避免异常页面本身被拦截- 加入
exceptionHandling配置自定义 403 处理 - URL 级规则只做粗粒度控制(如
/admin/**),细粒度由@PreAuthorize承担
七、常见坑位清单
7.1 hasRole 里写了 ROLE_ 前缀(双重前缀 bug)
// ❌ 错误:匹配的是 ROLE_ROLE_ADMIN
@PreAuthorize("hasRole('ROLE_ADMIN')")
// ✅ 正确:hasRole 不写前缀
@PreAuthorize("hasRole('ADMIN')")
排查方法 :启动项目后,在 getAuthorities() 中打印当前用户持有的所有 authority,与 hasRole 中的匹配值对比。
7.2 忘记加 @EnableMethodSecurity
不加这个注解,@PreAuthorize 完全不生效,接口不会被拦截,但也不会报错------这是最隐蔽的坑。
7.3 ClueController 缺少 @Controller 注解
当前代码中 ClueController 没有 @Controller 注解,这意味着 Spring 不会将其注册为 Bean,@PreAuthorize 自然也不会生效。需要补上:
@Controller
@RequestMapping("/api/clue")
public class ClueController {
// ...
}
7.4 URL 规则与方法注解的冲突
如果 SecurityConfig 中 anyRequest().authenticated() 和 @PreAuthorize 同时存在,两者都会执行:
- URL 规则在 Filter 层先执行
- 方法注解在 AOP 层后执行
因此,不要在 SecurityConfig 中用 anyRequest().permitAll() 绕过 Filter 层,否则方法注解也可能无法正确获取认证信息。
7.5 自定义 403 页面本身被拦截
/403 路径必须加入 permitAll(),否则无权限用户访问 403 页面时会再次触发 403 → 无限重定向。
7.6 SecurityContext 在异步线程中丢失
SecurityContextHolder 默认使用 ThreadLocal 存储认证信息。如果在 @Async 方法或新线程中调用 LoginInfoUtil.getCurrentLoginUser(),会拿到 null。
解决方案:在异步方法执行前手动传递:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 传递到异步线程
@Async
public void asyncTask() {
SecurityContextHolder.getContext().setAuthentication(authentication);
try {
// 业务逻辑
} finally {
SecurityContextHolder.clearContext();
}
}
或配置 DelegatingSecurityContextExecutor,由 Spring 自动传递。
八、快速自测清单
建议按以下顺序验证角色授权是否生效:
- 验证角色装载 :登录
admin/admin123,访问/welcome2,确认返回的authorities包含ROLE_ADMIN - 验证方法级拦截 :登录
user1/user123(只有ROLE_USER),访问/api/clue/del(需ROLE_ADMIN),应返回 403 - 验证 hasAnyRole :登录
bob/bob123(ROLE_MODERATOR+ROLE_AUDITOR),访问/api/clue/export(需ADMIN或MODERATOR),应通过 - 验证自定义 403 页面 :触发无权限访问后,确认跳转到自定义
/403页面而非默认白页 - 验证无注解接口 :登录任意用户,访问
/api/clue/index(无@PreAuthorize),只需登录即可通过 - 验证未登录拦截:未登录直接访问受保护接口,确认跳转到登录页而非 403
测试账号参考:
| 账号 | 密码 | 角色 | 预期可访问 | 预期不可访问 |
|---|---|---|---|---|
| admin | admin123 | ADMIN | 全部接口 | - |
| user1 | user123 | USER | /menu, /list, /view, /index | /del, /export |
| bob | bob123 | MODERATOR + AUDITOR | /export, /index | /del |
九、核心概念总结
| 概念 | 说明 |
|---|---|
@EnableMethodSecurity |
方法级安全总开关,不加则 @PreAuthorize 不生效 |
@PreAuthorize |
方法执行前的权限校验注解,基于 AOP 实现 |
hasRole |
角色校验,自动补 ROLE_ 前缀,不要在参数中再写 ROLE_ |
hasAuthority |
精确权限校验,不补前缀,按完整字符串匹配 |
hasAnyRole |
多角色任一满足即可,同样自动补 ROLE_ 前缀 |
accessDeniedPage |
自定义 403 跳转页面,已登录但权限不足时触发 |
authenticationEntryPoint |
未认证访问受保护资源时的处理器(401) |
accessDeniedHandler |
已认证但权限不足时的处理器(403) |
十、总结
本章把功能从"能登录"推进到了"能按角色授权 + 方法级拦截 + 无权限友好降级":
- 理清了基于角色的权限控制原理------角色编码如何从数据库到
GrantedAuthority到拦截规则 - 落地了
@PreAuthorize方法级拦截------从开启@EnableMethodSecurity到注解写法到原理 - 解决了无权限的用户体验------自定义 403 页面和 JSON 响应
- 梳理了
hasRole/hasAuthority的前缀坑位------这是实战中最容易出错的地方
到这一步,你的安全体系已经具备了 URL 级 + 方法级的双层拦截能力,并且无权限场景有了友好提示。下一章可以继续做:基于权限点(hasAuthority)的精细控制、权限动态刷新,以及统一异常返回(401/403 JSON 化在前后端分离场景的完整实践)。
编辑者 :Flittly
更新时间:2026年4月