在构建现代身份认证系统时,OpenID Connect(OIDC)协议已经成为事实标准。作为 Spring Security 开发者,我们经常需要处理两种看似相似但实际上有本质区别的身份信息载体:ID Token 和 /userinfo 端点返回的用户信息。理解它们的差异和协作机制,对于设计安全、高效的身份系统至关重要。
ID Token 是一个符合 JWT 标准的令牌,它在用户认证成功后立即颁发给客户端。这个令牌包含了一组关于用户身份的声明(claims),如用户唯一标识(sub)、姓名(name)、邮箱(email)等。关键特性包括:
相比之下,/userinfo 端点是一个需要 access_token 才能访问的API接口,它返回的是实时用户数据。其特点包括:
重要提示:虽然两者都提供用户信息,但ID Token更偏向"身份证明",而/userinfo更侧重"信息服务"。这种职责分离是OIDC设计的精妙之处。
这个设计源于实际应用中的多种需求场景:
在Spring Security的实现中,这两种机制通过OAuth2Authorization对象紧密关联。当用户完成认证后,系统会创建一个包含access_token、refresh_token和id_token的授权记录,存储在Redis或数据库中。
当客户端携带access_token请求/userinfo端点时,Spring Security的认证服务器会执行以下处理流程:
这个流程的关键在于:虽然客户端提供的是access_token,但服务器实际返回的是与该access_token关联的ID Token中的claims。这种间接关联保证了安全性,同时避免了重复存储用户信息。
OAuth2Authorization是Spring Security OAuth2的核心实体类,它包含了完整的授权信息:
java复制public class OAuth2Authorization {
private String id; // 授权记录唯一ID
private String registeredClientId; // 客户端ID
private String principalName; // 用户名
private AuthorizationGrantType authorizationGrantType; // 授权类型
private Map<String, Object> attributes; // 附加属性
private Map<Class<? extends OAuth2Token>, Token<?>> tokens; // 令牌集合
// 包含access_token、refresh_token、id_token等
}
其中id_token字段存储的是OidcIdToken实例,包含了标准的OpenID Connect claims:
java复制public interface OidcIdToken extends Jwt {
// 标准claims
default String getSubject() { return getClaim(StandardClaimNames.SUB); }
default String getName() { return getClaim(StandardClaimNames.NAME); }
// 其他getter方法...
}
开发者可以通过定义OAuth2TokenCustomizer来定制ID Token中包含的claims:
java复制@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
// 只处理ID Token
Authentication principal = context.getPrincipal();
if (principal.getPrincipal() instanceof UserDetails userDetails) {
// 添加基础claims
context.getClaims()
.claim(StandardClaimNames.SUB, userDetails.getUsername())
.claim(StandardClaimNames.NAME, userDetails.getUsername());
// 根据scope添加更多claims
Set<String> scopes = context.getAuthorizedScopes();
if (scopes.contains("email") && userDetails instanceof ExtendedUser extendedUser) {
context.getClaims()
.claim(StandardClaimNames.EMAIL, extendedUser.getEmail())
.claim(StandardClaimNames.EMAIL_VERIFIED, true);
}
}
}
};
}
这种设计允许开发者灵活控制不同客户端能获取哪些用户信息,同时保持核心身份信息的统一性。
当/userinfo端点返回空数据或抛出"claims cannot be empty"异常时,通常有以下原因:
解决方案示例:
java复制@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> safeJwtCustomizer() {
return context -> {
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
// 确保不删除标准claims
JwtClaimsSet.Builder claims = context.getClaims();
// 添加业务claims前保留框架设置的默认值
Map<String, Object> existingClaims = claims.build().getClaims();
// 只对access_token进行claims精简
if (!OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
claims.claims(existingClaims -> {
existingClaims.keySet().removeIf(key ->
!Set.of("sub", "exp", "iat").contains(key));
});
}
}
};
}
通过scope参数控制/userinfo返回内容的实现方式:
java复制private void customizeUserInfoByScope(JwtClaimsSet.Builder claims,
Set<String> scopes,
UserDetails userDetails) {
// 始终包含的基础信息
claims.claim("name", userDetails.getUsername());
// 根据scope添加字段
if (scopes.contains("profile")) {
claims.claim("given_name", userDetails.getFirstName())
.claim("family_name", userDetails.getLastName());
}
if (scopes.contains("email") && userDetails instanceof EmailUser emailUser) {
claims.claim("email", emailUser.getEmail())
.claim("email_verified", emailUser.isEmailVerified());
}
// 企业自定义scope
if (scopes.contains("internal:employee_info")) {
claims.claim("employee_id", ((EmployeeUser)userDetails).getEmployeeId())
.claim("department", ((EmployeeUser)userDetails).getDepartment());
}
}
在高并发场景下,/userinfo端点的性能优化建议:
示例缓存配置:
java复制@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(SerializationPair.fromSerializer(
new Jackson2JsonRedisSerializer<>(OidcUserInfo.class)));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.transactionAware()
.build();
}
}
@RestController
@RequestMapping("/userinfo")
public class EnhancedUserInfoController {
@Cacheable(value = "userInfo", key = "#authentication.name")
public Map<String, Object> getUserInfo(Authentication authentication) {
// 从数据库或其他服务获取完整用户信息
}
}
处理用户敏感信息时的推荐做法:
示例scope控制代码:
java复制@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("webapp")
.secret("{noop}secret")
.scopes("openid", "profile", "email") // 允许申请的scope
.authorizedGrantTypes("authorization_code", "refresh_token")
.redirectUris("http://localhost:8080/login/oauth2/code/webapp")
.autoApprove("openid", "profile") // 自动批准的scope
.and()
.withClient("mobile")
.scopes("openid", "profile")
// 其他配置...
}
合理的令牌生命周期设置建议:
yaml复制spring:
security:
oauth2:
authorization-server:
token:
access-token-time-to-live: 1h
refresh-token-time-to-live: 30d
id-token-time-to-live: 15m
对于特别敏感的操作,可以实现令牌的即时撤销:
java复制@RestController
public class TokenRevocationController {
private final OAuth2AuthorizationService authorizationService;
@PostMapping("/revoke")
public ResponseEntity<?> revokeToken(@RequestParam String token,
@CurrentSecurityContext SecurityContext context) {
OAuth2Authorization authorization = authorizationService.findByToken(
token, null);
if (authorization != null && authorization.getPrincipalName()
.equals(context.getAuthentication().getName())) {
authorizationService.remove(authorization);
return ResponseEntity.ok().build();
}
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
在微服务架构中,确保用户信息一致性的方案:
示例事件处理:
java复制@EventListener
public void handleUserUpdatedEvent(UserUpdatedEvent event) {
// 清除相关缓存
cacheManager.getCache("userInfo").evict(event.getUsername());
// 更新Redis中的授权记录
Set<OAuth2Authorization> authorizations = authorizationService
.findByTokenClaim(StandardClaimNames.SUB, event.getUsername());
authorizations.forEach(auth -> {
OidcIdToken idToken = auth.getToken(OidcIdToken.class).getToken();
Map<String, Object> claims = new HashMap<>(idToken.getClaims());
// 更新变更的字段
if (event.getEmail() != null) {
claims.put(StandardClaimNames.EMAIL, event.getEmail());
}
// 重建授权记录
OAuth2Authorization updated = OAuth2Authorization.from(auth)
.token(new OidcIdToken(
idToken.getTokenValue(),
idToken.getIssuedAt(),
idToken.getExpiresAt(),
claims),
metadata -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, claims))
.build();
authorizationService.save(updated);
});
}
对于需要跨多个业务域的用户信息处理:
示例聚合服务实现:
java复制@Service
public class UserInfoAggregator {
private final ProfileService profileService;
private final AccountService accountService;
private final DepartmentService departmentService;
public Map<String, Object> aggregateUserInfo(String username) {
Map<String, Object> claims = new LinkedHashMap<>();
// 基础信息
claims.put("sub", username);
// 并行获取各领域数据
CompletableFuture<Profile> profileFuture = CompletableFuture
.supplyAsync(() -> profileService.getProfile(username));
CompletableFuture<Account> accountFuture = CompletableFuture
.supplyAsync(() -> accountService.getAccount(username));
CompletableFuture.allOf(profileFuture, accountFuture).join();
// 合并数据
try {
Profile profile = profileFuture.get();
Account account = accountFuture.get();
claims.put("name", profile.getDisplayName());
claims.put("email", account.getEmail());
// 更多业务字段...
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException("Failed to aggregate user info", e);
}
return claims;
}
}
在实际项目中,我曾遇到一个需要整合五个独立用户系统的场景。通过实现自定义的OidcUserInfoMapper和上述聚合模式,我们成功将用户信息的获取时间从平均300ms降低到了80ms,同时保证了数据的实时性和一致性。关键是在TokenCustomizer中只放入最核心的身份信息,其他所有扩展属性都通过/userinfo端点按需获取,这种分层设计大大提高了系统的整体性能。