news 2026/10/3 4:11:44

Spring Security + JWT 实战:从过滤器链到权限认证完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Security + JWT 实战:从过滤器链到权限认证完整指南

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,在安全层面会依次经过这些过滤器,每一步都有机会放行、中断、改头换面。

一条最简化的链路大致是:

  1. 请求进入,先经过SecurityContextHolder相关过滤器,把当前登录用户信息塞进上下文;
  2. UsernamePasswordAuthenticationFilter(如果你用表单登录)负责拦截用户名密码,生成认证请求交给AuthenticationManager处理;
  3. AuthenticationManager找UserDetailsService查用户,再用PasswordEncoder比对密码,比对成功生成一个完整Authentication对象;
  4. ExceptionTranslationFilter负责把安全异常转成 HTTP 响应,比如未登录返回 401,没权限返回 403;
  5. 最后是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,登录时前端一并传回。

流程是:

  1. 生成图片验证码,存入 Redis,key 为captcha:<uuid>,有效期 2 分钟;
  2. 图片以 Base64 形式返回前端;
  3. 登录时提交username + password + captchaKey + captchaCode;
  4. 后端先比对验证码,成功后再走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):

  1. 登录时,把auth_version一并写入 JWT 的 claim;
  2. 每次请求,JWT 过滤器解析出authVersion后,从 Redis 或直接查库拿用户当前authVersion;
  3. 两个版本号不一致,判定 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 漏洞总结里排行第一的安全事故。

关键防线:

  1. 密钥不要硬编码在项目里,放配置中心、环境变量或密钥管理系统,不同环境用不同密钥;
  2. 密钥长度至少 32 个字节,用足够随机的字符串,不要用"secret"这类弱密钥;
  3. 定期轮换密钥,并配合第 6.3 节的版本号方案,轮换时全量用户要重新登录;
  4. 条件允许的话,尽量用 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 方案,我已经在好几个生产项目里落地跑了两三年,稳定性和扩展性都经得住考验。按这篇文章的思路搭一套初始版本,再根据业务把验证码、续签、版本号这几个增强点补上,应付大多数中小系统的权限认证需求绰绰有余了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 4:11:44

Vue.js 服务器端渲染(SSR)实战:SEO、首屏优化与踩坑记录

一个项目做得好好的&#xff0c;突然客户提了一嘴“这页面首屏太慢了&#xff0c;而且百度搜不到我们产品页”&#xff0c;那一刻我才意识到&#xff0c;SPA 做得再爽&#xff0c;在服务器端渲染&#xff08;SSR&#xff09;面前&#xff0c;该补的课一节都跑不掉。标题里这期“…

作者头像 李华
网站建设 2026/10/3 4:11:43

SpringBoot+Vue历史馆藏管理系统:从数据库设计到部署全解析

做历史馆藏管理这块的项目&#xff0c;最磨人的往往不是前端动画有多酷、并发能扛多高&#xff0c;而是先把业务理顺&#xff1a;一件藏品从进馆登记到盘点、借展、修复、再入库&#xff0c;中间到底要过多少道手。我见过不少博物馆、档案馆、学校院系还在用Excel甚至纸质台账管…

作者头像 李华
网站建设 2026/10/3 4:11:37

STM32 SDIO 4bit模式切换卡死?HAL_SD_ConfigWideBusOperation排查指南

1. 这个函数到底是干什么的&#xff1a;先看懂它卡住之前发生的三件事先别急着改代码。我调试嵌入式固件这些年有个习惯&#xff1a;遇到一个卡死的API&#xff0c;第一件事不是怀疑库函数写错了&#xff0c;而是先搞清楚它内部到底走了哪几步、每一步依赖什么前置条件。HAL_SD…

作者头像 李华
网站建设 2026/10/3 4:11:12

C/C++连接MySQL避坑指南:从编译配置到运行时排查

C/C链接MySQL&#xff0c;听起来就是个“基础知识”&#xff0c;但真正动手的时候&#xff0c;一堆坑会让你怀疑自己学的C是假的。特别是现在MySQL 8普及之后&#xff0c;认证插件、SSL、字符集、64位库衔接&#xff0c;每一个环节都可能让编译通过但运行时报错。这些内容适合哪…

作者头像 李华
网站建设 2026/10/3 4:10:12

运维转型DevOps:从背锅到技术深耕的实战路线

1. 运维三年&#xff0c;我见的每一个凌晨两点都叫"背锅"先说个真实的场景。某天凌晨两点&#xff0c;监控大屏突然飘红&#xff0c;某核心业务的接口超时率直线上升。我爬起来一看&#xff0c;服务器负载正常、网络流量正常、数据库慢查询也没有明显飙升&#xff0c…

作者头像 李华
网站建设 2026/10/3 4:10:07

跳跃游戏贪心解法:从DFS超时到O(n)最远可达距离

1. 先搞清楚这道题到底在考什么1.1 从题目描述里读出真实意图LeetCode 55题“跳跃游戏”是热题100里非常经典的一道贪心题目。题面不复杂&#xff1a;给你一个非负整数数组nums&#xff0c;你一开始站在下标 0 的位置&#xff0c;数组里的每个元素代表你在当前位置最多能跳多远…

作者头像 李华