基于SpringSecurity6的OAuth2系列之十九-高级特性--OIDC1.0协议之二
- 软件开发
- 2025-09-01 14:18:02

之所以想写这一系列,是因为之前工作过程中使用Spring Security OAuth2搭建了网关和授权服务器,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0。无论是Spring Security的风格和以及OAuth2都做了较大改动,里面甚至将授权服务器模块都移除了,导致在配置同样功能时,花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。
注意:由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,本系列OAuth2的代码采用Spring Security6.3.0框架,所有代码都在oauth2-study项目上: github /forever1986/oauth2-study.git
目录 1 底层原理2 几个OIDC的过滤器2.1 OidcProviderConfigurationEndpointFilter2.2 OidcUserInfoEndpointFilter2.3 OidcLogoutEndpointFilter 3 自定义用户信息3.1 OidcUserInfoAuthenticationProvider返回用户信息3.2 自定义用户信息3.2.1 代码逻辑3.2.2 演示效果上一章,我们讲解了OIDC1.0协议以及给出一个演示demo,那么这一章我们来剖析一下Spring Authrization Server如何实现OIDC的。
1 底层原理1)我们通过授权码模式下的代码原理,先看一下我们配置了odic之后,Filter过滤器链有什么变化
从上图可以看出增加了OidcLogoutEndpointFilter、OidcUserInfoEndpointFilter、OidcProviderConfigurationEndpointFilter可以看出对于授权码模式下,其认证还是基于原先OAuth2那一套,因此这里有兴趣的朋友可以去看看《系列之八-Spring Authrization Server的基本原理》其中OAuth2AuthorizationEndpointFilter过滤器的处理逻辑就能明白授权码的过程
2)那么怎么返回id_token的呢?我们在《系列之九-token的获取》中讲到token的获取是通过OAuth2TokenEndpointFilter过滤器,其中使用OAuth2AuthorizationCodeAuthenticationProvider进行生成,最终使用OAuth2TokenGenerator的三种不同的实现类进行生成。
3)我们先来看看OAuth2AuthorizationCodeAuthenticationProvider代码,先判断有没有请求openid的scope,有的话调用tokenGenerator进行生成
4)我们再来看看tokenGenerator的实现类之一JwtGenerator,其可以处理access_token 和id_token(这个我们在前面《系列之九-token的获取》已经讲过)。只不过在处理id_token与access_token加入的信息不太一样而已
5)至此,我们就明白了,Spring Authrization Server实现OIDC还是基于OAuth2的基础上做了一些修改。那么我们前面提到的关于OIDC的几个过滤器是做什么用的?下面一小节我们来看看。
注意:由于Spring Authrization Server1.3版本已经没有简化模式(Implicit Grant),因此没有实现OIDC的Implicit Flow和Hybrid Flow
2 几个OIDC的过滤器 2.1 OidcProviderConfigurationEndpointFilterOidcProviderConfigurationEndpointFilter过滤器有2段代码非常重要,一个是ENDPOINT_URI,可以看到是拦截/.well-known/openid-configuration请求的
一个是doFilterInternal方法,就是把授权服务器的配置信息发布出来。
因此该Filter中为了实现OIDC协议的4.1小结,为了发布一些授权服务器的信息。我们可以回忆一下,在《系列之十三 - 资源服务器–底层原理》那一章,就讲过资源服务器是通过/.well-known/openid-configuration请求获得jwt-set-uri的。
2.2 OidcUserInfoEndpointFilter1)该过滤器是拦截/userinfo请求,也就是获得用户信息
2)其最终的通过OidcUserInfoAuthenticationProvider来组装用户信息返回的
3)因此该过滤器只是为了实现OIDC协议的5.3.1小结
2.3 OidcLogoutEndpointFilter该过滤器是为了实现OIDC协议的2小结向授权服务器发起登出的功能。这里就不解析了。
3 自定义用户信息我们发现在访问/userinfo接口时,只返回一个用户名,如果我们想返回更多的信息,应该如何操作呢?
3.1 OidcUserInfoAuthenticationProvider返回用户信息1)我们先来看看OidcUserInfoAuthenticationProvider是如何返回用户信息
2)我们重点来看看userInfoMapper这个是如何组装
3)这里有一个小细节,就是它是根据请求授权的scopes来返回的,其映射如下表
scopeValueopenidid_token, subprofilename, family_name, given_name, middle_name, nickname, prefered_userame, profle, picture, website, gender, bithdate, zoneinfo, locale, updated_atemailemail、 email_verifiedphonephone_number、phone_number_verifiedaddressaddress 3.2 自定义用户信息现在我们知道其通过id_token里面的信息取获取的。那么我们有2种方式可以自定义用户信息:
第一种方法:自定义id_token,将信息放入id_token中,我们需要自定义OAuth2TokenGenerator。或许你存一些无关紧要的信息还是可以接受的,但是如果信息中包括个人隐私以及系统权限等信息,就会使得id_token暴露太多信息。如果想实现这个,参考官方的案例第二种方法:自定义OidcUserInfoAuthenticationProvider,我们可以从数据库获取用户信息,然后通过授权的scopes控制返回信息。这样既能自定义,也能做权限控制。 3.2.1 代码逻辑代码参考lesson13子模块,下面我们就采用第二种方法来实现自定义返回用户信息
前提条件:使用数据库保存用户信息,因此使用原先oauth_study数据库,并创建t_user表
1)在oauth_study数据库,创建t_user表(词表在lesson06子模块创建过,如果已经创建,就直接使用即可)
-- oauth_study.t_user definition CREATE TABLE oauth_study.`t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(100) NOT NULL, `password` varchar(100) NOT NULL, `email` varchar(100) DEFAULT NULL, `phone` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; INSERT INTO oauth_study.t_user (username, password, email, phone) VALUES('test', '{noop}1234', 'test@demo ', '13788888888');2)创建lesson13子模块,其pom引入如下
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> </dependency> <!-- lombok依赖,用于get/set的简便--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!-- mysql依赖,用于连接mysql数据库--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!-- mybatis-plus依赖,用于使用mybatis-plus--> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> </dependency> <!-- pool2和druid依赖,用于mysql连接池--> <dependency> <groupId>org.apache mons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> </dependency> </dependencies>3)在resources目录下,创建yaml文件
server: port: 9000 logging: level: org.springframework.security: trace spring: # 配置数据源 datasource: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/oauth_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true username: root password: root druid: initial-size: 5 min-idle: 5 maxActive: 20 maxWait: 3000 timeBetweenEvictionRunsMillis: 60000 minEvictableIdleTimeMillis: 300000 validationQuery: select 'x' testWhileIdle: true testOnBorrow: false testOnReturn: false poolPreparedStatements: false filters: stat,wall,slf4j connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200 # mybatis-plus的配置 mybatis-plus: global-config: banner: false mapper-locations: classpath:mappers/*.xml type-aliases-package: com.demo.lesson13.entity # 将handler包下的TypeHandler注册进去 type-handlers-package: com.demo.lesson13.handler configuration: cache-enabled: false local-cache-scope: statement4)在entity包下,创建LoginUserDetails和TUser,是用于Spring Security的用户存入数据库
/** * 扩展Spring Security的UserDetails */ @Data @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class LoginUserDetails implements UserDetails { private TUser tUser; @Override @JsonIgnore public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(); } @Override public String getPassword() { return tUser.getPassword(); } @Override public String getUsername() { return tUser.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } } /** * 表user结构 */ @Data public class TUser implements Serializable { @TableId(type = IdType.ASSIGN_ID) private Long id; private String username; private String password; private String email; private String phone; }5)在mapper包下,创建TUserMapper
@Mapper public interface TUserMapper { // 根据用户名,查询用户信息 @Select("select * from t_user where username = #{username}") TUser selectByUsername(String username); }6)在service包下,创建UserDetailsServiceImpl ,用于覆盖默认的用户查询
@Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private TUserMapper tUserMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 查询自己数据库的用户信息 TUser user = tUserMapper.selectByUsername(username); if(user == null){ throw new UsernameNotFoundException(username); } return new LoginUserDetails(user); } }7)在provider包下,创建MyOidcUserInfoAuthenticationProvider类,几乎复制OidcUserInfoAuthenticationProvider,并做小部分修改
public class MyOidcUserInfoAuthenticationProvider implements AuthenticationProvider { private final Log logger = LogFactory.getLog(getClass()); private final OAuth2AuthorizationService authorizationService; private final TUserMapper tUserMapper; private DefaultOidcUserInfoMapper userInfoMapper = new MyOidcUserInfoAuthenticationProvider.DefaultOidcUserInfoMapper(); /** * Constructs an {@code OidcUserInfoAuthenticationProvider} using the provided * parameters. * @param authorizationService the authorization service */ public MyOidcUserInfoAuthenticationProvider(OAuth2AuthorizationService authorizationService, TUserMapper tUserMapper) { Assert.notNull(authorizationService, "authorizationService cannot be null"); Assert.notNull(tUserMapper, "tUserMapper cannot be null"); this.authorizationService = authorizationService; this.tUserMapper = tUserMapper; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OidcUserInfoAuthenticationToken userInfoAuthentication = (OidcUserInfoAuthenticationToken) authentication; AbstractOAuth2TokenAuthenticationToken<?> accessTokenAuthentication = null; if (AbstractOAuth2TokenAuthenticationToken.class .isAssignableFrom(userInfoAuthentication.getPrincipal().getClass())) { accessTokenAuthentication = (AbstractOAuth2TokenAuthenticationToken<?>) userInfoAuthentication .getPrincipal(); } if (accessTokenAuthentication == null || !accessTokenAuthentication.isAuthenticated()) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN); } String accessTokenValue = accessTokenAuthentication.getToken().getTokenValue(); OAuth2Authorization authorization = this.authorizationService.findByToken(accessTokenValue, OAuth2TokenType.ACCESS_TOKEN); if (authorization == null) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN); } if (this.logger.isTraceEnabled()) { this.logger.trace("Retrieved authorization with access token"); } OAuth2Authorization.Token<OAuth2AccessToken> authorizedAccessToken = authorization.getAccessToken(); if (!authorizedAccessToken.isActive()) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN); } if (!authorizedAccessToken.getToken().getScopes().contains(OidcScopes.OPENID)) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INSUFFICIENT_SCOPE); } OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class); if (idToken == null) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN); } // 修改1:此处从数据库获得数据,并塞入idToken即可 TUser tUser = null; if(idToken.getClaims()!=null&&idToken.getClaims().get(StandardClaimNames.SUB) instanceof String){ tUser = tUserMapper.selectByUsername((String) idToken.getClaims().get(StandardClaimNames.SUB)); } if (this.logger.isTraceEnabled()) { this.logger.trace("Validated user info request"); } OidcUserInfoAuthenticationContext authenticationContext = OidcUserInfoAuthenticationContext .with(userInfoAuthentication) .accessToken(authorizedAccessToken.getToken()) .authorization(authorization) .build(); // 修改2:传入tUser OidcUserInfo userInfo = this.userInfoMapper.apply(authenticationContext, tUser); if (this.logger.isTraceEnabled()) { this.logger.trace("Authenticated user info request"); } return new OidcUserInfoAuthenticationToken(accessTokenAuthentication, userInfo); } @Override public boolean supports(Class<?> authentication) { return OidcUserInfoAuthenticationToken.class.isAssignableFrom(authentication); } /** * Sets the {@link Function} used to extract claims from * {@link OidcUserInfoAuthenticationContext} to an instance of {@link OidcUserInfo} * for the UserInfo response. * * <p> * The {@link OidcUserInfoAuthenticationContext} gives the mapper access to the * {@link OidcUserInfoAuthenticationToken}, as well as, the following context * attributes: * <ul> * <li>{@link OidcUserInfoAuthenticationContext#getAccessToken()} containing the * bearer token used to make the request.</li> * <li>{@link OidcUserInfoAuthenticationContext#getAuthorization()} containing the * {@link OidcIdToken} and {@link OAuth2AccessToken} associated with the bearer token * used to make the request.</li> * </ul> * @param userInfoMapper the {@link Function} used to extract claims from * {@link OidcUserInfoAuthenticationContext} to an instance of {@link OidcUserInfo} */ public void setUserInfoMapper(DefaultOidcUserInfoMapper userInfoMapper) { Assert.notNull(userInfoMapper, "userInfoMapper cannot be null"); this.userInfoMapper = userInfoMapper; } private static final class DefaultOidcUserInfoMapper { // @formatter:off private static final List<String> EMAIL_CLAIMS = Arrays.asList( StandardClaimNames.EMAIL, StandardClaimNames.EMAIL_VERIFIED ); private static final List<String> PHONE_CLAIMS = Arrays.asList( StandardClaimNames.PHONE_NUMBER, StandardClaimNames.PHONE_NUMBER_VERIFIED ); private static final List<String> PROFILE_CLAIMS = Arrays.asList( StandardClaimNames.NAME, StandardClaimNames.FAMILY_NAME, StandardClaimNames.GIVEN_NAME, StandardClaimNames.MIDDLE_NAME, StandardClaimNames.NICKNAME, StandardClaimNames.PREFERRED_USERNAME, StandardClaimNames.PROFILE, StandardClaimNames.PICTURE, StandardClaimNames.WEBSITE, StandardClaimNames.GENDER, StandardClaimNames.BIRTHDATE, StandardClaimNames.ZONEINFO, StandardClaimNames.LOCALE, StandardClaimNames.UPDATED_AT ); // @formatter:on public OidcUserInfo apply(OidcUserInfoAuthenticationContext authenticationContext, TUser tUser) { OAuth2Authorization authorization = authenticationContext.getAuthorization(); OidcIdToken idToken = authorization.getToken(OidcIdToken.class).getToken(); // 修改3:组装新的map,为了演示,我们只需要传入电话和email做演示 Map<String, Object> map = new ConcurrentHashMap<>(idToken.getClaims()); map.put(StandardClaimNames.EMAIL, tUser.getEmail()); map.put(StandardClaimNames.PHONE_NUMBER, tUser.getPhone()); OAuth2AccessToken accessToken = authenticationContext.getAccessToken(); Map<String, Object> scopeRequestedClaims = getClaimsRequestedByScope(map, accessToken.getScopes()); return new OidcUserInfo(scopeRequestedClaims); } private static Map<String, Object> getClaimsRequestedByScope(Map<String, Object> claims, Set<String> requestedScopes) { Set<String> scopeRequestedClaimNames = new HashSet<>(32); scopeRequestedClaimNames.add(StandardClaimNames.SUB); if (requestedScopes.contains(OidcScopes.ADDRESS)) { scopeRequestedClaimNames.add(StandardClaimNames.ADDRESS); } if (requestedScopes.contains(OidcScopes.EMAIL)) { scopeRequestedClaimNames.addAll(EMAIL_CLAIMS); } if (requestedScopes.contains(OidcScopes.PHONE)) { scopeRequestedClaimNames.addAll(PHONE_CLAIMS); } if (requestedScopes.contains(OidcScopes.PROFILE)) { scopeRequestedClaimNames.addAll(PROFILE_CLAIMS); } Map<String, Object> requestedClaims = new HashMap<>(claims); requestedClaims.keySet().removeIf(claimName -> !scopeRequestedClaimNames.contains(claimName)); return requestedClaims; } } }8)在config包下,新建JdbcSecurityConfig配置授权服务器的客户端
@Configuration public class JdbcSecurityConfig { @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) // 客户端id .clientId("oidc-client") // 客户端密码 .clientSecret("{noop}secret") // 客户端认证方式 .clientAuthenticationMethods(methods ->{ methods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); }) // 配置授权码模式 .authorizationGrantTypes(grantTypes -> { grantTypes.add(AuthorizationGrantType.AUTHORIZATION_CODE); }) // 需要授权确认 .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) // 回调地址 .redirectUri("http://localhost:8080/login/oauth2/code/oidc-client") .postLogoutRedirectUri("http://localhost:8080/") // 授权范围 .scopes(scopes->{ scopes.add(OidcScopes.OPENID); scopes.add(OidcScopes.PROFILE); scopes.add(OidcScopes.EMAIL); }) .build(); return new InMemoryRegisteredClientRepository(registeredClient); } @Bean public OAuth2AuthorizationService oAuth2AuthorizationService(){ return new InMemoryOAuth2AuthorizationService(); } }9)在config包下,新建SecurityConfig进行授权服务器的配置
@Configuration public class SecurityConfig { @Autowired private OAuth2AuthorizationService oAuth2AuthorizationService; @Autowired private TUserMapper tUserMapper; // 自定义授权服务器的Filter链 @Bean @Order(Ordered.HIGHEST_PRECEDENCE) SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) // oidc配置 .oidc(oidc-> oidc.userInfoEndpoint( userinfo -> userinfo.authenticationProvider(new MyOidcUserInfoAuthenticationProvider(oAuth2AuthorizationService, tUserMapper)))); // 同时作为资源服务器,使用/userinfo接口 http.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())); // 异常处理 http.exceptionHandling((exceptions) -> exceptions.authenticationEntryPoint( new LoginUrlAuthenticationEntryPoint("/login"))); return http.build(); } // 自定义Spring Security的链路。如果自定义授权服务器的Filter链,则原先自动化配置将会失效,因此也要配置Spring Security @Bean @Order(SecurityProperties.BASIC_AUTH_ORDER) SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((authorize) -> authorize .anyRequest().authenticated()).formLogin(withDefaults()); return http.build(); } } 3.2.2 演示效果1)请求授权code
2)登录
3)再次请求授权码code,state参数的值来自上一步。
4)请求token
5)请求用户信息
6)你可以尝试scopes中加入phone,则会返回用户电话号码。当然DefaultOidcUserInfoMapper还是比较简单,实际业务可能会有很多用户数据和权限控制,这个就根据自己的实际需求做吧。
结语:目前为止,我们将Spring Security中如何实现OIDC的内容都讲完了。下面我们还继续了解其它高级特性。
基于SpringSecurity6的OAuth2系列之十九-高级特性--OIDC1.0协议之二由讯客互联软件开发栏目发布,感谢您对讯客互联的认可,以及对我们原创作品以及文章的青睐,非常欢迎各位朋友分享到个人网站或者朋友圈,但转载请说明文章出处“基于SpringSecurity6的OAuth2系列之十九-高级特性--OIDC1.0协议之二”
下一篇
计算机网络原理试题六