不夸张地讲,Spring Security 是 Java 生态里最让人头疼、但又最值得花时间吃透的框架之一。我在做一个前后端分离项目时,技术栈选了 SpringBoot + Vue,安全这块要求必须支持 Token 登录、接口权限控制、返回统一 JSON,最终用 Spring Security 全部实现。当时网上翻了一堆教程,要么是老的WebSecurityConfigurerAdapter写法,放到 Spring Security 6.x 上直接编译报错,要么只讲单体应用里的 Session 登录,和前后端分离场景完全对不上号。这篇文章把我完整的接入过程写出来,从 SpringBoot 引入依赖、用户体系搭建、JWT 登录认证、自定义过滤器、统一 JSON 响应,到方法级权限控制、CSRF / CORS 处理,全部基于 Spring Security 6.x 的新写法,让你能直接照着落地。适合刚开始接触 Spring Security 的开发者,也适合被版本差异折磨过的同学。
1. 先想明白:前后端分离的登录认证,到底和传统 Session 差在哪
1.1 传统 Session 认证为什么撑不住前后端分离
很多老项目的安全认证走的是 Session + Cookie 这套玩法:用户登录成功后,服务端把用户状态存在 Session 里,然后给浏览器返回一个 JSESSIONID 写进 Cookie。后面每次请求,浏览器自动把 Cookie 带上去,服务端拿着 JSESSIONID 查 Session,能查到就说明"你已登录"。
这套机制在单体应用、前后端不分离的时代完全够用,但放到前后端分离的项目里就开始别扭了。核心原因有两个。
第一,前端可能是 Vue、React 或者小程序,HTTP 客户端不再像浏览器那样自动管理 Cookie。原生小程序里你得手动处理 Cookie,跨域的 axios 请求也得额外配置withCredentials,而且现在很多项目前后端是不同域名甚至不同端口,跨域场景下 Cookie 的SameSite策略会带来各种隐性拦截。与其强行把 Session 塞进现代前端架构里,不如直接换成"无状态"的 Token 方案。
第二,Session 是有状态的。集群部署或者微服务架构下,用户的登录状态存在某一台服务器的内存里,下次请求被负载均衡转发到另一台,Session 就丢了。虽然可以引入 Redis 统一存储 Session,但你又得多维护一套会话存储的中间件。而 JWT 这类 Token 方案天然无状态,服务端不保存用户会话,用户信息全部编码在令牌里,任何实例拿到合法 Token 都能完成认证,这才是前后端分离项目该有的样子。
1.2 选 Token 还是选 Session:技术选型的底层逻辑
看到这里你可能会问:是不是所有前后端分离项目都该无脑上 JWT?
我个人的答案是没有银弹。如果项目对安全性要求极高、需要服务端随时吊销某个用户的所有会话,那 Session + Redis 依然是靠谱的选择。但如果你做的是一个普通的业务系统、管理后台,或者接口需要被多端调用,JWT 能帮你省掉一堆会话同步的麻烦。
JWT 的结构本身就是三段式:Header、Payload、Signature。Header 里放签名算法,Payload 里放用户名、过期时间等声明信息,Signature 用服务端密钥生成。正因为签名存在,客户端拿到 Token 后并不能篡改里面的内容——一改签名立刻校验失败。
常见的误区是以为 JWT 是加密的。其实 Payload 只是 Base64 编码,别人拿到 Token 是可以直接解出里面的字段的。所以敏感信息一定不要放进去,比如密码、手机号这些,真要放也要自己额外加密。我的习惯是只在 Token 里放用户名和过期时间,其余用户信息每次从数据库查,或者放进 Redis 缓存,这样既保证了无状态,又不至于把鸡毛蒜皮的东西全塞进令牌里。
安全这块,Spring Security 本来就提供了非常完整的过滤器链,我们只是把"怎么判断一个请求是否已登录"从 Session 替换成了 JWT 而已。
2. 把 Spring Security 接进 SpringBoot 项目:依赖、配置、用户体系搭建
2.1 版本选择与 Maven 依赖
先说版本,这可以说是第一个大坑。现在主流的组合是 SpringBoot 3.x + JDK 17 + Spring Security 6.x。如果你还在用 SpringBoot 2.x,很多写法还能用旧的WebSecurityConfigurerAdapter,但升级到 3.x 之后这个类直接被移除了,网上大量老教程统统失效。
我的项目用的是 SpringBoot 3.2.x,Spring Security 6.2.x,JDK 17。如果不想被版本折磨,核心思路是:SpringBoot 3.x 就一定按 Spring Security 6 的写法来,不要再纠结"为什么不继承那个适配器类"。
Maven 依赖这样加:
<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> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>JWT 这块我用的是io.jsonwebtoken的 jjwt 库,0.11.5 稳定版。注意jjwt-impl和jjwt-jackson的 scope 设置为 runtime,这样编译期不需要、运行期自动加载,也符合官方推荐。
再准备一个application.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/security_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jwt: secret: "YTJiM2M0ZDVlNmY3ZzhoOWlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODk=" expiration: 86400000jwt.secret我建议用一个 Base64 编码后的 256 位密钥。因为 jjwt 库要求 HMAC 签名密钥至少 256 位,你如果直接写个短字符串是会启动报错的。生成方式很简单,随便找一个 Base64 编码工具,把一段随机字符串编码一下即可。
2.2 最小可运行的安全配置
Spring Security 引入后,默认它会拦截所有请求,并且自动生成一个随机密码用于表单登录。你如果不写任何配置,启动项目后访问任意接口,会跳到一个默认登录页。在前后端分离项目里这种行为肯定不能忍,必须自己写配置。
Spring Security 6 推荐用SecurityFilterChain的 Bean 来定义规则:
package com.example.securitydemo.config; import com.example.securitydemo.security.JwtAuthenticationFilter; import jakarta.annotation.Resource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity public class SecurityConfig { @Resource private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(Customizer.withDefaults()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/register").permitAll() .requestMatchers("/doc.html", "/webjars/**", "/v3/api-docs/**", "/swagger-ui/**", "/favicon.ico").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或Token已过期\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write("{\"code\":403,\"msg\":\"没有权限访问\"}"); }) ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }几个关键点我展开说明一下。
csrf(csrf -> csrf.disable()),不是让你放松警惕,而是前后端分离项目里没有 Session / Cookie 这套状态机制,CSRF 攻击依赖"浏览器自动带上 Cookie"这个前提。我们用 JWT,Token 是放在 Authorization Header 里的,CSRF 已经没有意义,所以关闭。
sessionManagement设为STATELESS,意思是让 Spring Security 彻底不管 Session,每个请求都独立验证,这也是 Token 方案的核心。
配置里那个requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()非常关键。前后端分离项目跨域时,前端很多请求会先发一个 OPTIONS 预检请求,这个请求没有 Authorization Header,如果不放行,预检直接被安全框架拦掉,现实效果就是你前端怎么调接口都是 401。
另外,addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)是把我们后面要写的 JWT 过滤器插入到 Spring Security 过滤器链的指定位置。UsernamePasswordAuthenticationFilter 是表单登录的地方,我们的 JWT 过滤器放在它之前,意思就是"先看 Token 认不认,认完再走后面的授权判断"。
2.3 用户表设计、UserDetailsService 与密码加密
Spring Security 里有个核心接口叫UserDetailsService,它就是用来"根据用户名把用户信息查出来"的。只要把这个接口的实现注册成 Bean,Spring Security 在认证时就会自动调它。这个接口的结构非常适合已有的业务系统:你完全不用改自己的用户表结构,只要写一个适配层把数据库里的用户转成框架认识的UserDetails即可。
我这里的用户表很简单,就三张核心表:用户表、角色表、用户角色关联表。权限就粗粒度地挂在角色上。把你自己的表结构和下面这个适配逻辑对上就行。
先写一个UserDetailsServiceImpl:
package com.example.securitydemo.service.impl; import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.example.securitydemo.entity.SysUser; import com.example.securitydemo.mapper.SysUserMapper; import jakarta.annotation.Resource; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; @Service public class UserDetailsServiceImpl implements UserDetailsService { @Resource private SysUserMapper sysUserMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser = sysUserMapper.selectByUsername(username); if (sysUser == null) { throw new UsernameNotFoundException("用户不存在"); } List<String> roles = sysUserMapper.selectRoleCodesByUserId(sysUser.getId()); List<SimpleGrantedAuthority> authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); return new User( sysUser.getUsername(), sysUser.getPassword(), authorities ); } }注意我在角色编码前拼接了ROLE_前缀。这是 Spring Security 的约定:用hasRole("ADMIN")判断时,框架会自动在前面拼ROLE_,所以数据库里角色编码只存ADMIN就行。
密码加密毫无疑问用BCryptPasswordEncoder。BCrypt 是自适应 Hash 算法,每次加密同一个密码得到的哈希串都不一样,但校验时能匹配成功。这种方式能有效对抗彩虹表攻击。数据库里存的用户密码,应该是前端明文密码经过passwordEncoder.encode(...)之后的结果。你千万不要为了一时方便存明文,我见过太多练手项目这么干,上线被脱库就是分分钟的事。
3. 核心逻辑实现:JWT 登录、过滤器串联与统一 JSON 响应
3.1 JWT 令牌工具类
JWT 这块我封装了一个工具类,负责生成 Token、解析 Token、校验 Token 三个功能。
package com.example.securitydemo.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Base64; import java.util.Date; @Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; private SecretKey getSigningKey() { byte[] keyBytes = Base64.getDecoder().decode(secret); return Keys.hmacShaKeyFor(keyBytes); } public String generateToken(String username) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return Jwts.builder() .setSubject(username) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public String getUsernameFromToken(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); return claims.getSubject(); } public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }这段代码有三个地方值得注意。
生成 Token 时我用setSubject(username)把用户名放进了 JWT 的 subject 字段,这是标准做法。setExpiration设置过期时间,我从配置文件读的86400000是毫秒,也就是 24 小时。
校验时如果 Token 过期或者签名不对,会抛出JwtException,只要捕获到就返回 false。IllegalArgumentException处理的是 Token 为 null 或格式完全不合法的情况。
Base64.getDecoder().decode(secret)是配合我在 yml 里配置的 Base64 密钥来的。这个设计是为了避免直接把原始字符串丢给Keys.hmacShaKeyFor时因为长度不够而启动报错。你换同样长度的密钥时,业务代码完全不用改。
3.2 登录接口实现
登录接口的作用是接收用户名和密码,交给 Spring Security 完成认证,成功后返回 Token。它的标准写法如下:
package com.example.securitydemo.controller; import com.example.securitydemo.common.Result; import com.example.securitydemo.dto.LoginRequest; import com.example.securitydemo.security.JwtUtil; import jakarta.annotation.Resource; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/auth") public class AuthController { @Resource private AuthenticationManager authenticationManager; @Resource private JwtUtil jwtUtil; @PostMapping("/login") public Result login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); SecurityContextHolder.getContext().setAuthentication(authentication); String token = jwtUtil.generateToken(request.getUsername()); return Result.success(token); } }你可能好奇AuthenticationManager是哪来的。我们之前在 SecurityConfig 里只定义了PasswordEncoder和SecurityFilterChain,并没有显式给AuthenticationManager注册 Bean。别急,Spring Security 内部会构建一个AuthenticationManager,但如果你要在 Controller 里注入它,需要在配置类里手动暴露一下:
@Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }authenticationManager.authenticate(...)这一行是整个登录流程的发动机。框架会自动调用我们写的UserDetailsServiceImpl去查用户,然后用PasswordEncoder比对密码。比不过就抛BadCredentialsException,比得过就返回一个认证成功的Authentication对象。拿到认证结果后,我用SecurityContextHolder把认证信息放进线程上下文,这样当前线程后面再取getAuthentication()就能拿到登录用户。
如果是注册接口,别忘了用passwordEncoder.encode(明文密码)存库。一个很常见的低级错误是注册时直接存明文,或者把encode之后的密码再encode一遍存进去。只加密一次,不要加二次。
3.3 自定义 JWT 过滤器
登录接口做完,接下来就是"让服务器认识Token"这一步了。Spring Security 的过滤器链在每进来一个请求时,会依次执行过滤器。我们写一个过滤器,在授权判断之前,先检查请求头里的Authorization是否带了合法的 Bearer Token,如果合法就主动把认证信息塞给 SecurityContext。
package com.example.securitydemo.security; import com.example.securitydemo.service.impl.UserDetailsServiceImpl; import jakarta.annotation.Resource; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Resource private JwtUtil jwtUtil; @Resource private UserDetailsServiceImpl userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); if (jwtUtil.validateToken(token)) { String username = jwtUtil.getUsernameFromToken(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (userDetails != null && SecurityContextHolder.getContext().getAuthentication() == null) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } filterChain.doFilter(request, response); } }这里有几个容易踩坑的细节。
OncePerRequestFilter是 Spring 提供的基类,保证一个请求只执行一次过滤器。如果只实现Filter,可能在转发场景下被多次执行,没必要。
authHeader.substring(7)是因为"Bearer ".length()刚好等于 7。很多人写成substring(6),下标越界不说,你怎么调都不对。
Token 校验通过后,我去loadUserByUsername重新查了一次数据库。这一步看着多余,其实是为了拿最新的角色权限信息。如果只从 Token 里拿角色,用户角色中途被改了也不会生效,得等 Token 过期。对安全敏感的系统,这个"每次请求查一次权限"的成本是值得的。
把authentication.setDetails(...)那行去掉也不会影响功能,但它能让后续日志和审计里带上请求来源信息,建议保留。
最后,如果 Token 无效或者没有带 Token,这个过滤器什么都不做,直接放行。放行后 Spring Security 后续的anyRequest().authenticated()会发现 SecurityContext 里没有认证信息,于是触发我们之前配置的 401 处理器。这个设计也是对的:过滤器只负责"识别并填充身份",真正拦截的活交给授权规则。
3.4 认证失败与权限不足的处理
在前后端分离项目里,最烦的事情之一就是安全框架拦截之后返回一坨框架自带的 HTML 错误页面。比如 403 Forbidden 页面、或者跳转到/login。我们要做的是在exceptionHandling里把 401(未认证)和 403(无权限)分别替换成统一的 JSON。
在 SecurityConfig 里,我其实已经写了这两段 Lambda 表达式:
.authenticationEntryPoint((request, response, authException) -> { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或Token已过期\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write("{\"code\":403,\"msg\":\"没有权限访问\"}"); })实际项目中,我会把这两段再提取成一个单独的类,比如RestAuthenticationEntryPoint implements AuthenticationEntryPoint,然后放到 IoC 容器里,让配置类更干净。但是如果你项目就几十个接口,写在配置类里也没毛病,少一点文件和类反而更好维护。
一个重要的点是区分 401 和 403 的应用场景。401 代表"没有认证信息或认证失败",用户应该去登录;403 代表"已经登录了但你没有权限看这个接口",用户可能是个普通用户点了一个管理员按钮。前端拿到 401 要做的是跳转登录页,拿到 403 要做的是弹窗提示"没有权限"。这两个状态码你要是在后端混着返回,前端就要跟着猜,非常坑。
4. 权限控制实战:方法级注解、RBAC 与前端对接细节
4.1 开启方法级安全注解
如果只在过滤器链里做粗粒度的 URL 拦截,权限粒度永远到不了"方法"这一层。管理后台里最常见的就是:同一个 Controller,普通用户能查列表,管理员才能删除。Spring Security 的方法级安全注解对这种需求几乎是零成本的。
先在配置类上加一个注解:
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { // ... }@EnableMethodSecurity是 Spring Security 6 中的新注解,替代了老版本里的@EnableGlobalMethodSecurity。
然后就可以在方法上直接声明权限规则:
@RestController @RequestMapping("/api/user") public class UserController { @GetMapping("/list") @PreAuthorize("hasRole('ADMIN')") public Result list() { // 只有 ADMIN 角色能访问 return Result.success(); } @PostMapping("/add") @PreAuthorize("hasAuthority('sys:user:add')") public Result add(@RequestBody SysUser user) { // 只有拥有 sys:user:add 权限的能访问 return Result.success(); } }hasRole('ADMIN')和hasAuthority('sys:user:add')是两条不同的判断路径。hasRole会自动在前面补ROLE_前缀,和我们UserDetailsServiceImpl里拼ROLE_是配套的;hasAuthority则是完全精确匹配字符串,适合细粒度权限码。
项目刚起步的时候,我建议直接用hasRole粗粒度搞定。等系统真的复杂到需要"同一个角色在不同业务模块里有不同权限"时,再迁移到权限码体系。
4.2 基于角色和权限的 RBAC 模型
很多新手会有一个疑问:角色和权限到底是不是同一个东西?
我的经验是:角色是权限的集合,权限是最小的操作单元。比如"运营专员"这个角色,可以拥有sys:order:view和sys:order:export两个权限;"运营主管"除了以上两个,还有sys:order:delete。用户表、角色表、菜单/权限表、两张关联表,这就是最经典的 RBAC 模型。
落到数据库设计上,我常用的核心表结构是:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| sys_user | 用户表 | id, username, password, status |
| sys_role | 角色表 | id, role_code, role_name |
| sys_user_role | 用户角色关联表 | user_id, role_id |
| sys_menu | 菜单权限表 | id, perm_code, menu_name, parent_id |
| sys_role_menu | 角色菜单关联表 | role_id, menu_id |
我在这套结构下扩展了SysUserMapper,让它能查出某个用户的所有角色编码:
<select id="selectRoleCodesByUserId" resultType="java.lang.String"> SELECT r.role_code FROM sys_user_role ur INNER JOIN sys_role r ON ur.role_id = r.id WHERE ur.user_id = #{userId} </select>这样写之后,UserDetailsServiceImpl里拼ROLE_前缀的roles就有了数据来源。
如果你还想做到"同一个 URL,根据用户的权限码决定是否放行",那就需要自定义AuthorizationManager或者用requestMatchers动态判断。但我强烈建议你先从方法级注解入手,不要一上来就搞动态鉴权,复杂度完全不在一个量级。等业务确实需要了,再去研究FilterInvocationAuthorizationManager也不迟。
4.3 前端 Vue 对接时的几个关键细节
安全框架在后端做得再好,前端对接时稍不注意,整个链路还是跑不通。我自己在前端踩过几个坑,特意记一下。
登录时,Vue 的 axios 请求要把 Token 存到localStorage或者 Pinia 的 store 里,然后每个请求都要在请求拦截器里加上 Authorization 头。这段代码几乎是标配:
// axios 请求拦截器 service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config })注意我在后端过滤器里写的是authHeader.startsWith("Bearer "),所以前端拼的这个Bearer前缀必须原样带上,大小写、空格都不能错。
响应拦截器要统一处理 401:
service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )一个容易被忽视的问题是:Token 过期之后,前端可能同时发出好几个请求,每个都返回 401,结果页面疯狂跳登录。比较稳妥的方案是引入一个"是否正在刷新 Token / 是否已经跳转登录"的开关变量,保证同一时间只处理一次。如果项目没有做"刷新 Token"的功能,最简单的办法是在 401 拦截里加一个布尔锁,弹出一次登录跳转即可。
5. 踩坑实录:CSRF、CORS、版本差异与 401/403 排查
5.1 CSRF 关闭与 CORS 配置
每次讲 Spring Security,总会有人问:为什么要把 CSRF 关掉?前后端分离项目关了真的安全吗?
CSRF 攻击的本质是:用户已经在浏览器里登录了网站 A,此时浏览器持有 A 的 Cookie。当用户访问恶意网站 B 时,B 偷偷发起一个对 A 的请求,浏览器会自动带上 A 的 Cookie,于是攻击请求就被服务端误认为是用户本人发出的。
但我们的系统用 JWT,Token 放在 Authorization Header 里,浏览器并不会自动在跨站请求时带上这个 Header。所以 CSRF 就失去了"浏览器自动携带凭证"的前提,关掉它是合理的。
为什么很多人还会在前后端分离项目里被 CSRF 坑?多半是项目里同时也用了一点 Cookie 做其他用途,比如记住登录状态、或者把 Token 也塞进 Cookie。只要 Token 在 Cookie 里,CSRF 风险就又回来了,这时候你就得保留 CSRF 防护或者把 Token 从 Cookie 里取出来放 Header。
再说 CORS,这也是前后端分离的必经之路。我在 SecurityConfig 里写了.cors(Customizer.withDefaults()),然后还需要提供一个CorsConfigurationSourceBean:
package com.example.securitydemo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration public class CorsConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }这里有个容易报错的组合:addAllowedOrigin("*")和setAllowCredentials(true)不能同时使用。*表示任意来源,但如果又允许携带 Cookie,浏览器出于安全策略会直接拒绝。所以我用的是addAllowedOriginPattern("*"),它允许搭配allowCredentials=true。
如果你的前端地址是确定的,比如http://localhost:5173,更推荐明确写死来源,不要用通配符。线上环境尤其如此,通配符 + 允许凭证的组合范围太大了。
5.2 Spring Security 5 到 6 的写法迁移
我见过太多人把 5.x 的代码直接复制到 6.x 项目里,然后一脸懵地看着报错。核心差异其实就几条,搞清楚之后迁移很快。
WebSecurityConfigurerAdapter已经无了。Spring Security 6 里你必须用SecurityFilterChainBean 的方式来配置,这也是我整篇文章采用的写法。那行经典的:
public class SecurityConfig extends WebSecurityConfigurerAdapter在新版本里直接报"类已被移除",别再用了。
antMatchers改成了requestMatchers。在新版里authorizeHttpRequests之后的.antMatchers("/api/**")根本编译不过。这不仅仅是改名,更是因为新版废弃了 AntPathMatcher,内部改用了 PathPattern 解析器。
and()链式调用的风格也别再写了。Spring Security 6 推荐用 Lambda DSL,代码可读性更强,也能避免很多"链上对象类型推断不出来"的编译问题。
@EnableGlobalMethodSecurity改成@EnableMethodSecurity,这个我在 4.1 已经提到。
我整理了一个速查表:
| 功能 | Spring Security 5 旧写法 | Spring Security 6 新写法 |
|---|---|---|
| 配置方式 | 继承 WebSecurityConfigurerAdapter | 定义 SecurityFilterChain Bean |
| URL 匹配 | antMatchers("/api/**") | requestMatchers("/api/**") |
| 方法级安全 | @EnableGlobalMethodSecurity | @EnableMethodSecurity |
| 自定义登录页 | formLogin().loginPage(...) | 配置 AuthenticationEntryPoint 或 formLogin(...) |
| 请求鉴权 | authorizeRequests() | authorizeHttpRequests() |
这个问题没有绕过去的余地:你一旦用了 SpringBoot 3,就必须接受新写法。老教程可以当思路参考,但代码别直接抄。
5.3 排查问题的调试清单与经验
最后把我实际调试中遇到的高频问题整理成一个清单,你可以打印出来对照处理。
第一类:所有接口都返回 401。先确认登录接口本身是不是放行了。如果登录接口也被拦,那就是requestMatchers的路径写错了,比如路径是/api/auth/login,你的配置里写成了/api/auth/**那没问题,但如果你写的是/api/login就会拦。再检查前端请求是不是真的携带了 Authorization 头,有时候浏览器 Network 面板里能清楚地看到请求头根本没带。
第二类:接口返回 403 而不是 401。这种情况多半是已经登录了,但角色权限不满足。优先看日志,Spring Security 的授权异常会打印当前用户的权限列表。如果你发现hasRole('ADMIN')一直失败,检查一下数据库角色编码是不是没拼成ADMIN,以及UserDetailsServiceImpl里是不是漏了ROLE_前缀。
第三类:前端传了 Token 但后端识别不了。打开浏览器的 Network 面板,看请求头里 Authorization 的值是不是Bearer eyJhbGci...这种格式。很多人把substring(7)理解为"取第八个字符开始",但如果你前端发的就是Bearer加 Token,这个逻辑是对的。如果你后端收到了但 validate 失败,多半是密钥不匹配,也就是生成 Token 的jwt.secret和解析 Token 的jwt.secret不是同一个。注意 Spring Boot 配置文件里那个@Value注入值在测试环境或打包环境被覆盖的情况,排查时先打印一下JwtUtil里的 secret 值。
第四类:CORS 配置了但前端还是报跨域。先确认 Security 配置里真的调用了.cors(Customizer.withDefaults())。很多人只在 MVC 层配了跨域,忘了安全层。Spring Security 的过滤器链比 MVC 拦截器更靠前,安全层不放行跨域预检,MVC 层配置再全也没用。
第五类:明明没登录,后端却认为你登录了。如果你手动设置了SecurityContextHolder.getContext().setAuthentication(...),记得在请求结束时清理上下文。SecurityContextHolder默认是ThreadLocal策略,线程池复用时脏数据会串到下一个请求里。虽然在 Spring Security 的过滤器链里它有自动清理机制,但如果你自己在业务代码里手动塞认证信息,一定要确认是否处于过滤器链管理范围内。
调试的时候有一个最笨但最有效的办法:在JwtAuthenticationFilter开头打一行日志,打印请求 URI 和 Authorization 头;在授权判断后打一行日志,打印当前认证对象的类名和权限列表。一个请求从走进来到走出过滤器链,每个环节的状态都被日志记录下来,问题基本一眼就能定位。我踩过滤掉器顺序的坑,就是在日志里发现自定义过滤器压根没执行,才想起来忘记在SecurityConfig里调addFilterBefore。
我的个人体会是,Spring Security 就像一个管道的管理者,你要理解的不是某一段代码,而是整个请求在管道里经过了哪些关卡、每一关负责什么、为什么顺序不能乱。搞懂了过滤器链,前面那些版本差异、CSRF、CORS 的问题其实都能迎刃而解。按 WebSecurityConfigurerAdapter 老思路去套 Spring Security 6,只会越来越痛苦;按本文这套SecurityFilterChain + JWT过滤器 + 方法级注解的思路走,前期多花的几个小时,后面会帮你省下大把时间。