基于Spring Security 6的OAuth2 系列之十九 - 高级特性--OIDC1.0协议之二
之所以想写这一系列,是因为之前工作过程中使用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项目上:https://github.com/forever1986/oauth2-study.git
目录
- 1 底层原理
- 2 几个OIDC的过滤器
- 2.1 OidcProviderConfigurationEndpointFilter
- 2.2 OidcUserInfoEndpointFilter
- 2.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 OidcProviderConfigurationEndpointFilter
OidcProviderConfigurationEndpointFilter过滤器有2段代码非常重要,一个是ENDPOINT_URI,可以看到是拦截/.well-known/openid-configuration请求的
一个是doFilterInternal方法,就是把授权服务器的配置信息发布出来。
因此该Filter中为了实现OIDC协议的4.1小结,为了发布一些授权服务器的信息。我们可以回忆一下,在《系列之十三 - 资源服务器–底层原理》那一章,就讲过资源服务器是通过/.well-known/openid-configuration请求获得jwt-set-uri的。
2.2 OidcUserInfoEndpointFilter
1)该过滤器是拦截/userinfo请求,也就是获得用户信息
2)其最终的通过OidcUserInfoAuthenticationProvider来组装用户信息返回的
3)因此该过滤器只是为了实现OIDC协议的5.3.1小结
2.3 OidcLogoutEndpointFilter
该过滤器是为了实现OIDC协议的2小结向授权服务器发起登出的功能。这里就不解析了。
3 自定义用户信息
我们发现在访问/userinfo接口时,只返回一个用户名,如果我们想返回更多的信息,应该如何操作呢?
3.1 OidcUserInfoAuthenticationProvider返回用户信息
1)我们先来看看OidcUserInfoAuthenticationProvider是如何返回用户信息
2)我们重点来看看userInfoMapper这个是如何组装
3)这里有一个小细节,就是它是根据请求授权的scopes来返回的,其映射如下表
scope | Value |
---|---|
openid | id_token, sub |
profile | name, family_name, given_name, middle_name, nickname, prefered_userame, profle, picture, website, gender, bithdate, zoneinfo, locale, updated_at |
email、 email_verified | |
phone | phone_number、phone_number_verified |
address | address |
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.com', '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.commons</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: statement
4)在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的内容都讲完了。下面我们还继续了解其它高级特性。