【SpringSecurity新手村系列】(6)基于角色的权限控制、权限拦截注解与自定义无权限页面

0 阅读1分钟

第六章 基于角色的权限控制、权限拦截注解与自定义无权限页面

本章在上一章的 RBAC 基础上,完成"认证之后如何授权"的闭环:先讲清基于角色的权限控制原理,再落地 @PreAuthorize 注解实现方法级拦截,最后处理无权限时的用户体验——自定义 403 页面。你将得到一套从"角色装载"到"方法级拦截"再到"异常优雅降级"的完整实践路径,同时规避 ROLE_ 前缀重复拼接、hasRolehasAuthority 混用、@PreAuthorize 不生效等高频坑位。

前五章解决了"谁能登录"和"数据库里怎么存角色权限"。本章解决"登录后谁能做什么",以及"做不了时的友好提示"。

一、问题切入

ss05 中我们已经做到:

  • Users 实现了 UserDetails,登录时自动校验四个账户状态
  • Roles 表有 role 字段存角色编码(如 ROLE_ADMIN
  • UserServiceImpl 可从数据库加载用户的角色列表

但实际项目还有三个缺口:

  1. 角色如何参与接口拦截?——角色存了,但没有用于控制谁能调哪个接口
  2. 细粒度控制怎么做?——URL 级规则不够灵活,同一个 Controller 里不同方法需要不同权限
  3. 无权限时怎么办?——默认白页(Whitelabel Error Page)用户看不懂,需要自定义 403 页面

本章逐一解决。

二、基于角色的权限控制原理

2.1 认证与授权的分工

Spring Security 的安全体系分两大阶段:

  • 认证(Authentication):确认"你是谁"——用户名密码校验
  • 授权(Authorization):确认"你能做什么"——角色/权限校验

ss05 完成了认证阶段。授权阶段的核心问题是:当前用户持有的 GrantedAuthority 集合,是否满足接口要求的角色/权限条件。

2.2 角色(Role)在 Spring Security 中的表示

Spring Security 对"角色"有一个特殊约定:角色编码以 ROLE_ 前缀开头

这不是强制要求,但框架内部很多方法会自动处理这个前缀:

  • hasRole("ADMIN") → 实际匹配的 authority 是 ROLE_ADMIN
  • hasAuthority("ROLE_ADMIN") → 按完整字符串匹配

因此,建议数据库中的 roles.role 字段直接存 ROLE_ADMINROLE_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

而不是 Collection

你在代码中可能会写成:

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 实现的:

  1. @EnableMethodSecurity 生效后,Spring 会为标注了 @PreAuthorize 的 Bean 创建代理对象
  2. 调用目标方法前,代理会执行 AuthorizationManager 的检查逻辑
  3. 检查逻辑从当前 SecurityContext 获取 Authentication,取出其中持有的 GrantedAuthority 集合
  4. 将集合与注解中的表达式(如 hasRole('USER'))进行匹配
  5. 匹配通过 → 放行执行目标方法;不通过 → 抛出 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_ADMINROLE_MODERATOR

实践建议:

  • 角色 → 用 hasRole,注解里写 ROLE_ 后面的部分
  • 权限点 → 用 hasAuthority,写完整权限编码
  • 不要在 hasRole 里再写 ROLE_ 前缀,否则会变成 ROLE_ROLE_ADMIN

3.7 ⚠️ 重要坑位:hasRole('ROLE_USER') 会导致双重前缀

当前 ClueController 中写的是:

@PreAuthorize(value = "hasRole('ROLE_USER')")

这里有一个隐含 bughasRole() 会自动在参数前加 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 项目)

SecurityConfigsecurityFilterChain 中添加:

@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 跳转或弹提示

混合项目

两者兼容

根据请求头 AcceptX-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();
    }
}

这是最常用的方式,但当前实现有隐患:

  1. authentication 可能为 null(如过滤器链中某些阶段)
  2. 匿名访问时 principal 是字符串 "anonymousUser",强制转型会 ClassCastException
  3. 异步线程默认拿不到主线程的 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 方法参数中注入 PrincipalAuthentication

@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();
    }
}

关键改造点:

  1. 添加 @EnableMethodSecurity 开启方法级拦截
  2. permitAll() 中加入 /403/error,避免异常页面本身被拦截
  3. 加入 exceptionHandling 配置自定义 403 处理
  4. 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 规则与方法注解的冲突

如果 SecurityConfiganyRequest().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 自动传递。

八、快速自测清单

建议按以下顺序验证角色授权是否生效:

  1. 验证角色装载:登录 admin/admin123,访问 /welcome2,确认返回的 authorities 包含 ROLE_ADMIN
  2. 验证方法级拦截:登录 user1/user123(只有 ROLE_USER),访问 /api/clue/del(需 ROLE_ADMIN),应返回 403
  3. 验证 hasAnyRole:登录 bob/bob123ROLE_MODERATOR + ROLE_AUDITOR),访问 /api/clue/export(需 ADMINMODERATOR),应通过
  4. 验证自定义 403 页面:触发无权限访问后,确认跳转到自定义 /403 页面而非默认白页
  5. 验证无注解接口:登录任意用户,访问 /api/clue/index(无 @PreAuthorize),只需登录即可通过
  6. 验证未登录拦截:未登录直接访问受保护接口,确认跳转到登录页而非 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)

十、总结

本章把功能从"能登录"推进到了"能按角色授权 + 方法级拦截 + 无权限友好降级":

  1. 理清了基于角色的权限控制原理——角色编码如何从数据库到 GrantedAuthority 到拦截规则
  2. 落地了 @PreAuthorize 方法级拦截——从开启 @EnableMethodSecurity 到注解写法到原理
  3. 解决了无权限的用户体验——自定义 403 页面和 JSON 响应
  4. 梳理了 hasRole/hasAuthority 的前缀坑位——这是实战中最容易出错的地方

到这一步,你的安全体系已经具备了 URL 级 + 方法级的双层拦截能力,并且无权限场景有了友好提示。下一章可以继续做:基于权限点(hasAuthority)的精细控制、权限动态刷新,以及统一异常返回(401/403 JSON 化在前后端分离场景的完整实践)。

编辑者:Flittly
更新时间:2026年4月