ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot 从头部 token 获取当前登录用户:JWT 原理与拦截器实战

Spring Boot 从头部 token 获取当前登录用户:JWT 原理与拦截器实战 最近在做一个前后端分离的登录改造项目遇到一个很典型的场景用户的登录状态全部交给前端维护后端每个接口在收到请求时必须先回答“当前登录用户是谁”。最开始的实现方式是前端在请求参数里传userId后端直接拿这个字段去查库结果上线没几天就出了问题——有人把请求里的userId改成别人的直接越权访问了。后来我把整套逻辑收敛到请求头里的 token 上从头部 token 中获取当前登录用户业务里再也不用猜“你是谁”才把这个问题彻底解决。这篇文章想把这件事讲透。我会先分析为什么要把用户信息放到带头部的 token 里再拆解 JWT 的结构和签名原理然后给出一套完整的 Spring Boot 实现登录生成 token、拦截器解析请求头、把当前用户注入 Controller。最后把我在生产环境里踩过的坑和排查思路整理成速查表尤其是 token 过期、续签、跨域、OAuth 集成这些容易翻车的地方一次聊透。1. 项目背景与整体设计思路1.1 为什么一定要从头部 token 取用户身份很多刚入行的同学会觉得前端传一个 userId 过来就能知道用户是谁简单又直观。但只要你经历过一次越权漏洞就会明白这个做法有多危险。请求参数是可以被随意改写的不管是 GET 的 query string、POST 的 form body 还是 JSON攻击者只要把工具打开想改成什么就改成什么。如果后端对参数里的 userId 全盘信任那等于把所有用户的数据都暴露在门外。正确的思路是用户的身份标识不能由前端传过来而是后端在登录成功时签发一个不可伪造的凭证这个凭证放在请求头一般是Authorization里后端拿这个凭证解析、验签得到当前登录用户。这就是“后端自己签发的后端自己能验证”的原则。前端只是负责保存和携带没有任何修改余地。把 token 放在头部而不是 body 里还有一个额外的好处它不参与业务参数的签名和日志记录方便统一处理。1.2 方案选型Session、Token、JWT 该怎么选有几种常见方案可以用来保存和获取登录状态我画个简单的对比方案服务端状态是否适合前后端分离扩展性典型问题HttpSession有状态一般依赖 Cookie 和 Session ID水平扩展要共享 Session集群环境要引入 Redis 等共享存储不透明 Token有状态可以Token 字符串可能是随机 UUID服务端要保存 Token 对应用户校验时需要查库或查缓存JWT无状态非常适合天然适合多实例部署无法主动踢人续签要单独设计这里重点说下 JWT 和传统 Session 的差别。Session 的流程是登录成功后在服务端存一份用户信息再给浏览器发一个 Session ID以后请求带着 Cookie服务端拿 Session ID 去内存或 Redis 里找到用户。这个过程本身没毛病但每个请求都要访问一次共享存储在微服务或者高并发场景下存储会成为瓶颈。JWT 的思路是反过来的它把用户信息直接加密签名后交给客户端服务端只要验签通过就默认里面的声明有效。这样服务端做到无状态水平扩容非常舒服。选型的时候要记住一句话Session 适合服务端渲染、单体应用JWT 适合前后端分离、开放平台、微服务。如果你的项目要做 App、小程序、Web 多端共用一套认证JWT 几乎是绕不开的选择。1.3 统一走请求头而不是散落在各接口里很多人接手老项目会发现有的接口从 header 取 token有的从参数取有的干脆不取直接查 Session。这种散乱的设计是最难维护的。统一从头部 token 获取当前用户好处有三个第一标准统一。HTTP 协议里专门有Authorization头前端拦截器在发起请求时统一塞 token后端统一在拦截器里解析业务层代码完全不感知 token 的存在。第二方便做权限控制。在拦截器阶段就能拿到 userId、角色、权限不需要每个 Controller 重复写校验逻辑。第三排查问题容易。出问题时只需要关注一个入口日志里也只要记录 token 的解析结果而不是每个接口各自输出不同格式的日志。说的直白一点把“当前用户是谁”这个公共问题交给过滤器和拦截器去解决业务代码只需要关心自己的业务逻辑。2. JWT 核心原理与结构拆解2.1 JWT 的三段式结构JWT 全称是 JSON Web Token它长这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiJ9.oIIL8e7oV7IkJn8P4YVv0L3HzL2kfzjYXvXvH_E5RI这段字符串被两个点号分成了三段Header、Payload、Signature。Header 里是加密算法和 token 类型Payload 里是用户相关的声明Signature 是由前两段加密钥计算出来的签名。这里要注意一个细节Header 和 Payload 只是 Base64URL 编码不是加密。任何人拿到 token都可以把中间那段解码出来看内容。所以千万别在 token 里放密码、身份证号、手机号这类敏感信息否则等于把隐私明文送给别人。JWT 的作用是防篡改不是加密。2.2 签名到底在防什么签名的核心作用是让服务端能够校验这段 token 是不是自己签发的以及内容有没有被人改动。签名算法会把 Header 和 Payload 的内容连同密钥一起做哈希运算客户端拿到的 token 里包含签名结果。服务端收到 token 后用同样的密钥重新算一遍签名如果算出来的结果和 token 里带的签名一致就说明内容没有被篡改。为了体现这一点我直接用一个直观的例子。假设用户 A 的 token 里是userId1001攻击者想把1001改成1002。即使他把 Payload 改成1002他也无法重新算出带正确签名的完整 token因为签名需要密钥密钥只保存在服务端。所以服务端验签不通过请求就被拒绝了。签名算法常用的有 HS256、RS256。HS256 是对称加密签发和验签都用同一个密钥实现简单适合内部系统。RS256 是非对称加密私钥签发、公钥验签适合开放平台或者多个服务相互校验的场景。一般的内部项目用 HS256 就够了但密钥一定要用足够长的随机字符串别用123456这种。2.3 常用声明与过期时间设置JWT 标准里有很多预定义字段每个字段叫一个 claim。我从实际开发角度列一下最常用的几个Claim含义使用建议subSubject主题通常存用户 ID适合放用户的唯一主键issIssuer签发者多服务时标明来源audAudience受众限制 token 只能给某个服务使用expExpiration Time过期时间必填防止 token 永久有效iatIssued At签发时间配合 exp 计算有效期jtiJWT ID唯一标识做强制下线或黑名单时用过时间参数怎么定没有标准答案要看业务敏感度。管理后台我见过 15 分钟就过期的论坛类产品会设置 7 天移动端用得比较多的方案是 access token 2 小时、refresh token 14 天。原则是过期时间越短越安全但用户体验越差过期时间越长用户越省事风险越高。如果实在拿不准先按 access token 2 小时、refresh token 7 天来后续根据业务反馈再调整。2.4 常见误区token 里能不能放业务数据很多同学顺手就把昵称、头像、部门、角色都塞进 token一是图方便想着拦截器里直接取二是省得每次查数据库。这个思路可以用但要克制。token 是“无状态”的意味着它一旦签发内容就被固定住了。如果你把角色塞进去用户改了角色token 里的旧角色不会自动更新除非强制用户重新登录。产品经理一句“帮我给那个用户加个管理员权限”技术就得让所有用户重新登录这体验就很尴尬。我见过一个项目把用户的地区、门店、积分都放进 token结果用户改了门店信息整天收到“数据同步失败”的告警。所以我个人的建议是token 里只放能唯一定位用户的最小信息比如 userId最多加一个用户名权限相关的东西让拦截器去查数据库或者查缓存。至于昵称头像这类易变信息每次都从库里取不要往 token 里塞。保持 token 短小精悍还能减小请求头体积。3. 动手实现Spring Boot 从头部 token 获取当前用户3.1 项目依赖与技术栈我用的是 Spring Boot 2.7 加 Java 8JWT 库选 jjwt 0.11.5。jjwt 是 Java 生态里最常用的 JWT 库API 清晰文档全社区活跃。在pom.xml里加入下面三个依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency注意jjwt-impl和jjwt-jackson的 scope 是 runtime这两个包不需要在代码里直接引用但运行时缺了就会报错。很多人配完依赖一启动就报ClassNotFoundException多半是少了其中一个。3.2 登录接口签发 token登录接口做的事很简单校验用户名密码通过后生成 token 返回给前端。我把生成 token 的代码封装成一个JwtUtilComponent public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire-hours}) private long expireHours; private SecretKey getKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username) { Date now new Date(); Date expiryDate new Date(now.getTime() expireHours * 3600_000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getKey(), SignatureAlgorithm.HS256) .compact(); } }这里的密钥从配置文件读取比如jwt: secret: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyfZx... expire-hours: 2密钥建议直接用Keys.secretKeyFor(SignatureAlgorithm.HS256)生成一个足够长的 Base64 字符串长度至少 32 字节。很多项目上线后才发现 token 能正常生成但不能正常解析十有八九是密钥太短。3.3 解析 token验签和取值解析 token 是核心中的核心。我用同一个JwtUtil里加一个方法public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getKey()) .build() .parseClaimsJws(token) .getBody(); }这个过程会自动完成三件事检查签名是否合法、检查是否过期、返回 Payload 中的所有 claim。如果签名不对抛SignatureException如果 token 过期抛ExpiredJwtException如果格式不对抛MalformedJwtException。我们只需要在拦截器里统一 catch 这些异常即可。这里有个小坑parseClaimsJws和parseClaimsJwt容易搞混。前者解析的是带签名的完整 JWT后者解析的是没签名的 JWT也就是只有 Header.Payload 两段的那种。我们业务里用的都是前者签名校验必须靠它。3.4 拦截器读取 Authorization 头并注入请求属性Spring MVC 里最合适的切面是HandlerInterceptor。它比Filter更靠内层可以拿到 HandlerMethod而且通过层层拦截器注册路径很方便做白名单控制。我写一个AuthInterceptorComponent public class AuthInterceptor implements HandlerInterceptor { private static final String AUTHORIZATION_HEADER Authorization; private static final String BEARER_PREFIX Bearer ; Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String authHeader request.getHeader(AUTHORIZATION_HEADER); String token resolveToken(authHeader); if (token null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } try { Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, Long.valueOf(claims.getSubject())); request.setAttribute(username, claims.get(username, String.class)); return true; } catch (ExpiredJwtException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write({\message\:\token expired\}); return false; } catch (JwtException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write({\message\:\invalid token\}); return false; } } private String resolveToken(String authHeader) { if (authHeader ! null authHeader.startsWith(BEARER_PREFIX)) { return authHeader.substring(BEARER_PREFIX.length()); } return authHeader; } }我这里的写法兼容两种情况一种是前端严格遵守标准传Authorization: Bearer xxx另一种是前端直接把 token 放在 Header 里传。如果严格要求只收 Bearer 格式可以把 finally 里的return authHeader改成return null这个取决于团队规范。用request.setAttribute把解析结果存到请求作用域后面的 Controller 和 Service 通过RequestAttribute直接拿避免了 ThreadLocal 没有清理导致的内存泄漏问题。3.5 注册拦截器并配置放行路径有拦截器就得注册。我建一个WebConfig实现WebMvcConfigurerConfiguration public class WebConfig implements WebMvcConfigurer { Autowired private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns( /api/auth/login, /api/auth/register ); } }配置里最关键的是addPathPatterns和excludePathPatterns。登录、注册、验证码、健康检查这类接口必须放行其他的/api/**全部走拦截器。这个白名单列表要单独维护好以后每加一个公开接口都要记得在这里补上。3.6 在 Controller 里取当前用户服务端收到经过校验的 token 后拦截器已经把 userId 和 username 放到了 request 属性里Controller 里取出来用就够了RestController RequestMapping(/api/user) public class UserController { GetMapping(/info) public ResultUserInfoVO getUserInfo(RequestAttribute Long userId) { UserInfoVO vo userService.getUserInfo(userId); return Result.success(vo); } }只用RequestAttribute(userId)就能拿到。这样写的好处是代码非常干净业务层不感知 token 解析细节。如果你不想每个方法都写RequestAttribute可以自定义一个注解加一个参数解析器把当前用户封装成一个完整的CurrentUser对象代码会更优雅但起步阶段用RequestAttribute已经够了别过度设计。4. 实操细节与性能优化要点4.1 请求头解析的边界情况处理请求头虽然只是字符串但真的什么奇葩情况都能遇到。我总结几个高频边界场景第一Header 缺失。有些客户端没登录就直接调接口Authorization整个没传。此时不要直接抛空指针要返回 401。第二Header 带了Bearer但后面是空字符串。这种属于恶意请求直接判断子串是否为空。第三token 里有空格。有的前端拼接字符串时不小心多打了一个空格substring(7)之后 token 最前面会有个空格验签必然失败所以解析后最好trim()一下。第四token 不是合法 JWT比如直接被传了一个abc解析时会抛MalformedJwtException要 catch 住。我自己在拦截器里的处理原则是拿不到合法用户直接 401不要把异常继续往业务层抛。否则业务层异常日志会疯狂刷屏。4.2 缓存用户信息避免每次请求都查库token 解析完拿到的是 userId但很多业务下一步就要查询用户昵称、头像、角色信息。如果每个请求都重新查一次数据库在高并发下很容易把数据库打爆。所以实际生产项目里一般会引入缓存层。我的做法是登录时把用户信息缓存到 Rediskey 是user:info:{userId}过期时间和 token 有效期保持一致。业务需要用户信息时先查缓存缓存没有再查库然后回填缓存。这里要特别注意缓存过期和 token 过期之间的关系最好做成 token 过期时间略短于缓存过期时间防止 token 失效但缓存里还能读到过期用户的信息。4.3 合理设置过期时间与刷新策略无状态 JWT 最大的痛点就是签发之后无法主动让它失效。你总不能把密钥换了吧换了所有在线用户全部下线。业界通用做法是引入 refresh token 机制简称双 token 机制。思路是登录成功返回两个 token。access token 有效期短负责访问资源refresh token 有效期长负责续签。当 access token 过期时前端拿 refresh token 调一个专门的刷新接口服务端校验 refresh token 合法则签发新的 access token。refresh token 一定要保存在服务端存储里可以是数据库也可以是 Redis这样才能实现吊销操作。前端配合的逻辑也要说下请求时如果发现返回 401不要立刻跳登录页先尝试用 refresh token 刷新一次刷新成功就重放原请求。如果刷新也失败再跳登录页。这套逻辑我已经在多个项目里用了体验比一过期就踢人好得多。4.4 并发与线程安全JWT 解析本身是无状态的理论上线程安全但有几个点容易忽略。第一SecretKey不要每次请求都创建。像我在JwtUtil里写的getKey()每次调用都会用字符串重新生成一个SecretKey对象高并发下反而多了一点点 CPU 开销。更好的做法是在PostConstruct里初始化一次然后复用。第二SimpleDateFormat这类对象如果被用在多线程环境下会有线程安全问题解析 token 时尽量用java.time下的新 API。第三如果使用 ThreadLocal 保存当前用户一定要在afterCompletion里调用remove()否则请求线程被线程池复用时用户信息会串号。我自己为了避免麻烦更推荐用request.setAttribute它天然是请求隔离的不存在清理问题。5. 常见问题与排查实录5.1 如果 JWT 解析报错先看这几类开发期问的最多的是“我明明写对了怎么解析失败”。我整理了一张速查表错误信息可能原因解决方案JWT String has neither a valid signature nor a claimtoken 是空串或非 JWT 格式检查前端是否真的把 token 放进 HeaderThe token was expected 3 parts, but got 1token 没有点号分隔比如传成了裸字符串检查 token 是否被截断或写错The token was expected 3 parts, but got 2token 缺少签名部分使用parseClaimsJwt解析了未签名 tokenSignatureException: JWT signature does not match签名密钥不一致检查签发和验签用的 secret 是否一致ExpiredJwtExceptiontoken 超期引导用户重新登录或走 refresh token 续签MalformedJwtExceptiontoken 被篡改或解析乱码看是不是 Header 里有特殊字符或空格SecretKey 过短类异常HS256 密钥长度不足 32 字节生成一个足够长的随机密钥出现这些错误时第一时间把 token 复制到 jwt.io 上手动解析一下看看结构是否正常、过期时间是否合理可以快速缩小问题范围。5.2 token 失效与主动踢人怎么实现JWT 默认是无状态的服务端不知道 token 什么时候被签发、什么时候该失效所以“踢人下线”是个大难题。如果业务上确实有强制退出、账号封禁的需求我常用的办法是维护一个黑名单。具体做法在 Redis 里以blacklist:token:{jti}为 key设置过期时间为 token 剩余有效期。每次请求进来解析 token 后先去检查一下这个 jti 是否在黑名单里如果在就直接拒绝。踢人的时候就把当前用户的 token 的 jti 拉进黑名单。这样做的好处是不用把所有在线用户都清掉只影响被踢的那一个。使用黑名单之后JWT 的“无状态”优势会打折扣因为每次请求都要查一次 Redis。但从业务安全角度考虑这笔开销是值得的。如果完全无法接受任何存储依赖那就不要用 JWT老老实实回 Session 或 Redis 方案。5.3 跨域请求为什么带不上 Authorization 头前后端分离项目里前端经常抱怨“明明登录了请求还是 401”打开浏览器 Network 一看Authorization根本就没发出去。这不是后端解析的问题十有八九是跨域配置没放开。当浏览器发跨域请求时有一个叫“预检请求”的机制。如果前端要带自定义 Header比如Authorization后端必须在Access-Control-Allow-Headers里显式允许这个 Header否则浏览器直接把请求拦截了后端连 token 都看不到。Spring Boot 的跨域配置可以这样写Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(https://your-frontend-domain.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(Authorization, Content-Type) .allowCredentials(true); } }重点关注allowedHeaders里有没有Authorization。生产环境不要用allowedOrigins(*)配合allowCredentials(true)这是浏览器规范不允许的组合而且安全隐患很大。把前端域名写明确。5.4 与其他登录体系集成时容易掉进哪些坑很多项目不是从零开始的而是要把自己的用户体系和公司统一的登录平台、OAuth2、OIDC 对接起来。这时你会遇到大量和 token 相关的概念access token、id token、refresh token、token exchange……搜索里出现的sign-in could not be completed token exchange failed: token endpoint returned ...这种报错基本都发生在 OAuth2/OIDC 授权码流程里。这类问题的通病是客户端拿到的 authorization code 只能使用一次而且有时效服务端再用这个 code 去 token endpoint 换 token 时如果 code 过期、客户端秘钥配错、回调地址不匹配、后端系统时间偏差过大都会导致 token exchange 失败。排查思路是第一看请求连接的 token endpoint 地址是否正确第二看 client_id 和 client_secret 是否配对第三看 redirect_uri 是否和授权申请时完全一致注意端口、路径、大小写第四看系统时间和授权服务器时间是否偏差过大偏差超过一两分钟就会导致签名过期。当然是内部 JWT 场景就不会出现这种 OAuth 流程但如果项目接的是企业微信、钉钉、GitLab 这种第三方登录这类排查经验你迟早用得上。5.5 前端也要配合请求拦截器和响应拦截器从头部 token 获取当前登录用户不只是后端的事前端也得配合好。我在 Vue 项目里一般会在 axios 请求拦截器里统一加上 Headerservice.interceptors.request.use(config { const token localStorage.getItem(accessToken) if (token) { config.headers.Authorization Bearer ${token} } return config })响应拦截器里处理 401 和 token 刷新service.interceptors.response.use( response response, async error { const { response } error if (response response.status 401) { const refreshToken localStorage.getItem(refreshToken) if (refreshToken) { const newToken await refreshAccessToken(refreshToken) if (newToken) { error.config.headers.Authorization Bearer ${newToken} return service.request(error.config) } } // 刷新失败跳登录页 router.push(/login) } return Promise.reject(error) } )这里有个细节401 有可能是 token 真过期也有可能是网络抖动、网关超时导致偶发的认证失败。一股脑去刷新 token 反而可能把正常请求搞挂。建议只在 access token 确实过期时刷新连续刷新失败次数过多就直接清理登录态别让用户陷入死循环。6. 结合个人经验的操作建议从头部 token 获取当前登录用户这件事看起来只是拦截器里几行代码但把它做到生产可用靠的是对细节的把控。我在实际项目中积累了几条原则分享给你做参考。第一永远不要把 userId 这类身份参数交给前端直接传。不管前端怎么解释“我就想传个 userId 方便你查”后端都要守住底线从 token 里统一解析。第二token 的有效期一定要配短refresh token 配长。宁可让用户多刷几次 token也不要让一个被盗的 token 能长期使用。第三不要为了追求无状态就放弃黑名单、放弃服务端存储。凡是要做“踢人”“封号”“强制下线”的业务就必须付出一点状态管理的成本。第四全局异常处理里一定要兜底 JWT 解析异常别让一串长长的堆栈打到前端页面上。我最开始做这套东西的时候也踩过“把用户信息全塞进 token”“Session 和 JWT 混着用”的坑后来慢慢收敛成一套规则token 里只放 userId 和 username权限和用户详情走缓存登录态由 access token 加 refresh token 双层管理拦截器统一解析注入。这套方案我不敢说适合所有项目但在大多数前后端分离的中后台系统里基本是够用且稳的。如果你正在做类似改造我建议你从最小闭环开始先实现生成 token、拦截器解析、Controller 取 userId跑通一条完整链路之后再逐步加上缓存、刷新、黑名单这些进阶能力。希望这篇文章能帮你少走一些我当年走过的弯路。
返回列表