1. 认识 Spring Security:它到底是什么,能替你解决哪些安全问题
先说结论:Spring Security 是 Spring 生态里的安全框架,核心解决两件事——认证(Authentication)和授权(Authorization)。说白了就是回答两个问题:“你是谁”和“你能干什么”。
这个框架不是最近才火的新东西,它从 Acegi 时代一路迭代到今天,已经成了 Java 后端做安全控制的默认选择。你只要用 Spring Boot 搭 Web 应用,只要涉及登录、权限、接口保护,十有八九会用到它。很多人一开始觉得它难,是因为它跟传统写业务代码的思路不太一样——你不需要在每个接口里写一堆 if 判断“有没有登录”,而是通过一套过滤器链在请求进入 Controller 之前就把安全检查做完。
我见过不少团队用了几年的 Spring Security,出了 BUG 只会到处复制配置,对底层机制一知半解。这篇文章我想从整体设计思路讲起,再把热点问题逐个拆开:Filter 是怎么注册进去的、OAuth2 里 hasScope 为什么不见了、Spring Boot 3.x 下到底该怎么配。适合刚接触 Spring Security 的初学者,也适合用了很久但没深入过原理的开发者。
2. 整体架构与核心思路拆解
2.1 三大核心组件:SecurityContext、Authentication、SecurityContextHolder
讲 Spring Security 之前,得先建立一个概念:它所有的工作都围绕一条链来展开——“身份信息从哪来、放在哪、怎么被取用”。
- SecurityContext:一个接口,内部保存当前请求的 Authentication 对象。你可以把它理解成一个“信封”,里面装的是当前用户是谁、有什么权限。
- Authentication:代表当前用户的身份凭据和权限集合。它有三个关键信息:
- principal:用户主体,通常是 UserDetails 对象或者用户名;
- credentials:凭证,通常是密码(认证完成后会被清空);
- authorities:已授予的权限集合。
- SecurityContextHolder:一个静态工具类,用 ThreadLocal 方式持有 SecurityContext。在同一个线程内,你随时可以用
SecurityContextHolder.getContext().getAuthentication()拿到当前用户信息。
这三个组件的关系,我用大白话解释一下:SecurityContextHolder 像一个储物柜,ThreadLocal 相当于给每个请求分一个独立的格子,互不干扰;格子里面放着 SecurityContext 信封;信封里装着一张写着用户信息的纸,也就是 Authentication。整个 Spring Security 的过滤链,做的主要工作就是“往信封里塞纸”和“检查纸上写了什么”。
2.2 认证流程的运行逻辑:从请求进入过滤器链到身份落定
如果你第一次接触 Spring Security,最容易困惑的是“认证到底在哪里发生的”。这里的关键机制是:Spring Security 默认不拦截所有请求做强制认证,而是通过过滤器链来实现策略控制。
一次带 Basic Auth 或表单登录的请求,大致的流程是:
- 请求进入
FilterChainProxy(这是整条过滤器链的总入口); - 按配置顺序执行链条上的各个 Filter,比如
SecurityContextHolderFilter(从 Session 或请求中恢复上下文)、UsernamePasswordAuthenticationFilter(处理提交的用户名密码)、AnonymousAuthenticationFilter(给未认证的请求设置匿名身份)等; - 当执行到授权相关过滤器时(如
AuthorizationFilter),会检查当前Authentication是否已认证、是否满足访问当前 URL 所需的权限; - 如果未认证,会抛出
AuthenticationException或被重定向到登录页;如果认证了但权限不足,会抛出AccessDeniedException。
这个过程有个很关键的点:过滤器链在业务代码之前执行,所以你的 Controller 里拿到的永远是一个已经经过安全处理的“当前用户状态”。这也是为什么 Spring Security 使用体验好——你压根不需要在 Controller 里写一堆重复的登录校验逻辑,框架在入口处就把活干完了。
2.3 为什么选择过滤器链而不是其他方案
有些读过源码的人会问:为什么 Spring Security 不直接用一个拦截器(HandlerInterceptor)或者 AOP 切面来做安全检查,非要搞一条复杂的过滤器链?
我个人的理解是:过滤器是 Servlet 标准里最早的、也最靠近容器底层的扩展机制,它能覆盖的不只是 Spring MVC 的路径,还包括静态资源、错误分发、以及非 Spring 管理的 Servlet。拦截器和 AOP 都晚了过滤器一步,很多场景覆盖不到。再说了,过滤器链天然支持“先检查再放行”的流水线模型,对“认证→授权→后续处理”这种安全流程非常契合。
另外,Spring Security 的过滤器链不是一串任意排列的 Bean,它通过SecurityFilterChain接口来组织,你可以配置多个 SecurityFilterChain,每个链可以匹配不同路径、应用不同规则。这种设计让多租户、前后端分离、接口和页面分别管控这样的场景变得很干净。
3. 核心机制详解:Spring Security Filter 是如何完成注册的
这个点是官方文档和中文社区里讨论热度非常高的一个问题。很多人扒 Spring Boot 源码时会好奇:我们并没有手动注册 Spring Security 的过滤器到 Servlet 容器,它到底是怎么生效的?
3.1 DelegatingFilterProxy 与 FilterChainProxy 的关系
先讲两个容易混淆的类:
- DelegatingFilterProxy:它是 Spring 提供的一个 Servlet 过滤器,但本身不做安全逻辑,只是一个“中间人”。它在 Servlet 容器中注册,被调用时从 Spring 容器里找名字为
springSecurityFilterChain的 Bean,再把请求委托给它。 - FilterChainProxy:这才是 Spring Security 真正的主角。它实现了
Filter接口,内部可以持有多个SecurityFilterChain,每个链包含一组有序的过滤器。
为什么要有这一层“代理套代理”的设计?因为直接注册FilterChainProxy到 Servlet 容器是可以的,但 Spring Security 需要解决“什么时候初始化、Bean 还没准备好怎么办”这类问题。DelegatingFilterProxy 机制让过滤器的生命周期和 Spring 容器的生命周期解耦了一些,保证容器能先完成初始化,再通过代理获取真正干活的 Bean。
3.2 在 Spring Boot 3.x 中,Filter 到底是怎么“自动”注册的
Spring Boot 3.x 使用了新的自动配置机制SecurityFilterAutoConfiguration,里面做了这么几件事:
- 注册一个名为
springSecurityFilterChain的DelegatingFilterProxyRegistrationBean; - 这个
RegistrationBean会把DelegatingFilterProxy注册到 Servlet 容器中,映射路径默认是/*; DelegatingFilterProxy在首次被调用时,会从 Spring 容器中获取名为springSecurityFilterChain的 Bean(通常就是FilterChainProxy);FilterChainProxy内部再按照配置好的SecurityFilterChain列表,逐条匹配并执行过滤器链。
所以如果你在 Spring Boot 项目里跑起来,看一眼启动日志,会发现多了一个名为springSecurityFilterChain的过滤器,它其实就是FilterChainProxy的化身。如果不理解这层关系,你会以为 Spring Security 用魔法给你做了所有事情,魔法失灵的时候连排查方向都找不到。
3.3 过滤器链的默认排序与自定义过滤器的插入位置
默认的过滤器链上,过滤器是有固定顺序的,比如SecurityContextHolderFilter在前,AuthorizationFilter在靠近末尾的位置。这种顺序设计是有道理的:先得把请求身份信息构建好,后面的授权过滤器才能检查。
如果你需要自定义过滤器(比如加一个验证请求头、记录审计日志),可以用addFilterBefore()或addFilterAfter()指定位置。这里有个实操上的重要提醒:插入过滤器时一定要搞清楚你要在哪个过滤器前后生效。我见过有人把自定义校验逻辑放在SecurityContextHolderFilter之前,导致根本拿不到用户信息;也有人把过滤器放在授权过滤器之后,结果被拒绝的请求已经“响应”了,逻辑压根不执行。
3.4 多个 SecurityFilterChain 的匹配顺序
Spring Security 允许配置多个SecurityFilterChain,匹配规则是“先到先得”——按配置顺序优先匹配第一条能匹配上的 Chain,之后的就不再执行。这在做 API 和后台管理区分时很好用:
- 第一链:匹配
/api/**,使用无状态 JWT 认证,放行登录接口; - 第二链:匹配
/admin/**,使用表单登录 + CSRF 保护,要求管理员角色; - 第三链:匹配所有路径,默认全部放行或全部拒绝。
但千万注意顺序的坑:如果你把“匹配所有路径”的链放在最前面,那后面的链全都会失效。这个点我在实际项目里踩过一次坑,后来养成习惯:具体的链放前面,兜底的链放最后。
4. Spring Security 6.x 与 OAuth2 相关变化的重点解读
4.1 hasScope 方法去哪了:OAuth2 授权范围与权限的纠葛
最近网上有很多人问:“Spring Security OAuth2 没有 hasScope 方法了吗?”。这个问题背后其实是一个版本演进的故事。
如果你用的是早期的 Spring Security OAuth2(比如 Spring Boot 1.x/2.x 时代的spring-security-oauth2项目),那时候资源服务器校验 Token 时,可以直接用.access("@oauth2.hasScope('read')")这样的写法,或者使用hasScope("read")。这是因为旧项目的@EnableResourceServer和@EnableOAuth2Sso注解提供了一套内置的 OAuth2 校验模型,hasScope是这套模型里的一个表达式方法。
但到了 Spring Security 5.x 之后,尤其是 Spring Boot 2.x 后期,官方把 OAuth2 的功能整合进了 Spring Security 核心里的spring-security-oauth2-resource-server,原来的@EnableResourceServer被废弃,大量spring-security-oauth2的写法就“变味”了。你不再能在配置里直接调用hasScope(),因为AuthorizationFilter的权限检查模型已经变成了 “基于 GrantedAuthority 通用模型”。换句话说,系统不再区分“这是一个 scope”还是“这是一个 role”,在权限上下文里它们统称为authority。
这时如果你配的是 JWT 登录,JwtGrantedAuthoritiesConverter会把 JWT 里的scope字段转换成带SCOPE_前缀的 authority。举例来说,JWT 声明中包含"scope": "read",那么转化后就变成了SCOPE_read。所以你的授权表达式应该写:
http.authorizeHttpRequests(auth -> auth.anyRequest().hasAuthority("SCOPE_read"))在方法安全上,如果你用@PreAuthorize,也应该写成hasAuthority('SCOPE_read')。网上很多说“hasScope 没了”的帖子,其实是因为他们没有重新理解“权限模型统一”这件事——scope 和 role 在 Spring Security 内部都被降维成了 authority,但增加了前缀规则来区分来源。
4.2 Spring Authorization Server 取代了旧的 OAuth2 工程
再延伸一个相关变化:原来的spring-security-oauth2-authorization-server是被拆出来的独立项目,后来正式进入 Spring 官方维护路线。Spring Boot 3.x 之后,如果你想自己搭一个授权服务器(Authorization Server)来签发 Token,官方推荐用的就是 Spring Authorization Server;而 Spring Security 本身专注做资源服务器(Resource Server)和客户端(Client)。
这就意味着:老的spring-security-oauth2依赖里很多类在 Spring Boot 3.x 里都已经不存在或迁移了。如果你在迁移老项目,遇到SecurityFilterChain里oauth2ResourceServer()配置不生效、或者引入旧依赖后报 ClassNotFound 的错误,第一反应应该是检查是不是引入了已被淘汰的 OAuth2 旧依赖。
4.3 Spring Security 6.x 相比 5.x 的关键差异
Spring Security 6.x 有几个对升级影响很大的变化:
- 配置方式彻底转向 Lambda DSL:老的
antMatchers()方法移除,改用requestMatchers(); - 默认禁止 CSRF:对无状态 API 应用来说更省心,但传统表单登录场景要注意配置;
- 密码编码策略升级:默认
PasswordEncoder是DelegatingPasswordEncoder,存储格式是{bcrypt}...,所以密码编码器的选择要明确; - 授权模型简化:
authorizeRequests()改成了authorizeHttpRequests(); - 角色校验变化:因为 authority 模型统一,要求角色检查时要注意 ROLE_ 前缀的匹配逻辑。
这些差异不是语法层面随便改改那么简单,背后是 Spring Security 在“去 OAuth2 特殊化、统一为通用权限模型”的过程中做的简化。理解了这个大方向,你看到一堆 API 变化就会觉得很合理。
5. 实操过程:基于 Spring Boot 3 搭建一个可运行的 Security 示例
理论讲再多,不如跑一个项目来得直观。这一节我带大家从零配置一个 Spring Boot 3 + Spring Security 6 的项目,覆盖 JWT 登录、接口保护、角色授权这几个最常见的需求。依赖我用的版本是 Spring Boot 3.2.x,对应的 Spring Security 6.2.x。
5.1 引入依赖与基础配置
先在pom.xml里加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>如果要用 JWT,还需要引入jjwt:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency>只要引入spring-boot-starter-security,哪怕你没写任何配置,Spring Boot 也会自动注册上面说的过滤器链,默认要求所有请求认证,并生成一个随机密码。所以项目启动的时候,控制台里那一段Using generated security password: xxx就是自动配置的默认密码。
5.2 核心安全配置类:SecurityFilterChain 的配置与参数含义
写一个SecurityConfig配置类:
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN") .anyRequest().authenticated() ) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或Token无效\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"没有权限访问\"}"); }) ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }逐个解释关键点:
csrf.disable():对于纯前后端分离的 REST API 来说,CSRF 防护通常不需要,因为 CSRF 攻击依赖浏览器的 Cookie 自动携带,而 JWT 通常存在 Header 里。如果你做的是表单登录的传统 Web 应用,不要图省事直接禁用它。sessionCreationPolicy(STATELESS):告诉 Spring Security 不使用 Session 保存登录状态。每次请求都要通过 Token 来认证。requestMatchers("/api/auth/login").permitAll():登录接口必须放行,不然用户没法登录。hasRole("ADMIN")等价于检查有没有ROLE_ADMIN这个权限。addFilterBefore(...):把自定义的 JWT 过滤器放到UsernamePasswordAuthenticationFilter之前执行,这样请求到达 Controller 之前就会完成 Token 解析和身份注入。
5.3 自定义 JWT 过滤器与登录认证流程
登录请求进来了,Spring Security 不会自动帮你验证用户名密码,需要你自己实现。一个常见做法是:写一个 Controller 接收用户名密码,调用AuthenticationManager做认证,成功后签发 JWT。
定义AuthenticationManagerBean:
@Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }然后在内部,Spring Security 会使用DaoAuthenticationProvider完成从UserDetailsService加载用户、比对密码的逻辑。所以你要实现一个UserDetailsService,从数据库(或内存)里加载用户信息:
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 这里替换为从数据库中查询用户 if (!"admin".equals(username)) { throw new UsernameNotFoundException("用户不存在"); } return User.withUsername("admin") .password(passwordEncoder().encode("123456")) .roles("ADMIN") .build(); } }登录接口:
@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtUtil jwtUtil; @PostMapping("/login") public Map<String, String> login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); String token = jwtUtil.generateToken(authentication.getName()); return Map.of("token", token); } }这里有个很核心的细节:authenticationManager.authenticate()抛出的异常,由 Spring Security 决定是继续往下走还是直接中断。如果用户名密码不对,会抛出BadCredentialsException,你不捕获的话会返回 500 或 401,根据你的异常处理来定。建议在全局异常处理器里专门捕获AuthenticationException和AccessDeniedException。
JWT 过滤器核心逻辑:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { String username = jwtUtil.extractUsername(token); if (username != null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtUtil.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception e) { // 解析失败则视为未认证,不设置 SecurityContext,后续过滤器会拦截 } } filterChain.doFilter(request, response); } }这里有个容易踩的坑:过滤器内如果解析 Token 失败,不要把异常直接抛出,否则请求直接中断。更好的做法是:不设置 SecurityContext,让后面的授权过滤器看到“未认证”状态,统一走AuthenticationEntryPoint返回 401。这样才能保证异常处理路径统一。
5.4 密码加密与 UserDetailsService 的配合
前面代码里用到了passwordEncoder(),这个方法需要定义 Bean:
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }密码加密这个环节,我见过很多初级项目直接明文存储,这是非常危险的做法。BCrypt 是一种适合密码哈希的算法,特点是自带随机盐、计算速度可控,能抵抗暴力破解。Spring Security 的DaoAuthenticationProvider会自动调用PasswordEncoder.matches(rawPassword, encodedPassword)来比对密码,所以你的UserDetailsService返回的密码必须是数据库里存的编码后的值。
在实际项目里,用户注册时应该用passwordEncoder.encode(rawPassword)存储密码,登录时由框架自动完成校验,不要自己写解密逻辑。
5.5 方法级安全:在 Controller 上使用 @PreAuthorize 增强权限控制
很多场景下,URL 级别的权限控制不够细,比如同一个接口,A 用户可以访问,B 用户不能访问。这时候方法级安全就派上用场了。在配置类上加了@EnableMethodSecurity之后,可以直接在方法上用注解:
@GetMapping("/order/{id}") @PreAuthorize("hasRole('ADMIN') or @orderService.isOwner(#id, authentication.name)") public Order getOrder(@PathVariable Long id) { return orderService.getOrder(id); }这里的@orderService.isOwner(#id, authentication.name)是 Spring Security 的 SpEL 表达式,可以调用 Spring Bean 的方法做自定义校验。这种写法比硬编码一堆 if 判断要优雅得多,而且和 URL 级别的授权是叠加生效的。我个人的习惯是:URL 级别配置粗粒度的入口规则,方法级别处理细粒度的资源所有权校验,两层结合,既简洁又安全。
6. 常见问题与排查技巧实录
这部分我整理了自己和团队在项目里经常碰到的几个问题,每个都是亲手排查过的,直接给结论和排查思路。
6.1 为什么有时候返回 403,有时候返回 401
这是初学者最容易困惑的问题。Spring Security 中:
- 401表示“未认证”——你可能根本没有登录,或者 Token 无效。触发点是
AuthenticationEntryPoint。 - 403表示“已认证但没有权限”——你登录了,但是访问的资源超出了你的角色/权限范围。触发点是
AccessDeniedHandler。
如果你发现“用户明明没登录却返回 403”,多半是因为匿名用户也能通过认证链,只是没有权限。解决办法是检查你的安全配置:对于需要认证的路径,不要用permitAll(),应该用authenticated()。另外,exceptionHandling里两个 Handler 都要配置,否则默认行为可能会把异常渲染成错误页,前后端分离时很烦。
6.2 自定义 Filter 被调用了两次,怎么排查
我遇到过一次:自定义的认证过滤器在两台实例上各执行了一次,导致用户状态被覆盖。排查后发现是配置了多个SecurityFilterChain,而我的自定义过滤器被同时添加到了多个链上,导致一个请求路径匹配了多条链。
还有一种可能性是:DelegatingFilterProxy和 Spring Boot 自动注册的 Filter 都注册了一次,两者叠加后看起来执行了多次。解决方法是:确认你的过滤器 Bean 是否实现了Filter接口且同时被手动RegistrationBean注册了。Spring Boot 的自动注册机制对容器中所有FilterBean 都会生效,如果你同时手动注册,就会重复执行。
6.3 使用 JWT 后,登录状态突然消失
如果你在无状态模式下,登录后再次请求接口发现SecurityContextHolder.getContext().getAuthentication()是 null,排查顺序如下:
- 确认 JWT 过滤器有没有被添加到过滤器链的正确位置;
- 确认过滤器有没有把
Authentication设置到SecurityContextHolder; - 确认每次请求都会经过过滤器,有没有被
permitAll()的路径跳过了; - 确认
OncePerRequestFilter的shouldNotFilter方法没有误伤。
这里有个关键点:Spring Security 6 默认在请求结束时会清空 SecurityContext,如果你用的是有状态 Session,框架会自动从 Session 中恢复;如果是无状态模式,必须每次请求重新解析 Token 并设置 SecurityContext。如果你发现“第一次请求有用户信息,第二次没有了”,大概率是 Token 解析或过滤器链被某个异常中断了。
6.4 CORS 配置到底放在哪里才生效
前后端分离的项目,跨域问题绕不开。Spring Security 6 中配置 CORS 的正确姿势是:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));然后在@Configuration里提供一个CorsConfigurationSourceBean:
@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(List.of("*")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; }很多同学在 Spring MVC 里配置过@CrossOrigin或WebMvcConfigurer,但在 Spring Security 下光配置这些是不够的,因为安全过滤器链上还需要开启cors()。否则预检请求(OPTIONS)会被安全链拦截,导致页面报 CORS 错误。还有一个细节:setAllowCredentials(true)时,setAllowedOriginPatterns不能用*,否则浏览器会拒绝请求。
6.5 为什么角色带上了 ROLE_ 前缀还是没生效
Spring Security 里,如果你用.hasRole("ADMIN"),框架内部会自动补全成ROLE_ADMIN,然后和UserDetails里的 authorities 比较。所以你有两种写法:
- 给用户赋权限时用
grantedAuthorities加ROLE_ADMIN; - 或者用
.roles("ADMIN")快捷方式。
但有个容易忽略的点:如果你使用 JWT 认证,Token 解析后生成的GrantedAuthority列表里如果是ADMIN而不是ROLE_ADMIN,那么hasRole("ADMIN")就匹配不上,因为框架会自动找ROLE_ADMIN。解决办法是在 JWT 过滤器解析时做权限前缀映射:
List<GrantedAuthority> authorities = jwtUtil.extractRoles(token).stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList());我把这个坑写出来,是因为项目里有个同事排查了一整天,最后发现是 JWT 里只放了"role": "ADMIN",没有加前缀,配置里用的又是hasRole,导致一直 403。
7. 与其他安全框架的对比与选型建议
有不少人在社区里问“Shiro 和 Spring Security 选哪个”。我个人的态度很明确:如果是新项目,除非有特殊理由(比如团队对 Shiro 极其熟悉),否则优先选 Spring Security。
原因有三:
- 生态整合度高:官方对 OAuth2、OIDC、LDAP、JWT 等都有现成的支持,Shiro 在这些领域往往需要自己拼装第三方库;
- 更新迭代稳定:Spring Security 随 Spring Boot 一起发布,版本兼容性由官方保证,不会出现“框架升级后安全配置 API 失效”这种失控情况;
- 社区问题库丰富:遇到问题搜解决方案基本都有人踩过坑,排障成本低。
Shiro 的优势在于学习曲线平缓、配置相对简单,适合在 Spring 的早期项目或者非 Spring 项目中使用。我一开始学的是 Shiro,后来切到 Spring Security 时确实经历了一段难受期,但撑过去就觉得整套模型更严谨。如果团队项目已经是 Spring Boot 技术栈,我建议不要混用两套安全框架,否则多套过滤器链叠加后会产生各种诡异问题。
8. 后续扩展方向:从基础认证到 Spring Authorization Server
如果你已经能独立完成上面的 JWT 登录示例,下一步我建议往这几个方向深入:
- Spring Authorization Server 授权码模式:自己搭建一个授权服务器,为第三方应用签发 Token,涉及授权码模式、刷新令牌、客户端注册等概念;
- OAuth2 Resource Server 与自定义 JWT 解析:不自己解析 Token,而是交给 JwtDecoder,并配置 JWT claim 到 authority 的映射规则;
- 动态权限:把 URL 和角色的映射关系存入数据库,通过自定义
AuthorizationManager实现运行时动态授权; - 分布式会话与无状态 Token 的取舍:微服务场景下 Session 和 JWT 的选型、Token 续期、黑名单方案设计。
这些方向每一个展开都能写一篇长文,但基础还是本文提到的这些组件。把 SecurityContext、过滤器链、认证与授权模型吃透,后面学什么都顺。
9. 最后分享一点我的实操体会
写这篇文章时我回想了一下这几年用 Spring Security 的过程。最大的体会是:不要一开始就追求把每个 Filter 的源码都看完,但要抓住核心链路。你只需要理解“请求进来 → 过滤器链检查 → 身份落定 → 放行到 Controller”这条主线,遇到问题再往细节里钻,效率远高于一开始就死磕源码。
另外,排查安全问题时要保持一个习惯:先确认请求走的是哪条 SecurityFilterChain、哪个过滤器在拦截。Spring Security 的日志里其实会输出过滤器链的执行顺序和匹配结果,启动时把日志级别调到 DEBUG,很多时候报错的原因在日志里已经写得明明白白。遇到 403 和奇怪的重定向,先看 DEBUG 日志,再去看代码,这是我最常用的排查套路。
如果你打算在真实项目里落地,建议从最简单的场景开始:先配好内存版的用户和角色,把登录流程跑通,再加数据库和 JWT,最后再考虑复杂的 OAuth2 分布式授权。一步到位反而容易失控。希望这篇文章能帮你把 Spring Security 的骨架搭起来,后面再遇到具体问题,欢迎带着场景和日志来聊。