本文涵盖的内容:
- 使用加密签名验证令牌
- 在 OAuth 2 架构中使用 JSON Web Token
- 使用对称和非对称密钥签名令牌
- 向 JWT 添加自定义详细信息
在本文中,我们将讨论使用 JSON Web Token ( JWT ) 实现令牌。您在《OAuth 2 :实现资源服务器》学习了资源服务器需要验证授权服务器发出的令牌。我告诉过你三种方法
- 使用资源服务器和授权服务器之间的直接调用,我们在《OAuth 2 :实现资源服务器》第 2 节中实现了这一点
- 使用一个共享数据库来存储令牌,我们在 《OAuth 2 :实现资源服务器》 第 3 节中实现了这一点
- 使用加密签名,我们将在本章中讨论
使用加密签名来验证令牌的优点是允许资源服务器验证令牌,而不需要直接调用授权服务器,也不需要共享数据库。这种实现令牌验证的方法通常用于使用 OAuth 2 实现身份认证和授权的系统中。因此,您需要了解这种实现令牌验证的方法。我们将为这个方法编写一个例子,就像我们在《OAuth 2 :实现资源服务器》中为其他两个方法所做的那样。
1 在 JWT 中使用对称密钥签名的令牌
签名令牌最简单的方法是使用对称密钥。使用这种方法,使用相同的密钥,您既可以对令牌签名,又可以验证其签名。使用对称密钥进行令牌签名的优点是比本文后面讨论的其他方法更简单,而且速度更快。然而,正如您将看到的,它也有缺点。您不能总是与身份认证过程中涉及的所有应用程序共享用于令牌签名的密钥。我们将在第 2 节比较对称键和非对称键对时讨论这些优点和缺点。
现在,让我们启动一个新项目来实现一个使用使用对称密钥签名的 JWT 的系统。对于这个实现,我将实现一个项目为授权服务器,再实现一个项目用于资源服务器。我们首先简要回顾一下在《Spring Security 动手实践:职责分离》系列文章中详细介绍过的 JWT。然后,我们在一个示例中实现这些。
1.1 使用 JWT
在本节中,我们简要回顾一下 JWT。我们在《Spring Security 动手实践:职责分离》详细讨论了 JWT,但是我认为最好先复习一下 JWT 是如何工作的。然后,我们继续实现授权服务器和资源服务器。我们在本文中讨论的所有内容都依赖于 JWT,因此,这就是为什么我认为在进一步讨论第一个示例之前,必须先从这个复习开始。
JWT 是一个令牌实现。令牌由三个部分组成:头部、正文和签名。头部和正文中的详细信息用 JSON 表示,它们被 Base64 编码。第三部分是使用使用头部和正文作为输入的加密算法生成的签名( 图 1 )。加密算法也意味着需要一个密钥。这个密钥就像一个密码。拥有正确密钥的人可以对令牌进行签名或验证签名是否真实。如果令牌上的签名是真实的,则保证在签名后没有人更改该令牌。

图 1
图 1 一个 JWT 由三部分组成:头部、正文和签名。头部和正文包含 JSON 表示的详细信息。这些部件采用 Base64 编码,然后进行签名。令牌是由由点分隔的这三个部分组成的字符串。
当 JWT 被签名时,我们也称它为 JWS ( JSON Web Token Signed )。通常,应用加密算法对令牌进行签名就足够了,但有时您可以选择对其进行加密。如果一个令牌被签名,您可以看到它的内容,而不需要任何密钥或密码。但是,即使黑客看到了令牌中的内容,他们也不能更改令牌的内容,因为如果他们这样做,签名就会失效 ( 图 2 )。为了有效,签名必须
- 使用正确的密钥生成
- 匹配已签名的内容

图 2
图 2 黑客截取了一个令牌并更改了其内容。资源服务器拒绝调用,因为令牌的签名不再与内容匹配。
如果一个令牌被加密了,我们也称它为 JWE ( JSON Web Token Encrypted )。如果没有有效的密钥,您将无法看到加密令牌的内容。
1.2 实现授权服务器来发行 JWT
在本节中,我们将实现一个授权服务器,该服务器将 JWT 发送给客户端进行授权。您在《OAuth 2 :实现资源服务器》 学习了管理令牌的组件是 TokenStore。在本节中,我们要做的是使用 Spring Security 提供的 TokenStore的不同实现。我们使用的实现的名称是 JwtTokenStore,由它管理 JWT 。我们还将在本节中测试授权服务器。稍后,在 1.3 节中,我们将实现一个资源服务器,并拥有一个使用 JWT 的完整系统。您可以通过两种方式使用 JWT 实现令牌验证:
- 如果我们使用相同的密钥对令牌进行签名和验证签名,我们就说该密钥是对称的。
- 如果我们使用一个密钥对令牌进行签名,而使用另一个密钥来验证签名,则我们称使用了非对称密钥对。
在本例中,我们使用对称密钥实现签名。这种方法意味着授权服务器和资源服务器都知道并使用相同的密钥。授权服务器使用密钥对令牌进行签名,资源服务器使用相同的密钥验证签名 ( 图 3 )。

图 3
图 3 使用对称密钥。授权服务器和资源服务器都共享同一个密钥。授权服务器使用该密钥为令牌签名,而资源服务器使用该密钥来验证该签名。
让我们创建项目并添加所需的依赖项。下一个代码片段展示了我们需要添加的依赖关系,它们与我们在《OAuth 2 :实现授权服务器》和《OAuth 2 :实现资源服务器》中用于授权服务器的依赖关系相同。
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-web
org.springframework.cloud
spring-cloud-starter-oauth2
我们配置 JwtTokenStore 的方法与《OAuth 2 :实现资源服务器》示例配置 JdbcTokenStore 的方法相同。此外,我们需要定义一个 JwtAccessTokenConverter类型的对象。使用 JwtAccessTokenConverter,我们配置授权服务器如何验证令牌;在我们的例子中,使用对称密钥。下面的清单展示了如何在配置类中配置 JwtTokenStore 。
清单 1 配置 JwtTokenStore
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig
extends AuthorizationServerConfigurerAdapter {
//从 application.properties 文件中获取对称密钥的值
@Value("${jwt.key}")
private String jwtKey;
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(
ClientDetailsServiceConfigurer clients)
throws Exception {
clients.inMemory()
.withClient("client")
.secret("secret")
.authorizedGrantTypes("password", "refresh_token")
.scopes("read");
}
@Override
public void configure(
AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.authenticationManager(authenticationManager)
.tokenStore(tokenStore())
.accessTokenConverter(
jwtAccessTokenConverter());
//配置令牌存储和访问令牌转换器对象
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(
// 使用与之关联的访问令牌转换器创建令牌存储
jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
// 设置访问令牌转换器对象的对称密钥的值
converter.setSigningKey(jwtKey);
return converter;
}
}
我将此示例的对称密钥的值存储在 application.properties 文件中,如下一个代码段所示。但是,不要忘记签名密钥是敏感数据,您应该在现实场景中将其存储在密钥存储库中。
jwt.key=MjWP5L7CiD
请记住,在前面《OAuth 2 :实现授权服务器》和《OAuth 2 :实现资源服务器》的授权服务器示例中,对于每个授权服务器,我们还定义了 UserDetailsService 和 PasswordEncoder 。清单 2 提醒您如何为授权服务器配置这些组件。为了使解释简短,本章中我不会对下面的所有示例重复相同的清单。
清单 2 配置授权服务器的用户管理
@Configuration
public class WebSecurityConfig
extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService uds() {
var uds = new InMemoryUserDetailsManager();
var u = User.withUsername("john")
.password("12345")
.authorities("read")
.build();
uds.createUser(u);
return uds;
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Bean
public AuthenticationManager authenticationManagerBean()
throws Exception {
return super.authenticationManagerBean();
}
}
现在我们可以启动授权服务器并调用 /oauth/token 端点以获得访问令牌。下面的代码片段展示了 cURL 命令来调用 /oauth/token 端点:
curl -v -XPOST -u client:secret http://localhost:8080/oauth/token?grant_type=password&username=xiaohua&password=12345&scope=read
响应体:
{
"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXV...",
"token_type":"bearer",
"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...",
"expires_in":43199,
"scope":"read",
"jti":"7774532f-b74b-4e6b-ab16-208c46a19560"
}
您可以在响应中观察到,访问令和刷新令牌现在都是 JWT。在代码片段中,我已经缩短了令牌,以使代码片段更具可读性。您将在控制台的响应中看到,令牌要长得多。在下一个代码段中,您可以找到令牌正文的解码 ( JSON )形式:
{
"user_name": "xiaohua",
"scope": [
"read"
],
"generatedInZone": "Europe/Bucharest",
"exp": 1583874061,
"authorities": [
"read"
],
"jti": "38d03577-b6c8-47f5-8c06-d2e3a713d986",
"client_id": "client"
}
在设置了授权服务器后,我们现在就可以实现该资源服务器了。
1.3 实现使用 JWT 的资源服务器
在本节中,我们实现资源服务器,它使用对称密钥来验证由我们在 1.2 节中设置的授权服务器发出的令牌。 在本节的最后,您将了解如何编写一个完整的 OAuth 2 系统,该系统使用使用对称密钥签名的 JWT。 正如下面的代码片段所示,我们创建一个新项目并将所需的依赖项添加到 pom.xml 中。
org.springframework.boot
spring-boot-starter-oauth2-resource-server
org.springframework.boot
spring-boot-starter-web
org.springframework.cloud
spring-cloud-starter-oauth2
我没有在《OAuth 2 :实现授权服务器》和《OAuth 2 :实现资源服务器》中使用的基础上添加任何新的依赖项。因为我们需要一个端点来保护,所以我定义了一个控制器和一种方法来公开用于测试资源服务器的简单端点。 以下清单定义了控制器。
清单 3 HelloController 类
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello!";
}
}
现在我们有了一个要保护的端点,我们可以声明配置 TokenStore 的配置类。我们将为资源服务器配置 TokenStore,就像为授权服务器配置一样。最重要的方面是确保为密钥使用相同的值。资源服务器需要密钥来验证令牌的签名。下一个清单定义了资源服务器配置类。
清单 4 资源服务器配置类
@Configuration
@EnableResourceServer
public class ResourceServerConfig
extends ResourceServerConfigurerAdapter {
//从 application.properties 文件注入密钥值
@Value("${jwt.key}")
private String jwtKey;
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
//配置 TokenStore
resources.tokenStore(tokenStore());
}
//声明 TokenStore 并将其添加到 Spring 上下文
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(
jwtAccessTokenConverter());
}
//创建访问令牌转换器并设置用于验证令牌签名的对称密钥
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
converter.setSigningKey(jwtKey);
return converter;
}
}
注意
不要忘了在 application .properties 文件中设置密钥的值。
用于对称加密或签名的密钥只是字节的随机字符串。 您使用随机算法生成它。 在我们的示例中,您可以使用任何字符串值,例如“ abcde”。 在实际情况下,最好使用随机生成的值,长度最好大于258个字节。 有关更多信息,我建议使用 David Wong 的《 Real-World Cryptography 》(Manning,2020年)。 在 David Wong 的书的第8章中,您将找到有关随机性和秘钥的详细讨论:
https://livebook.manning.comhttps://livebook.manning.com/book/real-world-cryptography/chapter-8/
因为我在同一台计算机上本地运行授权服务器和资源服务器,所以我需要为这个应用程序配置一个不同的端口。下一个代码段显示了 application.properties 文件的内容:
server.port=9090
jwt.key=MjWP5L7CiD
现在我们可以启动资源服务器,并使用先前从授权服务器获得的有效 JWT 调用 /hello 端点。在我们的示例中,您必须将令牌添加到前缀为 “Bearer” 的请求的 Authorization HTTP 请求头中。下面的代码片段展示了如何使用 cURL 调用端点:
curl -H "Authorization:Bearer eyJhbGciOiJIUzI1NiIs..." http://localhost:9090/hello
响应体:
Hello!
您刚刚完成了使用 OAuth 2 和 JWT 作为令牌实现的系统。 如您所见,Spring Security 使此实现变得容易。 在本节中,您学习了如何使用对称密钥来签名和验证令牌。 但是您可能会发现在现实情况下的需求,其中在授权服务器和资源服务器上都不能使用相同的密钥。 在 2 节中,您将学习如何实现一个类似的系统,该系统使用非对称密钥对这些情况进行令牌验证。
使用不带 Spring Security OAuth 项目的对称密钥
正如我们在《OAuth 2 :实现资源服务器》中讨论的那样,您还可以将资源服务器配置为使用带有 oauth2ResourceServer() 的 JWT。 如前所述,这种方法更适合将来的项目,但您可能会在现有应用程序中找到它。 因此,您需要了解此方法以用于将来的实现,当然,如果要将现有项目迁移到该方法,则当然也要知道。 下一个代码片段向您展示如何在不使用 Spring Security OAuth 项目类的情况下使用对称密钥配置 JWT 身份验证:
@Configuration
public class ResourceServerConfig
extends WebSecurityConfigurerAdapter {
@Value("${jwt.key}")
private String jwtKey;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2ResourceServer(
c -> c.jwt(
j -> j.decoder(jwtDecoder());
));
}
// Omitted code
}
如您所见,这一次我使用 Customizer 对象的 jwt() 方法作为参数发送给 oauth2ResourceServer()。使用 jwt() 方法,我们配置了应用程序所需的详细信息以验证令牌。在本例中,因为我们讨论的是使用对称密钥进行验证,所以我在同一个类中创建了一个 JwtDecoder 来提供对称密钥的值。下面的代码片段展示了我如何使用 decoder() 方法设置这个解码器:
@Bean
public JwtDecoder jwtDecoder() {
byte [] key = jwtKey.getBytes();
SecretKey originalKey = new SecretKeySpec(key, 0, key.length, "AES");
NimbusJwtDecoder jwtDecoder =
NimbusJwtDecoder.withSecretKey(originalKey)
.build();
return jwtDecoder;
}
我们配置的元素是相同的!如果您选择使用这种方法来设置资源服务器,唯一不同的是语法。
2 在 JWT 中使用非对称密钥签名的令牌
在本节中,我们实现一个 OAuth 2 身份认证的示例,其中授权服务器和资源服务器使用非对称密钥对对令牌进行签名和验证。有时,授权服务器和资源服务器只共享一个密钥是不可行的,正如我们在第 1 节中实现的那样。通常,如果授权服务器和资源服务器不是由同一组织开发的,就会发生这种情况。在本例中,我们说授权服务器不 “信任 (trust ) ” 资源服务器,因此您不希望授权服务器与资源服务器共享密钥。而且,使用对称密钥,资源服务器就有了很大的能力:不仅可以验证令牌,还可以对其进行签名( 图 4 )。
注意
我看到过通过邮件或其他不安全的渠道交换对称密钥的情况。永远不要这样做!对称密钥是私钥。有密钥的人可以用它来访问系统。我的经验法则是,如果您需要在系统外共享密钥,那么它不应该是对称的。

图 4
图 4 如果黑客设法获得对称密钥,他们可以更改令牌并对其签名。这样,他们就可以访问用户的资源。
当我们不能假定授权服务器和资源服务器之间存在信任关系时,我们使用非对称密钥对。因此,您需要知道如何实现这样一个系统。在本节中,我们将通过一个示例向您展示如何实现此目标所需的所有方面。
什么是非对称密钥对,它是如何工作的? 这个概念很简单。非对称密钥对有两个密钥:一个称为私钥,另一个称为公钥。授权服务器使用私钥对令牌进行签名,而其他人只能通过使用私钥对令牌进行签名 ( 图 5)。

图 5
图 5 为了签名令牌,需要使用私钥。然后任何人都可以使用密钥对的公钥来验证签名者的身份。
公钥连接到私钥,这就是为什么我们称它为一对。但是公钥只能用于验证签名。没有人可以使用公钥对令牌进行签名 ( 图 6 )。

图 6
图 6 如果黑客设法获得一个公钥,他们将不能使用它来签名令牌。公钥只能用于验证签名。
2.1 生成密钥键值对
在本节中,我将教您如何生成非对称密钥对。 我们需要一个密钥对来配置在稍候 2.2 和 2.3 节中实现的授权服务器和资源服务器。 这是一个非对称密钥对(这意味着它具有授权服务器用于签名的私有部分和资源服务器用于验证签名的公共部分)。 为了生成密钥对,我使用 keytool 和 OpenSSL,这是两个易于使用的命令行工具。 您的 JDK 安装了 keytool,因此您可能已经在计算机上安装了它。 对于 OpenSSL,您需要从 https://www.openssl.org/ 下载。 如果您使用 OpenSSL 随附的 Git Bash,则无需单独安装。 我始终喜欢使用 Git Bash 进行这些操作,因为它不需要我单独安装这些工具。 有了这些工具后,您需要运行两个命令
- 生成一个私钥
- 获取前面生成的私钥的公钥
生成一个私钥
要生成私钥,请在下一个代码片段中运行 keytool 命令。它在一个名为 yyit.jks 的文件中生成一个私钥。我还使用密码 “yyit123” 来保护私钥,并使用别名 “yyit” 来为密钥指定名称。在以下命令中,可以看到生成密钥 RSA 的算法。
keytool -genkeypair -alias yyit -keyalg RSA -keypass yyit123 -keystore yyit.jks -storepass yyit123
获取公钥
可以使用 keytool 命令获取之前生成的私钥的公钥。
keytool -list -rfc --keystore yyit.jks | openssl x509 -inform pem -pubkey
系统提示您输入生成公钥时使用的密码;我的名字是 yyit123。然后,您应该在输出中找到公钥和证书。(对于本例,只有键的值是必需的。)这个键应该类似于下面的代码片段:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAijLqDcBHwtnsBw+WFSzG
VkjtCbO6NwKlYjS2PxE114XWf9H2j0dWmBu7NK+lV/JqpiOi0GzaLYYf4XtCJxTQ
DD2CeDUKczcd+fpnppripN5jRzhASJpr+ndj8431iAG/rvXrmZt3jLD3v6nwLDxz
pJGmVWzcV/OBXQZkd1LHOK5LEG0YCQ0jAU3ON7OZAnFn/DMJyDCky994UtaAYyAJ
7mr7IO1uHQxsBg7SiQGpApgDEK3Ty8gaFuafnExsYD+aqua1Ese+pluYnQxuxkk2
Ycsp48qtUv1TWp+TH3kooTM6eKcnpSweaYDvHd/ucNg8UDNpIqynM1eS7KpffKQm
DwIDAQAB
-----END PUBLIC KEY-----
就是这样!我们有一个用于签名 JWT 的私钥和一个用于验证签名的公钥。现在我们只需要在授权和资源服务器中配置它们。
2.2 实现使用私钥的授权服务器
在本节中,我们将授权服务器配置为使用私钥对 JWT 进行签名。 在 2.1 节中,您学习了如何生成私钥和公钥。 在本节中,我将创建一个 单独项目,但在 pom.xml 文件中将使用与在第 1 节中实现的授权服务器相同的依赖项。
我将私钥文件 yyit.jks 复制到应用程序的 resources 文件夹中。 我将密钥添加到 resources 文件夹中,因为它使我更容易直接从类路径中读取密钥。 但是,并非必须要包含在类路径中。 在 application.properties 文件中,我存储文件名,密钥的别名以及生成密码时用来保护私钥的密码。 我们需要这些详细信息来配置 JwtTokenStore。 下一个代码段向您展示了我的 application.properties 文件的内容:
password=yyit123
privateKey=yyit.jks
alias=yyit
与我们为授权服务器使用对称密钥所做的配置相比,唯一改变的是 JwtAccessTokenConverter 对象的定义。我们仍然使用 JwtTokenStore。如果您还记得,我们在第 1 节中使用 JwtAccessTokenConverter 来配置对称密钥。我们使用相同的 JwtAccessTokenConverter 对象来设置私钥。下面的清单显示了授权服务器的配置类。
清单 5 授权服务器和私钥的配置类
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig
extends AuthorizationServerConfigurerAdapter {
//1
@Value("${password}")
private String password;
//2
@Value("${privateKey}")
private String privateKey;
//3
@Value("${alias}")
private String alias;
//1,2,3 从 application.properties 文件中注入私钥文件的名称,别名和密码
@Autowired
private AuthenticationManager authenticationManager;
/ Omitted code
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
//创建一个 KeyStoreKeyFactory 对象从类路径读取私钥文件
KeyStoreKeyFactory keyStoreKeyFactory =
new KeyStoreKeyFactory(
new ClassPathResource(privateKey),
password.toCharArray()
);
//使用KeyStoreKeyFactory 对象检索密钥对,并将密钥对设置为 JwtAccessTokenConverter 对象
converter.setKeyPair(
keyStoreKeyFactory.getKeyPair(alias));
return converter;
}
}
现在可以启动授权服务器并调用 /oauth/token 端点来生成新的访问令牌。当然,您只看到创建了一个普通的 JWT,但现在的区别在于,要验证它的签名,您需要使用这对中的公钥。顺便说一下,别忘了令牌只是签名,没有加密。下面的代码片段展示了如何调用 /oauth/token 端点:
curl -v -XPOST -u client:secret "http://localhost:8080/oauth/token?grant_type=password&username=xiaohua&passwopa=12345&scope=read"
响应体:
{
"access_token":"eyJhbGciOiJSUzI1NiIsInR5...",
"token_type":"bearer",
"refresh_token":"eyJhbGciOiJSUzI1NiIsInR...",
"expires_in":43199,
"scope":"read",
"jti":"8e74dd92-07e3-438a-881a-da06d6cbbe06"
}
2.3 实现使用公钥的资源服务器
在本节中,我们实现一个使用公共密钥来验证令牌签名的资源服务器。 当我们完成本节后,您将拥有一个完整的系统,该系统可以通过 OAuth 2 实现身份认证,并使用公私钥对来保护令牌。 授权服务器使用私钥对令牌签名,而资源服务器使用公钥来验证签名。 请注意,我们仅使用密钥对令牌进行签名,而不对密钥进行加密。 我们在 pom.xml 中使用与本文前面各节中的示例相同的依赖项。
资源服务器需要使用公钥来验证令牌的签名,因此让我们将该密钥添加到 application.properties 文件中。 在 2.1 节中,您学习了如何生成公 钥。 下一个代码片段显示了我的 application.properites 文件的内容:
server.port=9090
publicKey=-----BEGIN PUBLIC KEY-----MIIBIjANBghk...-----END PUBLIC KEY-----
为了更好的可读性,我简化了公钥。下面的清单向您展示了如何在资源服务器的配置类中配置这个密钥。
清单 6 资源服务器和公钥的配置类
@Configuration
@EnableResourceServer
public class ResourceServerConfig
extends ResourceServerConfigurerAdapter {
// 从 application.properties 文件中注入密钥
@Value("${publicKey}")
private String publicKey;
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.tokenStore(tokenStore());
}
//在 Spring 上下文中创建并添加 JwtTokenStore
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(
jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
//设置令牌存储用于验证令牌的公钥
converter.setVerifierKey(publicKey);
return converter;
}
}
当然,为了有一个端点,我们还需要添加控制器。下一个代码片段定义了控制器:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello!";
}
}
让我们运行并调用端点以测试资源服务器。 这是命令:
curl -H "Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6I..." http://localhost:9090/hello
响应体:
Hello!
不使用 Spring Security OAuth 项目的非对称密钥
在本文中,如果应用程序使用非对称密钥进行令牌验证,我们将讨论将使用 Spring Security OAuth 项目的资源服务器迁移到一个简单的 Spring Security 项目时需要做的更改。实际上,使用非对称密钥与使用对称密钥的项目没有太大区别。唯一的更改是您需要使用的 JwtDecoder。在这种情况下,您需要配置密钥对的公共部分,而不是配置用于令牌验证的对称密钥。下面的代码片段展示了如何做到这一点:
public JwtDecoder jwtDecoder() {
try {
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
var key = Base64.getDecoder().decode(publicKey);
var x509 = new X509EncodedKeySpec(key);
var rsaKey = (RSAPublicKey) keyFactory.generatePublic(x509);
return NimbusJwtDecoder.withPublicKey(rsaKey).build();
} catch (Exception e) {
throw new RuntimeException("Wrong public key");
}
}
有了使用公钥验证令牌的 JwtDecoder 后,需要使用 oauth2ResourceServer() 方法设置解码器。就像对称密钥一样。下一个代码片段展示了如何做到这一点。
@Configuration
public class ResourceServerConfig
extends WebSecurityConfigurerAdapter {
@Value("${publicKey}")
private String publicKey;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.oauth2ResourceServer(
c -> c.jwt(
j -> j.decoder(jwtDecoder())
)
);
http.authorizeRequests()
.anyRequest().authenticated();
}
// Omitted code
}
2.4 使用端点公开公钥
在本节中,我们将讨论一种使资源服务器知道公钥的方法——授权服务器公开公钥。在第 2 节实现的系统中,我们使用公私密钥对对令牌进行签名和验证。我们在资源服务器端配置了公钥。资源服务器使用公钥验证 JWT。但是如果你想改变密匙对呢 ?最好不要永远保持相同的密匙对,这是您要在本节中学习实现的内容。随着时间的推移,你应该更换钥匙!这使得您的系统不易受到密钥窃取 ( 图 7 )。

图 7
图 7 如果密钥定期更换,系统的密钥被盗几率会降低。但是,如果在两个应用程序中都配置了密钥,则很难更换它们。
到目前为止,我们已经在授权服务器端配置了私钥,在资源服务器端配置了公钥 (图 7)。在两个地方设置使得钥匙更难管理。但是如果我们只在一边配置它们,您就可以更容易地管理密钥。解决方案是将整个密钥对移动到授权服务器端,并允许授权服务器使用端点公开公钥 ( 图 8 )。

图 8
图 8 两个密钥都在授权服务器上配置。为了获取公钥,资源服务器从授权服务器调用端点。这种方法允许我们更容易地更换密钥,因为我们只需要在一个地方配置它们。
我们使用一个单独的应用程序来证明如何使用 Spring Security 实现这个配置。 与前面示例一样,需要授权服务器和资源服务器。
对于授权服务器,我们将保持与 2.3 节中开发的项目相同的设置。我们只需要确保可以访问公开公钥的端点。是的,Spring Boot 已经配置了这样的端点,但它只是这样。默认情况下,所有请求都被拒绝。我们需要覆盖端点的配置,并允许任何具有客户端凭据的人访问它。在清单 7 中,您将看到需要对授权服务器的配置类进行的更改。这些配置允许任何具有有效客户端凭证的人调用端点以获得公钥。
清单 7 公开公钥的授权服务器的配置类
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig
extends AuthorizationServerConfigurerAdapter {
/ Omitted code
@Override
public void configure(
ClientDetailsServiceConfigurer clients)
throws Exception {
clients.inMemory()
.withClient("client")
.secret("secret")
.authorizedGrantTypes("password", "refresh_token")
.scopes("read")
.and()
//添加资源服务器用于调用端点的客户端凭证,端点将公开公钥
.withClient("resourceserver")
.secret("resourceserversecret");
}
@Override
public void configure(
AuthorizationServerSecurityConfigurer security) {
security.tokenKeyAccess
("isAuthenticated()");//配置授权服务器,为使用有效客户端凭证进行身份验证的任何请求公开公钥的端点
}
}
您可以启动授权服务器并调用 /oauth/token_key 端点,以确保正确地实现了配置。下一个代码片段展示了 cURL 调用:
curl -u resourceserver:resourceserversecret http://localhost:8080/oauth/token_key
响应体:
{
"alg":"SHA256withRSA",
"value":"-----BEGIN PUBLIC KEY----- nMIIBIjANBgkq... -----END PUBLIC KEY-----"
}
为了使资源服务器使用此端点并获取公钥,您只需要在其属性文件中配置端点和凭证。 下一个代码段定义了资源服务器的 application.properties 文件:
server.port=9090
security.oauth2.resource.jwt.key-uri=http://localhost:8080/oauth/token_key
security.oauth2.client.client-id=resourceserver
security.oauth2.client.client-secret=resourceserversecret
因为资源服务器现在从授权服务器的 /oauth/token_key 端点获取公钥,所以您不需要在资源服务器配置类中配置它。资源服务器的配置类可以保持为空,如下面的代码片段所示:
@Configuration
@EnableResourceServer
public class ResourceServerConfig
extends ResourceServerConfigurerAdapter {
}
您现在也可以启动资源服务器,并调用它公开的 /hello 端点,以查看整个设置是否如预期的那样工作。下一个代码片段将向您展示如何使用 cURL 调用 /hello 端点。在这里,你获得一个令牌,就像我们在 2.3 节中做的那样,并使用它来调用资源服务器的测试端点:
curl -H "Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI..." http://localhost:9090/hello
响应体:
Hello!
3 向 JWT 中添加自定义详细信息
在本节中,我们将讨论如何向 JWT 令牌添加自定义详细信息。在大多数情况下,您只需要 Spring Security 已经添加到令牌中的内容。然而,在实际场景中,您有时会发现需要在令牌中添加自定义信息的需求。在本节中,我们将实现一个示例,在该示例中您将了解如何更改授权服务器以添加 JWT 上的自定义详细信息,以及如何更改资源服务器以读取这些详细信息。如果您使用我们在前面的示例中生成的一个令牌并对其进行解码,您将看到 Spring Security 添加到令牌的默认值。下面的清单给出了这些默认值。
清单 8 授权服务器发行的 JWT 正文中的默认详细信息
{
"exp": 1582581543, ##令牌到期的时间戳
"user_name": "xiaohua", ##通过身份认证以允许客户端访问其资源的用户
"authorities": [ ## 授予该用户的权限
"read"
],
"jti": "8e208653-79cf-45dd-a702-f6b694b417e7", ## 令牌的唯一标识符
"client_id": "client", ##请求该令牌的客户端
"scope": [
"read" ## 授予给客户端的权限
]
}
如清单 8 所示,默认情况下,令牌通常存储基本授权所需的所有详细信息。但是,如果您的现实场景的需求要求更多的东西呢?
- 您可以在读者用来阅读书籍的应用程序中使用授权服务器。 某些端点仅应提供给特定评论数量以上的用户访问。
- 仅当用户从特定时区进行身份认证时,才需要允许调用。
- 您的授权服务器是一个社交网络,您的某些端点仅应由连接数量最少的用户访问。
对于我的第一个示例,您需要将评论数量添加到令牌中。 对于第二个,您添加了客户端连接所在的时区。 对于第三个示例,您需要为用户添加连接数。 无论您是哪种情况,都需要知道如何自定义 JWT。
3.1 配置授权服务器以将自定义详细信息添加到令牌
在本节中,我们将讨论为向令牌添加自定义详细信息而需要对授权服务器进行的更改。为了使示例简单,我假设需求是添加授权服务器本身的时区。要向令牌添加额外的细节,您需要创建一个 TokenEnhancer 类型的对象。下面的清单定义了我为这个示例创建的 TokenEnhancer 对象。
清单 9 自定义令牌增强器
public class CustomTokenEnhancer
implements TokenEnhancer { //实现 TokenEnhancer 接口
//重写 enhance() 方法,该方法接收当前令牌并返回增强令牌
@Override
public OAuth2AccessToken enhance(
OAuth2AccessToken oAuth2AccessToken,
OAuth2Authentication oAuth2Authentication) {
//基于接收到的令牌对象创建新的令牌对象
var token =
new DefaultOAuth2AccessToken(oAuth2AccessToken);
//将我们想要添加到令牌的详细信息定义为 Map
Map info =
Map.of("generatedInZone",
ZoneId.systemDefault().toString());
//将其他详细信息添加到令牌中
token.setAdditionalInformation(info);
//返回包含其他详细信息的令牌
return token;
}
}
TokenEnhancer 对象的 enhance() 方法接收我们增强的令牌作为参数,并返回 “增强的” 令牌,其中包含额外的详细信息。对于本例,我使用了在第 2 节中开发的相同应用程序,只更改了 configure() 方法以应用令牌增强器。下面的清单展示了这些更改。
清单 10 配置 TokenEnhancer 对象
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig
extends AuthorizationServerConfigurerAdapter {
// Omitted code
@Override
public void configure(
AuthorizationServerEndpointsConfigurer endpoints) {
//定义了一个 TokenEnhancerChain
TokenEnhancerChain tokenEnhancerChain
= new TokenEnhancerChain();
//将两个令牌增强器对象添加到列表中
var tokenEnhancers =
List.of(new CustomTokenEnhancer(),
jwtAccessTokenConverter());
//将令牌增强器列表添加到链中
tokenEnhancerChain
.setTokenEnhancers(tokenEnhancers);
endpoints
.authenticationManager(authenticationManager)
.tokenStore(tokenStore())
.tokenEnhancer(tokenEnhancerChain); //配置令牌增强器对象
}
}
正如您可以看到的,配置我们的自定义令牌增强器有点复杂。我们必须创建一个令牌增强器链,并设置整个链,而不是只设置一个对象,因为访问令牌转换器对象也是一个令牌增强器。如果我们只配置自定义令牌增强器,就会覆盖访问令牌转换器的行为。相反,我们将这两个对象都添加到职责链中,然后配置包含这两个对象的链。
让我们启动授权服务器,生成一个新的访问令牌,并检查它,看看它是什么样子。下一个代码片段向您展示了如何调用 /oauth/token 端点来获取访问令牌:
curl -v -XPOST -u client:secret "http://localhost:8080/oauth/token?grant_type=password&username=xiaohua&password=12345&scope=read"
响应体:
{
"access_token":"eyJhbGciOiJSUzI...",
"token_type":"bearer",
"refresh_token":"eyJhbGciOiJSUzI1...",
"expires_in":43199,
"scope":"read",
"generatedInZone":"Europe/Bucharest",
"jti":"0c39ace4-4991-40a2-80ad-e9fdeb14f9ec"
}
如果对令牌进行解码,则可以看到其正文类似于清单 11 中所示的正文。 您还可以进一步观察到,默认情况下,框架还会在响应中添加自定义详细信息。 但我建议您始终参考令牌中的任何信息。 请记住,通过对令牌进行签名,我们可以确保如果有人更改了令牌的内容,则签名不会得到验证。 这样,我们知道如果签名正确,则没有人会更改令牌的内容。 您对响应本身没有相同的保证。
清单 11 增强的 JWT 的正文
{
"user_name": "xiaohua",
"scope": [
"read"
],
"generatedInZone": "Europe/Bucharest",
"exp": 1582591525,
"authorities": [
"read"
],
"jti": "0c39ace4-4991-40a2-80ad-e9fdeb14f9ec",
"client_id": "client"
}
3.2 配置资源服务器以读取 JWT 的自定义详细信息
在本节中,我们将讨论需要对资源服务器进行的更改,以读取添加到 JWT 的附加信息。更改授权服务器以向 JWT 添加自定义细节后,您希望资源服务器能够读取这些信息。为了访问自定义详细信息,您需要在资源服务器中进行的更改非常简单。
我们在第 1 节中讨论过 AccessTokenConverter 是将令牌转换为身份认证的对象。这是我们需要更改的对象,以便它也考虑到令牌中的自定义详细信息。之前,您创建了 JwtAccessTokenConverter 类型的 Bean,如下面的代码片段所示:
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter = new JwtAccessTokenConverter();
converter.setSigningKey(jwtKey);
return converter;
}
我们使用这个令牌来设置资源服务器用于令牌验证的密钥。我们创建了 JwtAccessTokenConverter 的自定义实现,它还考虑了关于令牌的新信息。最简单的方法是扩展这个类并重写 extractAuthentication() 方法。此方法转换 Authentication 对象中的令牌。下一个清单展示了如何实现自定义 AcessTokenConverter。
清单 12 创建自定义 AccessTokenConverter
public class AdditionalClaimsAccessTokenConverter
extends JwtAccessTokenConverter {
@Override
public OAuth2Authentication
extractAuthentication(Map map) {
//应用由 `JwtAccessTokenConverter` 类实现的逻辑,并获得初始身份认证对象
var authentication =
super.extractAuthentication(map);
//将自定义详细信息添加到身份认证
authentication.setDetails(map);
//返回身份认证对象
return authentication;
}
}
在资源服务器的配置类中,您现在可以使用自定义访问令牌转换器。下一个清单定义了配置类中的 AccessTokenConverter Bean。
清单 13 定义新的 AccessTokenConverter Bean
@Configuration
@EnableResourceServer
public class ResourceServerConfig
extends ResourceServerConfigurerAdapter {
// Omitted code
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
var converter =
new AdditionalClaimsAccessTokenConverter();
converter.setVerifierKey(publicKey);
return converter;
}
}
测试更改的一种简单方法是将它们注入控制器类,并在 HTTP 响应中返回它们。清单 14 展示了如何定义控制器类。
清单 14 控制器类
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(OAuth2Authentication authentication) {
//获取添加到 Authentication 对象的额外信息
OAuth2AuthenticationDetails details =
(OAuth2AuthenticationDetails) authentication.getDetails();
//返回 HTTP 响应中的详细信息
return "Hello! " + details.getDecodedDetails();
}
}
您现在可以启动资源服务器,并使用包含自定义详细信息的 JWT 测试端点。下一个代码片段将向您展示如何调用 /hello 端点和调用的结果。getDecodedDetails() 方法返回一个包含令牌详细信息的 Map。在本例中,为了保持简单,我直接打印了 getDecodedDetails() 返回的整个值。如果您只需要使用一个特定的值,您可以检查返回的 Map 并使用其键获取所需的值。
curl -H "Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp... " http://localhost:9090/hello
响应体:
Hello! {user_name=john, scope=[read], generatedInZone=Europe/Bucharest, exp=1582595692, authorities=[read], jti=982b02be-d185-48de-a4d3-9b27337d1a46, client_id=client}
您可以在响应中发现新属性 generatedInZone=Europe/Bucharest。
总结
- 使用加密签名是当前应用程序在 OAuth 2 身份认证架构中验证令牌的常用方式。
- 当我们将令牌验证与加密签名结合使用时,JSON Web Token ( JWT ) 是使用最广泛的令牌实现。
- 您可以使用对称密钥对令牌进行签名和验证。虽然使用对称密钥是一种简单的方法,但是在授权服务器不信任资源服务器时不能使用它。
- 如果在您的实现中不能使用对称密钥,那么您可以使用非对称密钥对来实现令牌签名和验证。
- 建议定期更换密钥,以减少系统密钥被盗的风险。我们将密钥的周期性变化称为密钥的更换。
- 可以直接在资源服务器端配置公钥。虽然这种方法很简单,但它使密钥更换更加困难。
- 为了简化密钥更换,您可以在授权服务器端配置密钥,并允许资源服务器在特定端点读取它们。
- 您可以根据实现的需求,通过向其正文添加信息来定制 JWT。授权服务器将自定义详细信息添加到令牌正文,资源服务器将使用这些详细信息进行授权。