之所以想写这一系列,是因为之前工作过程中使用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 基础代码
- 3 不同的认证
- 3.1 基于client_secret_basic认证
- 3.1.1 原理
- 3.1.2 代码实现
- 3.2 基于client_secret_post认证
- 3.2.1 原理
- 3.2.2 代码实现
- 3.3 基于client_secret_jwt和private_key_jwt认证
- 3.3.1 原理
- 3.3.2 代码实现
- 3.4 基于none认证
- 3.5 基于tls_client_auth和self_signed_tls_client_auth认证
从本章开始,就会讲解一些高级的特性,这些非必须的,看你项目是否有需要。这一章讲一下客户端认证的方式。我们来看一下lesson02子模块的yaml文件一个配置,client-authentication-methods配置了client_secret_basic,如下图:
这是配置的客户端认证的方式,在我们执行获取token的时候,需要在Authorization 配置,这就是client_secret_basic的认证模式,如下图:
为了传输的安全性,OAuth2提供了多种不同的客户端认证方式,包括client_secret_basic、client_secret_post、client_secret_jwt、private_key_jwt、none、tls_client_auth和self_signed_tls_client_auth。下面我们先讲一下原理,再一一讲解这些不同的认证方式:
1 客户端认证原理
在《系列之八 - 授权服务器–Spring Authrization Server的基本原理》中,我们知道客户端的认证通过OAuth2ClientAuthenticationFilter过滤器来,我们下面看看源代码:
从上图中,我们看到有三部分比较重要:
- 1)匹配请求认证的URL:主要拦截/oauth2/token、/oauth2/introspect、/oauth2/revoke
- 2)使用authenticationConverter从请求中获取客户端信息:主要有client_secret_basic、client_secret_post、client_secret_jwt、private_key_jwt、none、tls_client_auth和self_signed_tls_client_auth不同认证方式
- 3)对客户端进行认证:对应不同的认证方式,采用不同的AuthenticationProvider来做认证操作
其中第二步中,authenticationConverter是一个代理的Converter,由以下图不同转换器组成,可以匹配OAuth2规定的不同认证方式,这就是不同认证方式获取的方式,下面我们就开始对不同转换器进行解读和演示。
其中第三步,AuthenticationProvider是一个代理,由以下图不同的AuthenticationProvider组成:
这样通过不同AuthenticationProvider和authenticationConverter就可以匹配OAuth2规定的不同认证方式,因此我们知道有3个关键的点决定客户端认证:
- client-authentication-methods:其配置用于支持哪种客户端认证方式
- authenticationConverter:就是为了解析参数不同客户端认证传入的参数
- AuthenticationProvider:不同客户端的认证
以下是不同认证方式的汇总:
认证方式 | 说明 |
---|---|
client_secret_basic、client_secret_post | 采用客户端名称+密码的方式,两种方式是传递方式不同,一种通过请求头(Authorization),一种是通过Body参数 |
client_secret_jwt、private_key_jwt | 采用jwt格式的token |
none | 结合PKCE的认证 |
tls_client_auth、self_signed_tls_client_auth | 结合证书的认证 |
下面我们开始验证不同客户端认证:
2 基础代码
本次演示代码以lesson09子模块为授权服务器,使用Postman进行访问,注意:这里为了方便使用客户端模式,而非授权码模式,后面各种认证方式都是基于lesson09子模块进行改进。
1)新建lesson09子模块,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>
</dependencies>
2)配置yaml文件,主要配置端口和security的用户密码
server:
port: 9000
logging:
level:
org.springframework.security: trace
spring:
security:
# 使用security配置授权服务器的登录用户和密码
user:
name: user
password: 1234
3)在config包下,配置SecurityConfig
@Configuration
public class SecurityConfig {
// 自定义授权服务器的Filter链
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// oidc配置
.oidc(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
.requestMatchers("/jwt").permitAll()
.anyRequest().authenticated()).formLogin(withDefaults());
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient3 = RegisteredClient.withId(UUID.randomUUID().toString())
// 客户端id
.clientId("oidc-client")
// 客户端密码
.clientSecret("{noop}secret")
// 客户端认证方式
.clientAuthenticationMethods(methods ->{
methods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
})
// 这里为了方便采用客户端模式,而非授权码模式
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
// 回调地址
.redirectUri("http://localhost:8080/login/oauth2/code/oidc-client")
.postLogoutRedirectUri("http://localhost:8080/")
// 授权范围
.scopes(scopes->{
scopes.add(OidcScopes.OPENID);
scopes.add(OidcScopes.PROFILE);
})
.build();
return new InMemoryRegisteredClientRepository(registeredClient3 );
}
}
4)配置启动类Oauth2Lesson09ServerApplication,并启动项目
3 不同的认证
3.1 基于client_secret_basic认证
将 clientId 和 clientSecret 拼接并编码放到请求头(Authorization)去请求授权服务器,如果使用postman工具,则会自动完成这过程(clientId 和 clientSecret 通过 ‘:’ 号拼接,并使用 Base64 进行编码得到一个字符串)
3.1.1 原理
参数转换:ClientSecretBasicAuthenticationConverter
认证处理:ClientSecretAuthenticationProvider
client_secret_basic的认证方式是基于ClientSecretBasicAuthenticationConverter转换器来获取请求传过来的客户端信息,如下图:
3.1.2 代码实现
1)客户端配置的client-authentication-methods中增加client_secret_basic认证方式
2)在请求token时,在header的Authorization字段配置客户端即可,如下图:
参数:Authorization字段,设置为Basic Auth,并填写客户端信息的Username和Password
3.2 基于client_secret_post认证
该认证方法和client_secret_basic基本上一样,采用同一个ClientSecretAuthenticationProvider进行认证,唯一不同的是将 clientId 和 clientSecret 放到Body中参数去请求授权服务器
3.2.1 原理
参数转换:ClientSecretPostAuthenticationConverter
认证处理:ClientSecretAuthenticationProvider
client_secret_post的认证方式是基于ClientSecretPostAuthenticationConverter转换器来获取请求传过来的客户端信息,如下图:
3.2.2 代码实现
1)客户端配置的client-authentication-methods中增加client_secret_post认证方式
2)在请求token时,在参数配置客户端信息即可,如下图:
参数:Body字段,设置客户端信息的client_id和client_secret
3.3 基于client_secret_jwt和private_key_jwt认证
这两个一起讲其实是因为这2个实现方式一模一样,就是将客户端信息通过加密生成jwt的字符串,将jwt放入body参数client_assertion中发给授权服务器,授权服务器通过解析这个jwt字符串进行解密。唯一不同的是:
- client_secret_jwt是对称加密,通过clientSecret进行加密
- private_key_jwt是非对称加密,就是客户端发布自己的密钥,将公钥给授权服务器。
3.3.1 原理
参数转换:JwtClientAssertionAuthenticationConverter
认证处理:JwtClientAssertionAuthenticationProvider
client_secret_jwt的认证方式是基于JwtClientAssertionAuthenticationConverter转换器来获取请求传过来的客户端信息,如下图:
从源代码可以看到,是基于请求参数通过JWT方式来传递客户端信息,因此我们只需要先在client-authentication-methods配置client_secret_jwt,然后在请求token时,在参数配置client_assertion_type、client_assertion(JWT方式加密的客户端信息)以及client_id。我们还需要看一下JwtClientAssertionAuthenticationProvider如何解密JWT的客户端信息,如下图:
从上图我们知道,是通过JwtClientAssertionDecoderFactory,而JwtClientAssertionDecoderFactory是通过客户端配置的ClientSettings来配置JWT的加密方式进行解密,会判断对称或者非对称加密,如下图:
3.3.2 代码实现
我们这里就演示一个非对称加密方式private_key_jwt,以SignatureAlgorithm.RS256加密方式,密钥采用lesson08子模块的demo.jks。关于对称加密采用MacAlgorithm.HS256即可。
注意:从源代码看,其实你无论配置private_key_jwt或者client_secret_jwt都无所谓,对不对称关键在于ClientSettings的配置,如果配置SignatureAlgorithm.RS256就是非对称加密,配置MacAlgorithm.HS256就是对称加密
1)将lesson08子模块的resources目录下的demo.jks拷贝到lesson09子模块的resources目录下
2)在config包下SecurityConfig,进行客户端配置的client-authentication-methods中增加private_key_jwt认证方式以及密钥的配置
3)在config包下SecurityConfig,配置/jwt可以无权限访问
4)在controller包下,现在JwtController,包括发布jwt接口以及加载本地demo.jks
@RestController
public class JwtController {
/**
* 返回公钥
* @return
*/
@GetMapping("/jwt")
public String jwt(){
JWKSet jwkSet;
try {
JWKSelector jwkSelector = new JWKSelector(new JWKMatcher.Builder().build());
jwkSet = new JWKSet(jwkSource().get(jwkSelector, null));
}
catch (Exception ex) {
throw new IllegalStateException("Failed to select the JWK(s) -> " + ex.getMessage(), ex);
}
return jwkSet.toString();
}
private static String UUID_STR = "b07b297d-a1e3-4a86-a8fe-632ebd61545b";
// Jwt加密
private static JwtEncoder jwtEncoder() {
NimbusJwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource());
return jwtEncoder;
}
// Jwt解密
private static JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey((RSAPublicKey)generateRsaKey().getPublic()).build();
return jwtDecoder;
}
// 获取JWKSource
private static JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID_STR)
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
// 获取本地的demo.jks
private static KeyPair generateRsaKey() {
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("demo.jks"), "linmoo".toCharArray());
KeyPair keyPair = factory.getKeyPair("demo", "linmoo".toCharArray());
return keyPair;
}
public static void main(String[] args) {
String clientId = "oidc-client";
// 至少以下四项信息
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder();
claimsBuilder
// 发布者
.issuer(clientId)
// 对象
.subject(clientId)
// 授权服务器地址
.audience(Collections.singletonList("http://localhost:9000"))
// 发布时间
.issuedAt((new Timestamp(System.currentTimeMillis())).toInstant())
// 过期时间
.expiresAt((new Date(System.currentTimeMillis() + 1000 * 60 * 10)).toInstant())
.id(UUID.randomUUID().toString());
JwsHeader.Builder jwsHeaderBuilder = JwsHeader.with(SignatureAlgorithm.RS256);
JwsHeader jwsHeader = jwsHeaderBuilder.build();
JwtClaimsSet claims = claimsBuilder.build();
Jwt jwt = jwtEncoder().encode(JwtEncoderParameters.from(jwsHeader, claims));
String token = jwt.getTokenValue();
System.out.println(token);
Jwt newjwt =jwtDecoder().decode(token);
System.out.println(newjwt);
}
}
5)运行JwtController的main函数,生成一个token,将token放到postman的client_assertion参数
6)在请求token时,在参数配置client_id、client_assertion_type和client_assertion即可,如下图:
参数:Body字段,设置客户端信息的client_id、client_assertion_type和client_assertion
3.4 基于none认证
none方式一开始看还以为是不需要认证,其实不是,我们知道客户端一般需要保存Secrets,但是有时候客户端可能只是一个前端应用,其Secrets有可能暴露在代码中,OAuth2为了防止这种情况,采用了PKCE方式。而这个PKCE方式就是采用none认证方式。这一部分我们下一章再讲,请耐心等待《系列之十六 - 高级特性–PKCE》
3.5 基于tls_client_auth和self_signed_tls_client_auth认证
这部分涉及到https以及证书认证的基础知识,相对会复杂一些,因此我们单独开两章来讲,放在后续讲,请耐心等待《系列之二十二 - 高级特性–TLS客户端认证方法之一》和《系列之二十三 - 高级特性–TLS客户端认证方法之二》中来讲
结语:本章讲了授权服务器对客户端认证的几种不同方式及其原理,并使用代码演示一遍。下一章我们继续讲述这一部分内容,也就是none方式的客户端认证,但是会有一个新的扩展-PKCE扩展,这是OAuth2.1版本才有的,说明Spring Security 6 还是走在比较前面。