Spring Security + OAuth 2.0 + JWT 开发随笔

JWT 签名与验签

公钥与私钥生成

使用 JDK 提供的 keytool 工具生成 JKS 密钥库 (Java Key Store),认证授权服务器会使用私钥对 Token 进行签名,一般将生成的 shop.jks 文件放在 resources 目录下

1
keytool -genkey -alias shop -keyalg RSA -keypass 123456 -keystore shop.jks -storepass 123456

根据私钥生成公钥,将其保存在 public.crt 文件中,用于对 Token 进行验签,一般将其放 resources 目录下

1
keytool -list -rfc --keystore shop.jks | openssl x509 -inform pem -pubkey -noout
1
2
3
4
5
6
7
8
9
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtXKXj3JGNJNWVXg4+++4
FtNTJre+8kHLdPLwHJJcRw4aV7oMMjI1nesyj75w/kjRZImhbNo0poEu1jj+sDO9
UbLUHSy59zoDDMZTYmbkboDEpkFq3ZUhAoLtt5DtAgI8DkOK22RlSxXpcMvkeL8X
ziFizWf/HatSgAat/SfX+5dH3KX40piPv9kI5YVJz1GyD8xO4dN95tr0Ld7FDmdK
JBPWfkM+CMlKRhYqB+sAlaQW5/L3xb3WNftucC/RhdKT8/mmgMsIBhUZOS/1iFnD
KuPsEwU5xEQxK9pWX2bWsSkeOgQYJmQa6hiWBuujPUyOs4rICvniopxsW2yyPOFX
ZQIDAQAB
-----END PUBLIC KEY-----

认证授权服务器加载 JKS 秘钥库

认证授权服务器加载 JKS 秘钥库,从中获取密钥对(公钥 + 私钥),Java 示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
/**
* 从ClassPath下的密钥库中获取密钥对(公钥+私钥)
*
* @return
*/
@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("shop.jks"), "123456".toCharArray());
KeyPair keyPair = factory.getKeyPair("shop", "123456".toCharArray());
return keyPair;
}

认证授权服务器暴露获取公钥的接口

对外暴露 JWK Set URI 接口,让其他应用系统可以获取到公钥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RestController
@RequestMapping("/oauth")
public class JwkSetController {

@Autowired
private KeyPair keyPair;

/**
* 获取公钥
*
* @return
*/
@GetMapping("/.well-known/jwks.json")
public Map<String, Object> publicKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}

}

或者通过 KeyPair 来获取公钥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/oauth")
public class PublicKeyController {

@Autowired
private KeyPair keyPair;

/**
* 获取公钥
*
* @return
*/
@GetMapping("/publicKey")
public String publicKey() {
return Base64.encode(new String(keyPair.getPublic().getEncoded()));
}

}

或者直接使用 OAuth 2.0 内置的接口 /oauth/token_key 来获取公钥

1
2
# 下述的"127.0.0.1:8080"是认证授权服务器的地址
$ curl --request GET 'http://127.0.0.1:8080/oauth/token_key
1
2
3
4
{
"alg": "SHA256withRSA",
"value": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtXKXj3JGNJNWVXg4+++4FtNTJre+8kHLdPLwHJJcRw4aV7oMMjI1nesyj75w/kjRZImhbNo0poEu1jj+sDO9p8n5oYXn3qU8bsmqLa/vttq7Ubi4a5eaoP8ASjoD+dnQ0I7ZdpH/fiiHfriGI4tFziFizWf/HatSgAat/SfX+5dk3KX40piPv9kI5YVJz1GyD8xO4dN9dtr0Ld7FDmdKJBPWfkM+CMlKRhYqB+sAlaQW5/L3xb3WNftucC/RhdKT8/mmgMsIBhUZOS/1iFnDKaPsEwU5xEQxK9pWX2bWsSkeOgQYJmQa6hiWBuujPUyOs4rICvniopxsW2yyPOFXZQIDAQAB\n-----END PUBLIC KEY-----"
}

资源服务器指定公钥文件的路径

在 YML 配置里指定认证授权服务器暴露的 JWK Set URI 接口,以此来获取公钥,值得一提的是,默认情况下 jwk-set-uri 指定的 URL 无法使用 Ribbon 来实现负载均衡访问(除非利用 DNS 的域名解析,即单个域名绑定多个 IP,通过 DNS 服务器做负载均衡)

1
2
3
4
5
6
7
8
spring:
application:
name: gateway-server
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://127.0.0.1:8080/oauth/.well-known/jwks.json

或者将上面通过 keytool 工具获取到的公钥拷贝到 src/main/resources/public.crt 文件中,然后在 YML 配置里指定公钥文件的路径

1
2
3
4
5
6
7
8
spring:
application:
name: gateway-server
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:public.crt

Cannot convert access token to JSON 错误

应用启动后,出现 Cannot convert access token to JSON 这个错误,主要是 OAuth 2.0 的资源服务器缺少了加载公钥的配置,解决方法如下:

1
2
3
4
5
6
7
8
9
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// 获取公钥
String publicKey = getPublicKey();
// 加载公钥
converter.setVerifier(new RsaVerifier(publicKey));
return converter;
}

资源服务器加载公钥的完整示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 <dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
<dependency>
<groupId>xom</groupId>
<artifactId>xom</artifactId>
<version>1.3.7</version>
</dependency>
<dependency>
<groupId>org.jdom</groupId>
<artifactId>jdom</artifactId>
<version>2.0.2</version>
</dependency>
<dependency>
<groupId>net.sf.json-lib</groupId>
<artifactId>json-lib</artifactId>
<version>2.1</version>
<classifier>jdk15</classifier>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.5.8</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import net.sf.json.xml.XMLSerializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.security.jwt.crypto.sign.RsaVerifier;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.web.client.RestTemplate;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.stream.Collectors;

/**
* OAuth2.0认证的Token配置
*/
@Configuration
public class OAuthTokenConfig {

/**
* 获取公钥的接口地址
*/
@Value("${spring.security.oauth2.resourceserver.jwt.key-set-uri:}")
private String keySetUri;

private OAuth2ResourceServerProperties resourceServerProperties;

private static final Logger logger = LoggerFactory.getLogger(OAuthTokenConfig.class);

public OAuthTokenConfig(OAuth2ResourceServerProperties resourceServerProperties) {
this.resourceServerProperties = resourceServerProperties;
}

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

@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
String publicKey = getPublicKey();
converter.setVerifier(new RsaVerifier(publicKey));
logger.info("success to load public key");
return converter;
}

/**
* 通过读取本地文件获取非对称加密公钥
*
* @return 公钥
*/
private String getPublicKey() {
if (StrUtil.isBlank(keySetUri)) {
return getKeyFromLocal();
} else {
return getKeyFromAuthorizationServer();
}
}

/**
* 通过访问授权服务器获取非对称加密公钥<br>
* 这里可以直接使用OAuth2.0内置的接口来获取公钥,Key Set Uri 地址配置示例: http://127.0.0.1:8080/oauth/token_key
*
* @return 公钥
*/
private String getKeyFromAuthorizationServer() {
try {
XMLSerializer xmlSerializer = new XMLSerializer();
String xmlPubKey = new RestTemplate().getForObject(keySetUri, String.class);
String jsonPubKey = xmlSerializer.read(xmlPubKey).toString();
JSONObject json = JSONObject.parseObject(jsonPubKey);
return json.get("value").toString();
} catch (Exception e) {
logger.error("failed to load public key from authorization server: {}", e.getLocalizedMessage());
}
return null;
}

/**
* 获取本地的公钥
*
* @return
*/
private String getKeyFromLocal() {
Resource resource = getPublicKeyFile();
try (BufferedReader br = new BufferedReader(new InputStreamReader(resource.getInputStream()))) {
return br.lines().collect(Collectors.joining("\n"));
} catch (Exception e) {
logger.error("failed to load public key from local: {}", e.getLocalizedMessage());
}
return null;
}

/**
* 获取本地的公钥文件
*
* @return
*/
private Resource getPublicKeyFile() {
try {
// 读取YML配置里指定的本地公钥文件,对应的YML配置如下:
// spring.security.oauth2.resourceserver.jwt.public-key-location=public.crt
Resource resource = resourceServerProperties.getJwt().getPublicKeyLocation();
if (FileUtil.exist(resource.getFile())) {
return resource;
}
} catch (Exception e) {
logger.error("failed to read public key file from local: {}", e.getLocalizedMessage());
}
// 读取默认路径下的本地公钥文件
return new ClassPathResource("public.crt");
}

}
1
2
3
4
5
6
7
8
9
spring:
application:
name: provider-service
security:
oauth2:
resourceserver:
jwt:
# public-key-location: classpath:public.crt # 加载本地的公钥文件
key-set-uri: http://127.0.0.1:8080/oauth/token_key # 从认证授权服务器获取公钥

特别注意:在上述代码中,若在 YML 文件里配置了从认证授权服务器获取公钥,那么必须使用 OAuth 2.0 内置的接口 /oauth/token_key 来获取公钥,同时使用的配置项是 key-set-uri,而不再是 jwk-set-uri

OAuth 2.0 资源服务器

资源服务器鉴权配置

默认情况下,OAuth 2.0 的权限是从 Client 的 scope 中获取,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 资源服务器配置
*/
@Configuration
@EnableResourceServer
public class OAuthResouceServer extends ResourceServerConfigurerAdapter {

@Autowired
private TokenStore tokenStore;

/**
* 资源配置
*/
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId("school")
.tokenStore(tokenStore)
.stateless(true)
.accessDeniedHandler(new CustomAccessDeniedHandler());
}

/**
* 对HTTP请求鉴权
*/
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/**").access("#oauth2.hasScope('teacher')")
.and().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}

}

若权限存在于 authorities 中,需要替代 OAuth2ResourceServerWebSecurityConfiguration 的配置,示例代码如下:

  • 弃用方法安全
  • 通过自定义 Converter 来指定权限,Converter 是函数接口,当前上下问参数为 JWT 对象
  • 获取 JWT 中的 authorities
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwt -> {
Collection<SimpleGrantedAuthority> authorities =
((Collection<String>) jwt.getClaims()
.get("authorities")).stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
return new JwtAuthenticationToken(jwt, authorities);
});
}

}

参考博客