招聘信息网站建设网站建设的实训技术总结
之所以想写这一系列,是因为之前工作过程中使用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 definitionCREATE 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: 9000logging:level:org.springframework.security: tracespring:# 配置数据源datasource:type: com.alibaba.druid.pool.DruidDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://127.0.0.1:3306/oauth_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=trueusername: rootpassword: rootdruid:initial-size: 5min-idle: 5maxActive: 20maxWait: 3000timeBetweenEvictionRunsMillis: 60000minEvictableIdleTimeMillis: 300000validationQuery: select 'x'testWhileIdle: truetestOnBorrow: falsetestOnReturn: falsepoolPreparedStatements: falsefilters: stat,wall,slf4jconnectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200# mybatis-plus的配置
mybatis-plus:global-config:banner: falsemapper-locations: classpath:mappers/*.xmltype-aliases-package: com.demo.lesson13.entity# 将handler包下的TypeHandler注册进去type-handlers-package: com.demo.lesson13.handlerconfiguration:cache-enabled: falselocal-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@JsonIgnorepublic Collection<? extends GrantedAuthority> getAuthorities() {return List.of();}@Overridepublic String getPassword() {return tUser.getPassword();}@Overridepublic String getUsername() {return tUser.getUsername();}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic 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 {@Autowiredprivate TUserMapper tUserMapper;@Overridepublic 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;}@Overridepublic 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:传入tUserOidcUserInfo userInfo = this.userInfoMapper.apply(authenticationContext, tUser);if (this.logger.isTraceEnabled()) {this.logger.trace("Authenticated user info request");}return new OidcUserInfoAuthenticationToken(accessTokenAuthentication, userInfo);}@Overridepublic 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:offprivate 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:onpublic 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 {@Beanpublic 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);}@Beanpublic OAuth2AuthorizationService oAuth2AuthorizationService(){return new InMemoryOAuth2AuthorizationService();}
}
9)在config包下,新建SecurityConfig进行授权服务器的配置
@Configuration
public class SecurityConfig {@Autowiredprivate OAuth2AuthorizationService oAuth2AuthorizationService;@Autowiredprivate 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的内容都讲完了。下面我们还继续了解其它高级特性。