使用 JWT

在前面的案例中,我们一直都是使用的不透明令牌(Opaque Token),在实际开发中,JWT 令牌自前使用较多,因此本节我们来看一下如何在 OAuth2 中使用 JWT。

JWT

JWT 全称为 Json Web Token,它是一种 JSON 风格的轻量级授权和身份认证规范,可实现无状态、分布式的 Web 应用授权。

JWT 作为一种规范,并没有和某一种语言绑定在一起,开发者可以使用任何语言来实现 JWT。Java 中 JWT 相关的开源库也比较多,例如 jjwt、nimbus-jose-jwt 等。

JWT 数据格式

JWT 包含三部分数据:Header、Payload 与 Signature。

Header

头部,通常头部有两部分信息:

  • 声明类型,这里是 JWT。

  • 加密算法,自定义。

我们会对头部进行 Base64Url 编码(可解码),得到第一部分数据。

Payload

载荷,就是有效数据,在官方文档中(RFC7519)给了 7 个示例信息:

  • iss(issuer):签发人。

  • exp(expiration time):过期时间。

  • sub(subject):主题。

  • aud(audience):受众。

  • nbf(Not Before):生效时间。

  • iat(Issued At):签发时间。

  • jti(JWT ID):编号。

这部分也会采用 Base64Url 编码,得到第二部分数据。

Signature

签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的密钥 secret (密钥保存在服务端,不能泄漏给客户端)。这个密钥通过 Header 中配置的加密算法生成,用于验证整个数据完整性和可靠性。

生成的数据格式如图15-20所示。

image 2024 04 16 13 08 18 043
Figure 1. 图15-20 JWT数据格式

这里的数据使用 “.” 隔开成了三部分,分别对应前面提到的三部分。另外,这里数据是不换行的,图片换行只是为了展示方便而已。

OAuth2 中使用 JWT

Spring Security 官方推荐使用 nimbus-jose-jwt 来生成和解析 JWT 令牌,该库同时支持对称加密和非对称加密两种方式处理 JWT,本小节使用目前通用的非对称加密(RSA)来处理 JWT。

非对称加密有两种使用场景:

  • 加密场景:公钥负责加密,私钥负责解密。

  • 签名场景:私钥负责签名,公钥负责验证。

我们在 JWT 中使用的非对称加密属于签名场景。如果要使用 JWT,我们首先需要创建一个证书文件,这里使用 Java 自带的 keytool 工具来生成 jks 证书文件,该工具在 JDK 的 bin 目录下,生成过程如图 15-21 所示。

image 2024 04 16 13 11 05 403
Figure 2. 图15-21 证书生成过程

生成证书命令中,我们设置了生成证书的别名是 jwt,生成的证书文件是 jwt.jks。接下来输入密码以及其他信息即可,命令执行完成后,会在当前目录下生成一个 jwt.jks 文件,将该文件拷贝到 auth_server 项目的 resources 目录下,如图 15-22 所示。

image 2024 04 16 13 12 36 662
Figure 3. 图15-22 将生成的 jwt.jks 文件复制到 resources 目录下

接下来在 auth_server 项目中添加 JWT 依赖,代码如下(nimbus-jose-jwt 在资源服务器中有提供,所以只需要在授权服务器中添加即可):

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-jwt</artifactId>
    <version>1.1.0.RELEASE</version>
</dependency>
<dependency>
    <groupId>com.nimbusds</groupId>
    <artifactId>nimbus-jose-jwt</artifactId>
</dependency>

接下来进行 JWT 配置,首先对密钥进行配置,代码如下:

class KeyConfig {
	private static final String KEY_STORE_FILE = "jwt.jks";
	private static final String KEY_STORE_PASSWORD = "123456";
	private static final String KEY_ALIAS = "jwt";
	private static KeyStoreKeyFactory KEY_STORE_KEY_FACTORY = new KeyStoreKeyFactory(
			new ClassPathResource(KEY_STORE_FILE), KEY_STORE_PASSWORD.toCharArray());

	static RSAPublicKey getVerifierKey() {
		return (RSAPublicKey) getKeyPair().getPublic();
	}

	static RSAPrivateKey getSignerKey() {
		return (RSAPrivateKey) getKeyPair().getPrivate();
	}

	private static KeyPair getKeyPair() {
		return KEY_STORE_KEY_FACTORY.getKeyPair(KEY_ALIAS);
	}
}

KEY_STORE_FILE 就是生成的证书文件名,KEY_STORE_PASSWORD 则是生成证书时输入的密码,KEY_ALIAS 指证书别名,然后再通过 getVerifierKey 和 getSignerKey 两个方法分别返回公钥和私钥。

接下来配置 TokenStore,代码如下:

@Configuration
public class AccessTokenConfig {

    @Bean
    TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        RsaSigner signer = new RsaSigner(KeyConfig.getSignerKey());
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigner(signer);
        converter.setVerifier(new RsaVerifier(KeyConfig.getVerifierKey()));
        return converter;
    }
    @Bean
    public JWKSet jwkSet() {
        RSAKey.Builder builder = new RSAKey.Builder(KeyConfig.getVerifierKey())
                .keyUse(KeyUse.SIGNATURE)
                .algorithm(JWSAlgorithm.RS256);
        return new JWKSet(builder.build());
    }
}

此时提供的 TokenStore 实例是 JwtTokenStore,创建该实例时需要一个 JwtAccessTokenConverter 对象,该对象是一个令牌生成工具。JwtAccessTokenConverter 对象在创建时,配置一下签名以及验证者即可。最后还需要提供一包含公钥的 JWSet 对象,该对象接下来要暴露给资源服务器。

接下来配置 AuthorizationServer,主要在 AuthorizationServerTokenServices 实例中进行配置,代码如下:

@EnableAuthorizationServer
@Configuration
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
    @Autowired
    TokenStore tokenStore;
    @Autowired
    JwtAccessTokenConverter jwtAccessTokenConverter;

    @Bean
    AuthorizationServerTokenServices tokenServices() {
        DefaultTokenServices services = new DefaultTokenServices();
        services.setClientDetailsService(clientDetailsService);
        services.setSupportRefreshToken(true);
        services.setTokenStore(tokenStore);
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter));
        services.setTokenEnhancer(tokenEnhancerChain);
        return services;

    }
    //省略其他
}

主要是在 DefaultTokenServices 中配置 TokenEnhancer,将之前的 JwtAccessTokenConverter 注入进来即可。

最后我们还需要提供一个公钥接口,资源服务器将从该接口中获取到公钥,进而完成对 JWT 的校验:

@GetMapping(value = "/oauth2/keys")
public String keys() {
    return jwkSet.toString();
}

至此,我们的 auth_server 就改造完成了,接下来对 res_server 进行改造。

当采用 JWT 之后,资源服务器就不需要每次拿到令牌后都去调用授权服务器校验令牌,资源服务器只需要调用授权服务器接口获取到公钥即可。有了公钥,资源服务器就可以自已校验 JWT 令牌了。所以,对资源服务器的改动很简单,代码如下:

@Configuration
public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .oauth2ResourceServer().jwt()
                .jwkSetUri("http://auth.javaboy.org:8881/oauth2/keys");
    }
}

开启 JWT 并设置获取 JwkSet 的地址即可。

配置完成后,分别启动 auth_server 和 res_server,测试客户端依然使用 15.5.2.3 小节搭建的客户端,具体的测试过程这里就不再赘述了。