1. 为什么选 Spring Security + JWT 这套组合
先说结论:如果你正在做前后端分离项目,尤其是一个 SPA(单页应用),Spring Security + JWT 几乎是目前 Java 后端做权限认证最主流的标配方案。导航栏、页面按钮、接口数据,哪个用户能看、哪个用户能点、后端哪个接口允许谁调,这套组合都能管起来。
我见过不少团队在权限方案上反复横跳:开始用 Session,后端存登录状态,前端靠 Cookie 自动带过去,听起来简单,一旦上了集群或多实例部署,session 同步问题就冒出来了。后来有人换成 Redis 存 token,虽然解决了共享问题,但每次请求都要回查一次 Redis,多一次网络开销。再后来大家开始用 JWT,把用户信息和权限直接编码进 token 里,服务端不存状态,彻底无状态化,水平扩展的时候省心太多。
这套组合真正解决的三个核心问题:
- 身份认证:证明“你是谁”。登录时校验用户名密码,没问题就发一个代表身份的 JWT。
- 权限授权:证明“你能干什么”。JWT 里携带角色或权限标识,后端在接口入口做拦截校验。
- 接口安全:防止未授权访问。每次请求都带着 token,后端过滤器校验 token 的合法性和有效期,不合法直接 401,连 Controller 都进不去。
适合谁来参考?如果你满足下面任意一条,这篇文章就值得看完:
- 用 Spring Boot 做后端,前端是 Vue / React 这类 SPA,目前还在用 Session 方案,想切换成 JWT;
- 刚接触 Spring Security,被过滤器链、AuthenticationManager 这些概念绕晕了,需要一个能跑通的完整示例;
- 项目已经上了 JWT,但遇到 token 过期、用户信息变更后旧 token 不失效、403 拦不住人等实际问题,想找排查思路。
这篇文章我会按“原理 → 落地 → 踩坑”的顺序来写,不堆概念,直接给你能用的代码和配置,顺便把我在生产环境里踩过的坑一并交代清楚。
2. 动手之前:先弄懂 Spring Security 的过滤器链
很多新手一上来就撸代码,搞了半天 401 满天飞,连“到底哪一步拦截了我”都没搞明白。原因很简单:没理解 Spring Security 的工作模型。
2.1 一条请求进来会经过什么
Spring Security 的核心不是拦截器,不是 AOP,是一组有序的过滤器,统称“过滤器链”。一次 HTTP 请求从进来到进入你的 Controller,在安全层面会依次经过这些过滤器,每一步都有机会放行、中断、改头换面。
一条最简化的链路大致是:
- 请求进入,先经过
SecurityContextHolder相关过滤器,把当前登录用户信息塞进上下文; UsernamePasswordAuthenticationFilter(如果你用表单登录)负责拦截用户名密码,生成认证请求交给AuthenticationManager处理;AuthenticationManager找UserDetailsService查用户,再用PasswordEncoder比对密码,比对成功生成一个完整Authentication对象;ExceptionTranslationFilter负责把安全异常转成 HTTP 响应,比如未登录返回 401,没权限返回 403;- 最后是
AuthorizationFilter(旧版本叫FilterSecurityInterceptor),做 URL 级别的权限判断。
看到关键点了没?JWT 方案本质上就是往这条过滤器链里插入一个“自定义过滤器”,替换掉默认的表单登录过滤器。这个自定义过滤器负责:解析请求头里的Authorization: Bearer <token>,验证 token,把解析出来的用户信息放入SecurityContext,然后放行请求。
提示:理解这一点,后面所有配置你都能串起来。很多配置报错排不出来,就是因为只盯着某个类看,没从过滤器链的视角去分析。
2.2 JWT 为什么能做到无状态
JWT 全称是 JSON Web Token,本质是一个经过签名后的 JSON 字符串,一般长这样:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjoiUk9MRV9BRE1JTixST0xFX1VTRVIifQ.8JfTqY8HcnYXj9F4L6lkxXnY8kSvVzZvE_j3OTaBkR0它由三部分组成,用点号分隔:
- Header:声明签名算法(比如 HS256),Base64 编码;
- Payload:存放声明信息,比如用户 ID、用户名、角色列表、过期时间
exp,Base64 编码; - Signature:对前两段做签名,密钥只有服务器知道,用来防止内容被篡改。
需要注意,JWT 的 Payload 只是 Base64 编码,不是加密。用工具随时可以解出来看到内容。所以千万不要把密码、手机号、身份证这类敏感信息放进 token,这是新手最容易踩的坑。
为什么无状态?因为服务器收到 token 后,只靠“验签”就能确认内容没有被篡改,同时从 Payload 里直接读出身份和权限,不需要去数据库或 Redis 里查。每个请求是独立的,服务器不记录这个用户是否在线。
2.3 无状态带来的两个“副作用”
无状态不是没有代价的,两个典型问题必须接受:
第一,token 无法主动失效。Session 想踢人下线,删掉服务端 session 就行。JWT 只要没到过期时间,服务端手里没有“吊销名单”的话,就只能眼睁睁看着它继续有效。生产环境一般引入 Redis 做黑名单,登出时把 token 的 jti 或用户 ID 存进 Redis,过滤器里先查一下黑名单再放行。
第二,token 一旦签发,权限变更无法立刻生效。用户角色改了,旧 token 里的角色信息还是旧的,必须等 token 过期才能生效。所以“用户信息更新后 token 要不要重新签发、旧 token 如何作废”是 JWT 项目里绕不开的话题,我在本文第 6 部分专门讲这个问题。
3. 项目落地:从零搭建一套可运行的认证授权
下面开始写代码。我以 Spring Boot 3.x + Spring Security 6.x 为例(老项目用 Spring Boot 2.x 的注意版本差异,后续配置类写法略有不同),数据库用 MySQL + MyBatis-Plus,前端先不管,重点讲后端。
3.1 初始化项目与依赖
用 Spring Initializr 创建项目,依赖加上这几样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>JWT 库我用的是 Auth0 的 java-jwt,个人觉得 API 比 jjwt 更直观。你要是习惯 jjwt 也没问题,思路完全一样。
3.2 数据库设计与用户实体
RBAC(基于角色的访问控制)模型通用做法是三张核心表加两张关联表。我通常这样设计:
sys_user:用户表,字段id、username、password(BCrypt 加密结果)、status、create_time;sys_role:角色表,字段id、role_code(例如ADMIN)、role_name;sys_menu:菜单/权限表,字段id、perm_code(例如system:user:add)、menu_name;sys_user_role:用户角色关联表;sys_role_menu:角色权限关联表。
实体类不多写了,无非是常规 MyBatis-Plus 注解的 POJO。重点说一下UserDetails的实现:
public class LoginUser implements UserDetails { private Long userId; private String username; private String password; private List<String> authorities; // 权限标识集合 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities.stream().map(SimpleGrantedAuthority::new).toList(); } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }这里有个细节:authorities字段里我会同时装角色和权限两种信息,角色用ROLE_ADMIN这种前缀,权限用system:user:add这种字符串,后续用注解控制接口权限时一个 Spring Security 自带的能力就够用了。
3.3 实现 UserDetailsService
Spring Security 的AuthenticationManager在认证时要用UserDetailsService来加载用户信息。核心代码如下:
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Resource private SysUserMapper userMapper; @Resource private SysRoleMapper roleMapper; @Resource private SysMenuMapper menuMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userMapper.selectOne( new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username)); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } List<String> authorities = new ArrayList<>(); // 查角色 List<String> roles = roleMapper.selectRoleCodesByUserId(user.getId()); roles.forEach(role -> authorities.add("ROLE_" + role)); // 查权限标识 List<String> perms = menuMapper.selectPermsByUserId(user.getId()); authorities.addAll(perms); LoginUser loginUser = new LoginUser(); loginUser.setUserId(user.getId()); loginUser.setUsername(user.getUsername()); loginUser.setPassword(user.getPassword()); loginUser.setAuthorities(authorities); return loginUser; } }查询用户时顺手把角色和权限都查出来放在authorities里,这样在 JWT 生成阶段可以直接把权限编码进去,后面接口鉴权时不需要再查库。
3.4 JWT 工具类:生成、解析、校验
这是全项目的核心工具,我封装成了一个静态类:
@Component public class JwtUtil { // 注意:生产环境从配置中心读取,不要硬编码 @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; // 单位:毫秒 @Value("${jwt.header}") private String header; @Value("${jwt.tokenPrefix}") private String tokenPrefix; // 生成 token:把用户ID、用户名、权限列表编码进去 public String createToken(Long userId, String username, List<String> authorities) { Algorithm algorithm = Algorithm.HMAC256(secret); Date now = new Date(); Date expireDate = new Date(now.getTime() + expire); return JWT.create() .withClaim("userId", userId) .withClaim("userName", username) .withClaim("authorities", authorities) .withExpiresAt(expireDate) .withIssuedAt(now) .sign(algorithm); } // 解析 token,得到 DecodedJWT public DecodedJWT parseToken(String token) { Algorithm algorithm = Algorithm.HMAC256(secret); return JWT.require(algorithm).build().verify(token); } public Long getUserId(String token) { return parseToken(token).getClaim("userId").asLong(); } public List<String> getAuthorities(String token) { return parseToken(token).getClaim("authorities").asList(String.class); } public boolean isTokenExpired(String token) { try { DecodedJWT jwt = parseToken(token); return jwt.getExpiresAt().before(new Date()); } catch (TokenExpiredException e) { return true; } catch (JWTVerificationException e) { return true; // 签名不对、被篡改都视为无效 } } }生成 token 的原则是:只放不敏感、且后续鉴权真正用得到的信息。userId 后续可以用来查询用户当前最新权限;用户名可以方便日志排查;权限列表直接决定了这个 token 能访问哪些接口。放入前建议排序,避免权限顺序不同导致每次生成的 token 都不一致。
3.5 认证过滤器:把 token 变成“已登录状态”
这是 JWT 接入 Spring Security 最关键的一个类。
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Resource private JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader = request.getHeader(jwtUtil.getHeader()); if (authHeader == null || !authHeader.startsWith(jwtUtil.getTokenPrefix())) { // 没有 token 或格式不对,直接放行,由后面的 URL 级鉴权决定是否拦截 chain.doFilter(request, response); return; } String token = authHeader.replace(jwtUtil.getTokenPrefix(), ""); try { DecodedJWT decodedJWT = jwtUtil.parseToken(token); Long userId = decodedJWT.getClaim("userId").asLong(); String userName = decodedJWT.getClaim("userName").asString(); List<String> authorities = decodedJWT.getClaim("authorities").asList(String.class); // 检查 Redis 黑名单,判断 token 是否已被强制下线 // if (redisTemplate.hasKey("logout:" + userId)) { // throw new RuntimeException("token 已失效"); // } // 构造成 Authentication 对象放进 SecurityContext UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( userName, null, authorities.stream().map(SimpleGrantedAuthority::new).toList()); authenticationToken.setDetails(userId); SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 如果需要主动续签,这里还可以判断剩余时间,参见第 6.2 节 } catch (JWTVerificationException e) { // token 非法:过期、篡改、签名错误 // 注意:这里不直接抛异常,而是清空上下文,让后续逻辑统一处理 SecurityContextHolder.clearContext(); } chain.doFilter(request, response); } }有两点必须说明:
- 过滤器里只做“解析、验签、放身份”三个动作,不做 URL 权限判断。URL 权限判断交给 Spring Security 的授权过滤器,职责单一,代码才清晰。
- 一旦 token 校验失败,把
SecurityContext清空,后续AuthorizationFilter发现有请求需要认证时就会返回 401,范式统一,不用在各种异常里手工写响应。
3.6 配置 Security 过滤链,放行登录接口
配置类是整个方案的“总开关”。Spring Boot 3.x + Security 6.x 推荐用 SecurityFilterChain 的 Bean 方式配置:
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Resource private JwtAuthenticationFilter jwtAuthenticationFilter; @Resource private UserDetailsService userDetailsService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/auth/captcha").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated()) .exceptionHandling(handler -> handler .authenticationEntryPoint((request, response, ex) -> { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或token已失效\"}"); }) .accessDeniedHandler((request, response, ex) -> { response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"msg\":\"没有访问权限\"}"); })) .authenticationManager(authenticationManager(null)) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }关键动作逐个解释:
csrf关闭:JWT 方案 token 存在请求头里,没有 Cookie,CSRF 攻击的前提不存在,关掉省事;sessionCreationPolicy(STATELESS):告诉 Spring Security 别创建 Session,我们不需要服务端状态;authenticationEntryPoint和accessDeniedHandler:分别处理 401 和 403,返回统一 JSON 格式,前端好解析;addFilterBefore:把 JWT 过滤器插到默认认证过滤器之前,确保请求进来先走 token 校验。
配置完成,一个最简可用的 JWT 认证链路已经通了。
4. 登录接口:从密码校验到签发 token
4.1 登录接口的完整流程
登录接口是所有流程的起点。来看看一个实际生产可用的登录逻辑:
@RestController @RequestMapping("/api/auth") public class AuthController { @Resource private AuthenticationManager authenticationManager; @Resource private JwtUtil jwtUtil; @Resource private SysUserMapper userMapper; @PostMapping("/login") public R<LoginResponse> login(@RequestBody LoginRequest request) { // 1. 先做验证码校验(可选,推荐生产环境开启) // 2. 调用 AuthenticationManager 认证 Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); // 3. 认证成功后拿到 LoginUser LoginUser loginUser = (LoginUser) authentication.getPrincipal(); // 4. 生成 token String token = jwtUtil.createToken( loginUser.getUserId(), loginUser.getUsername(), loginUser.getAuthorities()); // 5. 组装返回,前端保存 token,后续请求放入请求头 return R.ok(new LoginResponse(token, jwtUtil.getExpire())); } }这里有个细节值得注意:authenticationManager.authenticate()内部会自动调用UserDetailsService加载用户,用PasswordEncoder比对密码。密码错误时抛BadCredentialsException,用户不存在抛UsernameNotFoundException。实测中这两个异常需要统一捕获,不要直接让 Spring 默认的 500 错误抛给前端。
4.2 验证码加一道保险
热词里提到了“SPA 项目开发之 jwt 验证码实现”,我的建议是登录接口一定要上验证码。别把验证码存 Session,要用 Redis,key 可以用一个随机 UUID,返回给前端时带上这个 key,登录时前端一并传回。
流程是:
- 生成图片验证码,存入 Redis,key 为
captcha:<uuid>,有效期 2 分钟; - 图片以 Base64 形式返回前端;
- 登录时提交
username + password + captchaKey + captchaCode; - 后端先比对验证码,成功后再走
AuthenticationManager认证。
验证码的作用不是防黑客,主要防两种场景:一是口令爆破,二是并发刷登录接口把验证码接口打爆。配上 Redis 限流更稳妥。
4.3 登录接口的返回结构
前端拿到 token 后一般保存到 localStorage 或 Vuex/Pinia。接口返回我习惯带expiresIn字段,前端可以提前判断要不要主动刷新 token:
{ "code": 200, "data": { "token": "eyJhbGciOiJIUzI1NiJ9...", "expiresIn": 7200000, "tokenType": "Bearer" } }注意:前端在封装 axios 时统一添加拦截器,请求发出前从存储里取 token,拼到
Authorization头里。响应拦截器遇到 401 时跳转登录页。这些前端细节虽然简单,但两边约定不统一,后端文档写得再清楚也白搭。
5. 权限控制:注解 + 动态放行
5.1 注解方式控制接口权限
实际项目里,URL 级别的粗粒度控制满足不了需求。比如“用户管理接口只有管理员能删,普通用户只能看”,这种就要用方法级别的注解。
前提是配置类里已经加了@EnableMethodSecurity(Spring Security 6.x),老版本是@EnableGlobalMethodSecurity(prePostEnabled = true)。然后直接在 Controller 或 Service 方法上标注:
@RestController @RequestMapping("/api/user") public class UserController { @PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/{id}") public R<Void> deleteUser(@PathVariable Long id) { // 只有 ADMIN 角色能进到这里 } @PreAuthorize("hasAuthority('system:user:add')") @PostMapping public R<Void> addUser(@RequestBody SysUser user) { // 需要 system:user:add 权限 } }hasRole会自动匹配ROLE_前缀,比如hasRole('ADMIN')对应的权限标识是ROLE_ADMIN。hasAuthority则是精确匹配字符串权限标识。两种都用,角色管粗粒度,权限标识管细粒度,分工明确。
5.2 动态菜单与按钮级别控制
接口鉴权搞定后,前端还要做菜单和按钮的权限控制。常规做法是登录后调一个/api/auth/me接口,返回当前用户的角色和权限列表,前端根据这些数据动态生成菜单、控制按钮显示。
后端实现如下:
@GetMapping("/me") public R<Map<String, Object>> me() { // 从 SecurityContext 拿当前登录用户 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); // 查权限列表,查菜单树 List<String> perms = loginUser.getAuthorities(); List<MenuVO> menus = menuMapper.selectMenusByUserId(loginUser.getUserId()); Map<String, Object> result = new HashMap<>(); result.put("username", loginUser.getUsername()); result.put("permissions", perms); result.put("menus", menus); return R.ok(result); }注意一个坑:登录时 JWT 里的权限列表是“静态快照”,如果用户在 token 有效期内改了权限,旧 token 里的权限不会自动更新。所以上面/me接口强烈建议实时查库,而不是从 token 里读。能容忍 5 分钟延迟的话,也可以把权限列表放入 Redis 缓存并设置短过期时间,让前端展示信息和接口鉴权之间保持基本一致。
5.3 权限变更后强制旧 token 失效
这应该是 JWT 项目避不开的进阶需求。效果是:管理员改了某个用户的角色,或者用户被停用了,这个用户手里的旧 token 立刻失效。
实现思路我推荐“版本号”方案,在用户表加一个字段auth_version(或叫token_version):
- 登录时,把
auth_version一并写入 JWT 的 claim; - 每次请求,JWT 过滤器解析出
authVersion后,从 Redis 或直接查库拿用户当前authVersion; - 两个版本号不一致,判定 token 无效,强制重新登录。
这个方案比“把所有旧 token 拉黑”高效得多,不需要额外存储,每次请求只有一次简单查询。如果每次查库觉得太重,可以将userId -> authVersion缓存在 Redis,用户角色变更时主动更新 Redis、数据库两个地方。
6. 常见问题与排查技巧实录
这个部分我把这几年在 JWT + Spring Security 项目里遇到的真实问题整理一下,按照出现频率排序。
6.1 首次配置后所有请求都 401,代码看起来没错
十有八九是配置问题。排查顺序建议这样来:
第一步,看请求头有没有带 token。前端漏了Authorization头是最常见的问题,可以通过浏览器开发者工具里的 Network 面板确认。
第二步,看 token 格式。Bearer前缀是否一致,比如后端要求Bearer后有个空格,前端拼错了就会解析失败。
第三步,看 JWT 过滤器是否真的注册进链路了。给过滤器doFilterInternal第一行加个日志,如果请求没进到这个方法,说明过滤器没被 Spring Security 加载。
第四步,看 Spring Security 的放行规则。如果登录接口没配permitAll(),登录自己也会被 401 拦死,形成“登录都要登录”的死循环。
6.2 token 快过期了要不要处理?怎么处理?
JWT 过期时间是签发时写死的,过期后只能重新登录。很多产品不接受“强制重新登录”,于是需要“续签”能力。
我用的方式是“滑动过期”+“自动续签”组合:
- JWT 的有效期设短一些,比如 30 分钟;
- Redis 里存一份
refresh:<userId>,过期时间设为 7 天,每次请求刷新这个过期时间; - 当 JWT 剩余有效期小于某个阈值(比如 10 分钟),且 Redis 里的 refresh key 还存在,就生成一个新的 token 放响应头里,前端拦截器发现新 token 就替换掉旧的。
JHipster 的做法是在响应头带一个X-Refresh-Token,前端 axios 拦截到后自动替换本地 token。这个小细节对用户体验提升非常明显,用户不会在凌晨刷页面时突然被踢出登录态。
6.3 用户禁用了,token 却还能访问接口
上面第 3.5 节重点场景。用户状态变更、角色变更、权限变更,这三类事件都需要“旧 token 失效”的处理。我的做法是统一封装一个TokenInvalidationService:
- 用户被禁用:更新
status字段,同时把该用户的auth_version加一,并清掉 Redis 中的 refresh key; - 用户角色调整:更新关联表,同时
auth_version加一; - 用户主动登出:把当前 token 的
jti存入 Redis,设置过期时间为 token 剩余有效时间。
JWT 过滤器里统一检查这三层:先看 token 是否过期,再看auth_version是否对得上,最后查 Redis 黑名单。有点牺牲一点点性能,但安全和体验都有保障。
6.4 签名密钥泄露,token 能被任意伪造
JWT 库默认支持 HS256 对称签名,密钥一旦泄露,攻击者就能用密钥自己签发“无限期有效”的 token,这是 JWT 漏洞总结里排行第一的安全事故。
关键防线:
- 密钥不要硬编码在项目里,放配置中心、环境变量或密钥管理系统,不同环境用不同密钥;
- 密钥长度至少 32 个字节,用足够随机的字符串,不要用
"secret"这类弱密钥; - 定期轮换密钥,并配合第 6.3 节的版本号方案,轮换时全量用户要重新登录;
- 条件允许的话,尽量用 RS256 非对称签名,私钥只放在服务端,公钥对外。
另外一个容易被忽略的点:JWT 库默认会接受算法为none的 token。生产环境务必确认库的版本和配置,Algorithm必须显式指定为 HMAC 或 RSA,不要用默认配置。这个漏洞原理是老生常谈,但仍然能看到线上项目中招。
6.5options预检请求被 401 拦截
前后端分离项目里,跨域请求会先发一个OPTIONS预检请求,这个请求不会带Authorization头。如果不放行,浏览器就会报跨域错误,前端还一脸懵。
配置里一定要加上:
.authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated())同时项目里要增加跨域配置。注意CorsFilter的加载顺序,如果出现跨域相关异常,优先排查 CORS 过滤器有没有被 Spring Security 链路正确加载。
7. 从单体到微服务:这套方案还能怎么延伸
如果你只是写一个单体应用,以上内容已经完全够用。但不少读者做的是微服务或者前后端完全分离的多端项目,这里聊点扩展思路。
微服务架构下,JWT 的价值最能体现。各服务共享同一个 JWT 校验逻辑,网关统一鉴权,下游服务只信任网关传来的userId、userName和权限列表,不需要每个服务都去UserDetailsService查一遍库。我见过一套简化方案是网关把解析出的用户信息放行到请求头,下游服务写一个轻量过滤器读取请求头,构建Authentication对象放入SecurityContext。
Spring Security OAuth2.1 也可以和 JWT 结合。热词里提到了“springsecurity oauth2.1”,如果你们的场景是第三方登录、多应用单点登录、开放平台 API,那就不是简单地在 spring security 过滤器链里加一个 JWT 过滤器能搞定的,需要引入spring-boot-starter-oauth2-resource-server,用JwtDecoder统一校验 JWT,这部分内容量很大,这里只提示选型方向。场景单纯适合自己扩展,场景复杂建议直接用 OAuth2.1 完整方案,别重复造轮子。
最后再分享两个小技巧
第一,关于日志。JWT 过滤器里只打印摘要信息,不要打印完整 token,token 属于敏感信息,打印到日志里就是安全隐患。我用的是这种格式:userId=123, token=xxx***xxx, remainingTime=15min,既方便排查又不会泄露。
第二,写单元测试时一定要把安全上下文清干净。SecurityContextHolder.getContext().setAuthentication()之后,测试用例之间会互相污染身份状态,务必在@AfterEach里执行SecurityContextHolder.clearContext()。别问我是怎么发现的——当年排查一个“测试 A 通过后测试 B 的权限不对”的诡异问题,查了一晚上,最后发现是测试之间上下文串了。
这套 Spring Security + JWT 方案,我已经在好几个生产项目里落地跑了两三年,稳定性和扩展性都经得住考验。按这篇文章的思路搭一套初始版本,再根据业务把验证码、续签、版本号这几个增强点补上,应付大多数中小系统的权限认证需求绰绰有余了。