前几天帮一个合作方排查登录问题,他们的应用从 Spring Security 6 升级到 7 之后,用户在登录后看到的姓名、头像全部变成了默认值。查了半天,问题出在 ID Token 和 userinfo 端点的 claims 合并上。说实话,类似的问题我见过不止一次,根因往往不是配置写错了,而是对 OIDC 协议里“ID Token 管认证、userinfo 端点管用户资料”这个分工没理清。这篇文章就把 Spring Security 7 下的 /connect/userinfo 端点、ID Token 解析与用户信息获取这条链路,从协议设计到代码实现完整拆一遍。
先说结论:标准 OIDC 授权码流程里,Spring Security 7 会自动完成这些事——带 scope=openid 跳到授权服务器,用户同意后拿授权码换 token,从 token 响应里解析出 ID Token 和 Access Token,对 ID Token 做签名与声明校验,再用 Access Token 请求 userinfo 端点,最后把两边返回的 claims 合并成 OidcUser。框架自动化程度很高,但一旦涉及自定义用户映射、多 provider 切换、业务字段提取,你就必须知道每一步背后的机制。这篇文章适合三类人:刚接触 OIDC 登录的 Spring 新手,正在从 6 往 7 迁移的老用户,以及需要对接多套身份提供商的后端工程师。
1. 先从协议层面看懂 userinfo 端点和 ID Token 的关系
1.1 一次完整 OIDC 授权登录中,两个令牌各自的角色
很多新手容易把 OIDC 和 OAuth2 混在一起看,其实 OIDC(OpenID Connect)是在 OAuth2 授权码模式之上,加了一层身份认证层。OAuth2 只负责发访问令牌,而 OIDC 额外签发一个 ID Token,专门用来向应用证明“当前登录的人是谁”。
一次标准 Authorization Code Flow 大致是下面这条线:
- 用户点击登录,浏览器被重定向到授权服务器,附带
scope=openid profile email、client_id、redirect_uri等参数。 - 用户在授权服务器上完成登录和授权确认。
- 授权服务器把授权码通过浏览器回传到应用的回调地址。
- 应用在服务端用授权码去换 token。这时 token 响应里既有
access_token,也有id_token。 - 应用解析并校验 ID Token,确认用户身份。
- 应用拿着 Access Token 去请求 userinfo 端点,获取用户的详细资料。
- 业务系统把 userinfo 返回的用户属性填充到自己的用户模型或者页面里。
注意第 4 步和第 6 步。第 4 步里拿到的 ID Token 是一个 JWT,里面已经有一批用户声明了,但同时第 6 步还有一个专门的 userinfo 请求。很多人会问:既然 ID Token 里已经有 name、email,为什么还要再调一次 userinfo 端点?这正是整个协议里最容易忽视的设计意图。
在实际对接中,/connect/userinfo 这类路径并不是协议强制的统一路径。标准要求的是:授权服务器在/.well-known/openid-configuration发现文档中公布userinfo_endpoint地址,具体路径由服务提供方自定义。你只要配置了 issuer-uri,Spring Security 会自动从发现文档里读出真正可用的 userinfo 端点。
1.2 为什么拿到了 ID Token 还不够,还要再调一次 userinfo 端点
这个问题可以从四个维度来看。
第一是体积控制。ID Token 会跟着认证流程在浏览器和服务端之间传递,如果所有用户资料都塞进去,token 体积会越来越大。JWT 的 header 和 payload 都只是 Base64URL 编码,没有加密,放太多敏感信息还容易泄露。协议设计者的本意是 ID Token 保持精简,只放认证所需的核心声明。
第二是数据时效性。ID Token 一旦签发,Claims 就固定了。用户在身份平台改了头像、换了手机号,旧的 ID Token 里还是旧数据。而 userinfo 端点是实时返回最新数据的,应用可以拿到最新画像。
第三是职责分离。ID Token 解决的是“你是谁”的认证问题,userinfo 端点解决的是“你长什么样”的用户资料问题。认证逻辑和用户资料管理解耦后,授权服务器可以独立维护用户信息,应用侧也不用把 token 解析逻辑和用户数据同步逻辑搅在一起。
第四是隐私最小化。ID Token 的 claims 集合受 scope 限制,开发者在申请 scope 时可以控制返回字段;userinfo 端点返回的字段同样受 scope 控制,而且应用在调用时还可以根据实际业务需要,选择只取其中几个字段。对比一下就很直观,一个典型的 ID Token Payload 可能只有下面几个字段:
{ "iss": "https://iam.example.com", "sub": "10086", "aud": "demo-client", "exp": 1738380000, "iat": 1738376400, "nonce": "abc123", "at_hash": "pL1uY8Zf6g" }而 userinfo 端点返回的内容要丰富得多:
{ "sub": "10086", "name": "张三", "given_name": "三", "family_name": "张", "email": "zhangsan@example.com", "picture": "https://cdn.example.com/avatar/10086.png", "department": "研发部", "title": "高级工程师", "updated_at": 1738300000 }这就是“认证”和“用户资料”两个层面的区别。Spring Security 7 里也是沿着这条线设计的。
1.3 Spring Security 7 把协议翻译成了哪些类和流程
对应上面 6 步流程,Spring Security 7 的 OAuth2 Client 模块里主要组件是下面这些:
| 协议角色 | Spring Security 组件 | 职责 |
|---|---|---|
| 授权码流程总控 | OidcAuthorizationCodeAuthenticationProvider | 处理授权码兑换 token、校验 ID Token、触发用户信息加载 |
| token 与注册信息载体 | OidcUserRequest | 封装 Access Token、ID Token、ClientRegistration、userinfo 端点地址 |
| 用户组装与合并 | DefaultOidcUserService | 合并 ID Token 与 userinfo 返回的 claims,生成 OidcUser |
| HTTP 调用端点 | DefaultOAuth2UserService | 真正发请求请求 userinfo 端点,解析响应为 OAuth2User |
| 最终用户对象 | OidcUser / DefaultOidcUser | 代表登录用户,暴露 getClaims、getUserInfo、getSubject 等方法 |
大部分开发者的日常工作其实只接触到了DefaultOidcUserService和OidcUser这一层。Spring Security 7 跟前几个版本相比,OAuth2/OIDC 模块做了一次整理,旧版本里依赖WebSecurityConfigurerAdapter的那套写法已经彻底移除了,OAuth2 登录配置基本都收敛到HttpSecurity的 lambda 配置里。不过 OidcUserService 的扩展机制和 DefaultOidcUser 的使用方式仍然延续了下来。
2. 搭一个最小可运行的 OIDC 登录工程
2.1 环境与依赖准备
Spring Security 7 运行在 Spring Framework 7 之上,对应 Spring Boot 4.x,要求 Java 17 及以上。如果你项目还在用 Spring Boot 3.x,对应的其实是 Spring Security 6.x,部分 API 会有差异,后面第五节我会专门列一下升级注意点。
工程只需要一个基础 Spring Boot Web 项目和 oauth2-client 依赖。Maven 配置如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency>如果还要做简单页面展示,再加 thymeleaf 或其它模板依赖。oauth2-client 这个 starter 会同时引入 Spring Security 的核心模块,所以不需要单独引 spring-security-config。
2.2 在身份提供商侧申请客户端注册信息
在动手写代码之前,先去你要对接的身份平台(无论是自建的还是第三方的 IAM)申请一个应用/客户端,拿到三样东西:
- client-id:应用标识
- client-secret:应用密钥
- 授权回调地址:比如
http://localhost:8080/login/oauth2/code/demo
这个回调地址非常关键。Spring Security 的 OAuth2 登录默认回调路径就是{baseUrl}/login/oauth2/code/{registrationId},registrationId 对应配置文件里的命名。你在身份平台注册的回调地址,必须和这里保持一致,差一个斜杠都会导致回调失败。
Scope 这里要特别注意:必须包含openid,否则整个流程会退化为普通 OAuth2,不会签发 ID Token,Spring Security 也就不会走 OIDC 的用户信息获取逻辑。通常再加profile和email,分别对应姓名等基本资料和邮箱声明。
2.3 核心配置:application.yml
Spring Security 支持两种 Provider 配置方式。一种是直接指定 issuer-uri,让框架通过发现文档自动获取所有端点;另一种是手动指定 authorization-uri、token-uri、user-info-uri 等。强烈建议优先用 issuer-uri,因为发现文档是 OIDC 标准的一部分,框架能自动拿到准确端点,少写配置也少出错。
一个典型的配置长这样:
spring: security: oauth2: client: registration: demo: provider: iam client-id: demo-client client-secret: demo-secret authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: openid, profile, email provider: iam: issuer-uri: https://iam.example.com这里issuer-uri的值必须和身份平台签发的 ID Token 里的iss声明一致。很多平台在官网填的“Issuer”是不带尾部斜杠的,但实际签发 token 时却带了/,这种细节会导致校验失败,我在第五节会讲排查方法。
如果用不了 issuer-uri,比如内部自研平台不提供标准发现文档,可以手动补全端点:
spring: security: oauth2: client: provider: iam: authorization-uri: https://iam.example.com/oauth2/authorize token-uri: https://iam.example.com/oauth2/token user-info-uri: https://iam.example.com/connect/userinfo jwk-set-uri: https://iam.example.com/oauth2/jwks user-info-authentication-method: header手动指定端点时,最容易被漏掉的就是 jwk-set-uri。没有它,ID Token 的签名校验无法进行,登录会直接失败。
2.4 写一个 SecurityFilterChain
Spring Security 7 要求显式声明 SecurityFilterChain。最简单的配置如下:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/public/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .oidcUserService(oidcUserService()) ) ); return http.build(); } @Bean public OidcUserService oidcUserService() { return new DefaultOidcUserService(); } }默认情况下,访问受保护页面时会自动跳到授权服务器,登录成功后回到原页面。oidcUserService这里先用默认实现,后续需要自定义用户映射时,再替换成自己的 Service。
到这里,一个能跑通的 OIDC 登录工程就搭好了。启动应用,访问一个受保护路径,走完授权流程,你就能在代码里拿到 OidcUser 了。但此时很多同学对“ID Token 和 userinfo 到底怎么关联的”仍然不清楚,下面两章分别拆开讲。
3. 核心环节一:ID Token 的解析与校验
3.1 ID Token 的结构与常见 Claims
ID Token 本质是一个 JWS 格式的 JWT,分三部分:Header、Payload、Signature。Header 里主要放着签名算法和密钥 ID;Payload 是 Claims 集合;Signature 由授权服务器用私钥签名,应用侧通过 JWKS 公钥验证。
标准 Claims 大致分两类,一类是必选核心声明,一类是可选的资料声明:
| Claim | 含义 | 必选性 |
|---|---|---|
| iss | 签发者,必须匹配配置的 issuer | 必选 |
| sub | 用户在授权服务器上的唯一标识 | 必选 |
| aud | 受众,必须包含本应用的 client-id | 必选 |
| exp | 过期时间戳 | 必选 |
| iat | 签发时间戳 | 可选 |
| nonce | 防重放随机数,保持与登录发起时一致 | 流程内必选 |
| at_hash | Access Token 的哈希值 | 有条件必选 |
| azp | 授权方,多受众场景出现 | 有条件必选 |
| name / email / picture 等 | 用户资料声明 | 受 scope 限制,可选 |
ID Token 的 payload 是可以被任何人 Base64URL 解码的,因为它不加密。也就是说,你把它贴到 https://jwt.io 上就能看到明文内容。正因如此,ID Token 里不应该塞敏感信息,比如手机号、身份证号这类数据。
这也引出一个重要的安全认知:能解码 JWT 不等于能信任 JWT。任何客户端都能解码看到 claims,但只有验签通过后,才能确认这些 claims 确实由你信任的授权服务器签发。所以业务代码里可以主动读取 ID Token 的 claims,但要校验的工作必须依赖框架或自己完整实现签名验证,不能只解 payload 就完事。
3.2 Spring Security 7 替我们完成了哪些校验
在 OidcAuthorizationCodeAuthenticationProvider 的流程里,拿到 token 响应后,框架会对 ID Token 做一串校验,这个清单非常关键:
- 签名校验:通过发现文档里的
jwks_uri获取公钥,按照 JOSE Header 中的kid选择公钥,验证签名。 - Issuer 校验:
iss必须等于配置的 issuer-uri。 - Audience 校验:
aud中必须包含当前客户端的 client-id。 - Expiry 校验:
exp必须晚于当前时间,框架允许一定的时钟偏移。 - Nonce 校验:
nonce必须和认证请求发起时保存在 session 里的值一致,防止跨站登录伪造。 - 有条件校验:如果
aud有多个值且出现了azp,那么azp必须是 client-id。
这些校验的配置在框架内部,不需要你手动逐条实现。但你得知道它们的逻辑,否则出事时很难快速定位。
Issuer 发现机制这里多说一句。当你配置了 issuer-uri,Spring Security 会去拉取{issuer}/.well-known/openid-configuration,这个文档里包含了jwks_uri。框架再根据 jwks_uri 获取公钥列表,按 kid 动态选公钥验签。生产环境里,这一步经常出现内网证书不信任导致拉取失败的问题,具体解法我在第五节讲。
3.3 业务代码中如何获取并解析 Claims
在 Controller 里拿到 OidcUser 最直接的方式是@AuthenticationPrincipal:
@RestController public class UserController { @GetMapping("/me") public Map<String, Object> me(@AuthenticationPrincipal OidcUser oidcUser) { return Map.of( "subject", oidcUser.getSubject(), "name", oidcUser.getFullName(), "email", oidcUser.getEmail(), "claims", oidcUser.getClaims() ); } }注意getClaims()这个名字有一点误导。这里返回的 map 实际上已经是 ID Token 和 userinfo 合并后的结果,并不是单纯 ID Token 里的 claims。如果你只想要 ID Token 里的原始 claims,要从oidcUser.getIdToken().getClaims()取。
另一个写法是从 SecurityContext 里拿 Authentication 再转型:
@GetMapping("/profile") public String profile(Authentication authentication) { OidcUser oidcUser = (OidcUser) authentication.getPrincipal(); return oidcUser.getUserInfo().getFullName(); }这两种方式在 oauth2Login 流程里都是有效的。OidcUser 既实现了OAuth2User,又持有OidcIdToken和OidcUserInfo,所以业务代码能一头拿认证信息,一头拿用户资料。
4. 核心环节二:/connect/userinfo 端点的自动调用与自定义映射
4.1 userinfo 端点自动调用的完整链路
当框架走完 ID Token 校验后,会构造一个OidcUserRequest,里面装着四样东西:OidcUserRequest 的总流程是把 Access Token、ID Token、ClientRegistration 以及从发现文档里解析出的userInfoEndpointUri打包起来,交给DefaultOidcUserService.loadUser方法。
看一下 DefaultOidcUserService 的调用链会更清楚:
- DefaultOidcUserService.loadUser 先构造一个 DefaultOAuth2UserService。
- DefaultOAuth2UserService 使用
RestOperations发送GET {userInfoUri},请求头带上Authorization: Bearer {access_token}。 - userinfo 端点根据 Content-Type 返回响应。如果是
application/json,直接解析为 Map;如果返回application/jwt,框架会用 JwtDecoder 解码后取 claims。 - 返回的 userinfo claims 被包装成 OAuth2User。
- DefaultOidcUserService 把 ID Token 里的 claims 与 userinfo 返回的 attributes 合并,构建出 OidcUser。
这个调用时机是登录过程内一次的,不是每个 HTTP 请求都触发。登录成功后,认证信息会保存在 session 里,后续请求直接复用,不会反复请求 userinfo 端点。这个设计既保证了首次登录能拿到完整资料,也不会因为频繁调用 userinfo 给身份平台造成压力。
4.2 自定义 OidcUserService 接管用户信息处理
默认的 DefaultOidcUserService 返回的 OidcUser 只包含 OIDC 协议中的 claims,没有任何业务逻辑。真实系统里,登录后通常要做三件事:关联本地用户表、分配业务角色、把业务用户 ID 塞进认证对象。这些逻辑最好的落点就是自定义 OidcUserService。
下面是一个完整的自定义实现:
@Service public class LocalUserOidcUserService extends DefaultOidcUserService { private final LocalUserRepository userRepository; private final RoleMapper roleMapper; public LocalUserOidcUserService(LocalUserRepository userRepository, RoleMapper roleMapper) { this.userRepository = userRepository; this.roleMapper = roleMapper; } @Override public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { // 1. 先走框架默认逻辑,完成ID Token校验、userinfo获取、claims合并 OidcUser oidcUser = super.loadUser(userRequest); // 2. 从合并后的claims里取唯一标识 String issuer = oidcUser.getIssuer().toString(); String subject = oidcUser.getSubject(); String email = oidcUser.getEmail(); // 3. 关联或创建本地用户 LocalUser localUser = userRepository.findByIssuerAndSubject(issuer, subject); if (localUser == null) { localUser = userRepository.create(issuer, subject, email, oidcUser.getFullName(), oidcUser.getUserInfo().getPicture()); } // 4. 把本地角色转换成Spring Security的权限 List<GrantedAuthority> authorities = new ArrayList<>(oidcUser.getAuthorities()); for (String role : roleMapper.findByLocalUser(localUser.getId())) { authorities.add(new SimpleGrantedAuthority(role)); } // 5. 把本地业务ID等字段混入claims,业务代码直接可取 Map<String, Object> mergedClaims = new HashMap<>(oidcUser.getClaims()); mergedClaims.put("localUserId", localUser.getId()); mergedClaims.put("localUserName", localUser.getDisplayName()); return new DefaultOidcUser( authorities, oidcUser.getIdToken(), oidcUser.getUserInfo(), mergedClaims ); } }配置到 HttpSecurity 时,把默认的 OidcUserService 换成这个自定义实现:
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http, LocalUserOidcUserService localUserOidcUserService) throws Exception { http .authorizeHttpRequests(auth -> auth .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .oidcUserService(localUserOidcUserService) ) .successHandler((request, response, authentication) -> { // 登录成功的业务埋点或跳转 response.sendRedirect("/me"); }) ); return http.build(); }这里的扩展点就是 Spring Security 留给业务开发的“后门”。默认实现是一套纯协议逻辑,自定义实现是在协议逻辑之上叠加业务逻辑。注意super.loadUser一定不能省,否则 ID Token 校验和 userinfo 获取都不会执行,loadUser 拿到的 OidcUserRequest 里只有原始 token,没有用户对象。
另外,如果在自定义 OidcUserService 里抛OAuth2AuthenticationException,Spring Security 会把异常翻译成登录失败。业务上经常用它来做“该用户未在本地开通权限”的拦截。
4.3 claims 合并规则:哪个来源说了算
这是文章开头那个合作方踩坑的核心点。DefaultOidcUserService 在构建 OidcUser 时,合并规则是先以 ID Token 的 claims 为底,再把 userinfo 返回的 attributes 放进去,遇到同名 key 时,userinfo 里的值会覆盖 ID Token 里的值。
举个例子:ID Token 里返回的name是“张三”,而 userinfo 端点当天返回的name已经被用户改成“张叁”。合并后 OidcUser 的getClaims().get("name")得到的是“张叁”。
这几条规则在实际项目中要记住:
- 用户名、头像、邮箱这类资料型字段,以 userinfo 为准。
- sub、iss、aud、exp 这类协议安全字段,以 ID Token 为准,因为 userinfo 响应本身由 Access Token 保护,但 sub 必须与 ID Token 的 sub 一致,框架会在某些协议实现中校验这个一致性。
- 如果某个字段只在 ID Token 里有,userinfo 里没返回,那合并结果仍然会保留它。
- 如果某个自定义字段只在 userinfo 里返回,ID Token 里没有,合并结果也会有。
所以从业务代码角度,直接读oidcUser.getClaims()拿到的就是“最终数据”,而不需要关心它最初来自哪个来源。但你要做数据同步或审计时,就必须回到getIdToken().getClaims()和getUserInfo().getAttributes()分别取。
自定义 OidcUserService 里如果自己手动改 claims 时,也要遵循同样的逻辑:先在默认合并结果的基础上做修改,不要自己另起炉灶、完全丢开 super.loadUser 的合并结果,否则容易丢失 ID Token 里的安全字段。
4.4 手动调用 userinfo 端点的兜底方案
大部分场景靠框架自动调用就够了,但有两个场景需要手动调用 userinfo:一是纯 OAuth2 流程(scope 没有 openid),没有 ID Token,自然没有 OidcUser;二是你要在非登录上下文中,比如定时任务里用已保存的 Refresh Token 换新 Access Token 后重新同步用户资料。
手动调用其实很简单,用 RestClient 或 WebClient 带 Bearer Token 发 GET 请求即可:
@Service public class UserInfoManualClient { private final RestClient restClient = RestClient.create(); public Map<String, Object> fetch(String userInfoUri, String accessToken) { return restClient.get() .uri(userInfoUri) .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .retrieve() .body(new ParameterizedTypeReference<Map<String, Object>>() {}); } }调用时要注意身份平台返回的 Content-Type。多数平台返回application/json,可以直接转 Map。如果遇到返回application/jwt的平台,你需要先用 JwtDecoder 解码:
JwtDecoder decoder = JwtDecoders.fromIssuerLocation("https://iam.example.com"); Jwt jwt = decoder.decode(jwtString); Map<String, Object> claims = jwt.getClaims();为什么要关心这个?因为 OIDC 核心规范允许 userinfo 端点用 JWT 格式响应,而且这种格式比 JSON 更安全,因为它自带签名。对接新平台时,先看一下它的发现文档里有没有userinfo_signing_alg_values_supported,如果有,就要做好处理 JWT 响应的准备。
5. 常见问题排查与避坑实录
5.1 高频报错与定位思路速查表
下面这个表是从实践中整理出来的高频问题,出现频率最高的是前三个。
| 现象 | 根因 | 排查思路 |
|---|---|---|
[invalid_token] JWT expired | ID Token 或 userinfo JWT 过期 | 先看系统时钟是否漂移;再检查 token 有效期是否太短;最后看是否在自定义 OidcUserService 里缓存了过期数据 |
[invalid_token] Invalid issuer | 配置的 issuer-uri 与 token 中 iss 不一致 | 抓包或者用 jwt.io 解出实际 iss,对比配置值,注意斜杠差异 |
[invalid_token] Invalid audience | aud 不包含客户端 client-id | 确认身份平台上 client-id 是否被改动,检查配置是否拼写错误 |
| 回调地址 404 | redirect-uri 没有完全匹配注册的回调 | 逐字符比对,包括协议、域名、端口、路径、尾斜杠 |
| 登录后马上又跳回登录页 | session 中认证信息丢失,或 authorities 为空 | 检查 cookie 是否正常写入;打印 authentication 查看 authorities |
| userinfo 请求返回 401 | access_token 非法或过期 | 确认代码使用的 token 是本次登录的,手动验证 token 有效性 |
| Claims 里没有自定义字段 | username_attribute 或自定义 claim 未配置 | 直接 curl userinfo 端点看原始返回,确认字段名大小写和层级 |
| 内网环境报 PKIX path building failed | 拉取 jwks_uri 时证书不被信任 | 配置自定义 SSLContext 或导入内网 CA 证书 |
5.2 ID Token 明明能解开却被拒绝:签名和时钟问题
我在实际排障里遇到最多的一类问题是:把 ID Token 贴到在线解码工具里,payload 显示完全正常,可 Spring Security 就是报 invalid_token。
这类问题通常分为两个方向。第一个方向是验签失败。本地解码工具不验签,只解 Base64URL,当然怎样都能解出来。Spring Security 用授权服务器的公钥验证签名,如果 jwks_uri 拉取失败,或者平台在后台轮换了密钥,而缓存里还是旧公钥,签名校验就会失败。遇到这种情况,先把 jwks_uri 返回的 keys 列表和 ID Token Header 里的 kid 对应起来看,确认平台侧公钥是否已经更换。
第二个方向是 exp 判断的时钟漂移。认证服务器和应用服务器时间不同步时,ID Token 明明标注的过期时间还没到,但应用本地时间已经超过了。生产环境里,应用服务器一定配置好时间同步,不然登录会在某个时间点突然批量失败。
Spring Security 默认在验证时留有一定时钟偏移量,但这不是你忽略时间同步的借口。真遇到紧急情况,可以临时扩大偏移量或关闭时间校验,但只能作为应急手段,不能带到生产。
5.3 自定义字段丢失和数据新鲜度问题
有同行问过我:userinfo 里明明有 department 字段,为什么 OidcUser 里拿不到?这类问题的常规原因是 scope 没包含对应权限。department 这类企业属性,通常需要额外申请organization或者自定义 scope,身份平台才会在 userinfo 响应里返回。
排查时最有效的方法是直接带 Access Token 访问 userinfo 端点,看原始 JSON 里到底有没有这个字段。如果原始响应有、OidcUser 里没有,那就是 claims 合并或自定义 OidcUserService 里的逻辑出了问题。
数据新鲜度是另一个经常被忽略的点。第一次登录后,Spring Security 会把 OidcUser 放入 session,后续请求不再请求 userinfo。所以用户在身份平台改了头像,应用里看到的还是旧的,这是预期行为,不是 bug。如果你的业务对头像、昵称实时性要求高,要么在关键操作时主动刷新 userinfo,要么做定时任务同步。用缓存的时候尤其注意:不要在一次登录后把整个身份平台的用户信息缓存 24 小时以上,否则线上会出现大量“用户资料已更新,系统里还是老数据”的工单。
5.4 从 Spring Security 6 升级到 7 的几个明显变化
升级到 Spring Security 7 后,老代码最常见的三类问题:
第一,凡是继承WebSecurityConfigurerAdapter的配置类全部失效,启动直接报类不存在。7.0 移除了这类适配器,必须改成组件式 SecurityFilterChain 写法,也就是在 @Configuration 类里暴露一个返回 SecurityFilterChain 的 @Bean。
第二,OAuth2 登录的配置里,部分旧方法被整体调整。比如oauth2Login()里的.permitAll()行为、OAuth2LoginConfigurer下的一些 helper 方法。建议直接按新版 API 重写,而不是尝试微调旧代码。
第三,依赖版本要对齐。Spring Boot 4.x 默认使用 Spring Security 7,如果你在 Boot 4 项目里手动指定了 Spring Security 6 的版本,会导致 classpath 冲突和不明所以的配置失效。最简单的做法是删除所有 Spring Security 相关 version 声明,直接用 Boot 的 BOM 管理。
升级之后,默认的 OIDC 登录链路并没有颠覆性变化,OidcUserService、OidcUser、DefaultOidcUser 这些核心概念仍是原型,只是配置 API 更强调 lambda 配置和安全过滤链的显式声明。你的业务代码如果之前是在自定义 OidcUserService 里做的用户映射,迁移到 7 的改动量通常很小。
最后说一点个人体会。很多人在对接 OIDC 时习惯直接看配置项,照着官方文档填完就完事了。但 userinfo 端点、ID Token、claims 合并这些问题,如果不把协议层面的分工搞清楚,等遇到多 provider 切换、claims 冲突、签名密钥轮换这类情况时就会很被动。我自己的习惯是:不管用哪个框架,先把那家身份提供商的/.well-known/openid-configuration完整拉下来看一遍,确认它支持哪些 scope、userinfo 返回什么格式、有没有自定义 claims 映射,再动手写配置。这套流程在 Spring Security 7 上完全适用,换到别的技术栈也同样成立。