1. 概述
本教程中,我们将讨论如何利用JWT(JSON Web Tokens)实现Spring Security OAuth2认证。
同时我们将在OAuth系列教程的前一篇文章基础上进行构建。
在我们开始之前,需要重点提醒的是,Spring Security 核心团队正则开发新的OAuth2技术栈,部分已经完成,部分还在进行中。
本文的Spring Security 5实现版本,请参见JWT 与 Spring Security OAuth这篇文章。
下面进入正题。
1. Maven 依赖
首先,我们需要在pom.xml
中添加spring-security-jwt
依赖:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>
注意在授权服务器和资源服务器上都需要添加。
3. 授权服务器(Authorization Server)
下一步,配置授权服务器使用JwtTokenStore
,如下:
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints)
throws Exception {
endpoints.tokenStore(tokenStore())
.accessTokenConverter(accessTokenConverter())
.authenticationManager(authenticationManager);
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123");
return converter;
}
@Bean
@Primary
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
defaultTokenServices.setSupportRefreshToken(true);
return defaultTokenServices;
}
}
注意,我们在JwtAccessTokenConverter
中使用了对称密钥来对token进行签名。这意味着在资源服务器上我们需要使用相同的密钥。
4. 资源服务器
下面看下我们的资源服务器的配置,和授权服务器配置几乎相同:
@Configuration
@EnableResourceServer
public class OAuth2ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer config) {
config.tokenServices(tokenServices());
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123");
return converter;
}
@Bean
@Primary
public DefaultTokenServices tokenServices() {
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setTokenStore(tokenStore());
return defaultTokenServices;
}
}
请记住,授权服务器和资源服务器是2个完全独立的项目,可以单独部署。所以这就是为什么,我们需要在新配置中重复定义相同的bean。
5. Token中自定义声明(Claims)
有时候,我们需要扩展JWT payload(负载)中的字段,此时我们需要添加自定义声明(Claims)。
现在,让我们设置一些基础设施,以便能够在access token中添加一些自定义声明。框架提供的标准声明都很好,但是大多数时候我们需要在token添加一些额外信息。
我们将定义一个TokenEnhancer
以定制我们的Access Token
。
下面例子中,我们将使用CustomTokenEnhancer
添加一个额外的"organization
"字段到我们的Access Token中:
public class CustomTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(
OAuth2AccessToken accessToken,
OAuth2Authentication authentication) {
Map<String, Object> additionalInfo = new HashMap<>();
additionalInfo.put(
"organization", authentication.getName() + randomAlphabetic(4));
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(
additionalInfo);
return accessToken;
}
}
然后,我们将其装配到授权服务器配置中,如下:
@Override
public void configure(
AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(
Arrays.asList(tokenEnhancer(), accessTokenConverter()));
endpoints.tokenStore(tokenStore())
.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager);
}
@Bean
public TokenEnhancer tokenEnhancer() {
return new CustomTokenEnhancer();
}
使用新配置运行后,token payload结构将是这个样子的:
{
"user_name": "john",
"scope": [
"foo",
"read",
"write"
],
"organization": "johnIiCh",
"exp": 1458126622,
"authorities": [
"ROLE_USER"
],
"jti": "e0ad1ef3-a8a5-4eef-998d-00b26bc2c53f",
"client_id": "fooClientIdPassword"
}
5.1. 在JavaScript客户端中使用Access Token
最后,我们将在AngualrJS中使用上面的token信息。我们将利用angular-jwt库实现。
我们将要做的是,在index.html
中使用organization
声明。
<p class="navbar-text navbar-right">{{organization}}</p>
<script type="text/javascript"
src="https://cdn.rawgit.com/auth0/angular-jwt/master/dist/angular-jwt.js">
</script>
<script>
var app =
angular.module('myApp', ["ngResource","ngRoute", "ngCookies", "angular-jwt"]);
app.controller('mainCtrl', function($scope, $cookies, jwtHelper,...) {
$scope.organiztion = "";
function getOrganization(){
var token = $cookies.get("access_token");
var payload = jwtHelper.decodeToken(token);
$scope.organization = payload.organization;
}
...
});
6. 资源服务器上获取额外信息
但是,我们如何在资源服务器上访问这些信息?
我们在这里要做的是,从Access token中提取额外的声明:
public Map<String, Object> getExtraInfo(OAuth2Authentication auth) {
OAuth2AuthenticationDetails details =
(OAuth2AuthenticationDetails) auth.getDetails();
OAuth2AccessToken accessToken = tokenStore
.readAccessToken(details.getTokenValue());
return accessToken.getAdditionalInformation();
}
在下一节中,我们将讨论如何使用自定义AccessTokenConverter
将这些额外信息添加到Authentication
详细信息中。
6.1. 自定义 AccessTokenConverter
我们创建CustomAccessTokenConverter
,并用access token声明设置Authentication
详细:
@Component
public class CustomAccessTokenConverter extends DefaultAccessTokenConverter {
@Override
public OAuth2Authentication extractAuthentication(Map<String, ?> claims) {
OAuth2Authentication authentication =
super.extractAuthentication(claims);
authentication.setDetails(claims);
return authentication;
}
}
备注: DefaultAccessTokenConverter
默认设置Authentication详情为Null
。
6.2. 配置 JwtTokenStore
下一步,我们将配置JwtTokenStore
使用我们的CustomAccessTokenConverter
:
@Configuration
@EnableResourceServer
public class OAuth2ResourceServerConfigJwt
extends ResourceServerConfigurerAdapter {
@Autowired
private CustomAccessTokenConverter customAccessTokenConverter;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setAccessTokenConverter(customAccessTokenConverter);
}
// ...
}
6.3. 从Authentication对象中提取声明
到此,授权服务器在token中添加了一些额外声明,我们现在可以直接在资源服务器上通过Authentication对象获取:
public Map<String, Object> getExtraInfo(Authentication auth) {
OAuth2AuthenticationDetails oauthDetails =
(OAuth2AuthenticationDetails) auth.getDetails();
return (Map<String, Object>) oauthDetails
.getDecodedDetails();
}
6.4. Authentication Details 测试
测试我们的Authentication对象是否包含该额外信息:
@RunWith(SpringRunner.class)
@SpringBootTest(
classes = ResourceServerApplication.class,
webEnvironment = WebEnvironment.RANDOM_PORT)
public class AuthenticationClaimsIntegrationTest {
@Autowired
private JwtTokenStore tokenStore;
@Test
public void whenTokenDoesNotContainIssuer_thenSuccess() {
String tokenValue = obtainAccessToken("fooClientIdPassword", "john", "123");
OAuth2Authentication auth = tokenStore.readAuthentication(tokenValue);
Map<String, Object> details = (Map<String, Object>) auth.getDetails();
assertTrue(details.containsKey("organization"));
}
private String obtainAccessToken(
String clientId, String username, String password) {
Map<String, String> params = new HashMap<>();
params.put("grant_type", "password");
params.put("client_id", clientId);
params.put("username", username);
params.put("password", password);
Response response = RestAssured.given()
.auth().preemptive().basic(clientId, "secret")
.and().with().params(params).when()
.post("http://localhost:8081/spring-security-oauth-server/oauth/token");
return response.jsonPath().getString("access_token");
}
}
备注:我们从授权服务器获得了带有额外声明的Access token,然后从其中读取了Authentication对象,该对象在details对象中包含额外的信息"organization"。
7. 非对称密钥对
在之前的配置中,我们使用对称密钥对token进行签名:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123");
return converter;
}
我们还可以使用非对称密钥(公有和私有密钥)进行签名过程。
7.1. 生成 JKS Java密钥库文件
首先,使用命令行工具keytool
生成密钥 —— 更具体地说是.jks
文件:
keytool -genkeypair -alias mytest
-keyalg RSA
-keypass mypass
-keystore mytest.jks
-storepass mypass
该命令将生成一个名为mytest.jks
的文件,其中包含我们的公钥和私钥。
另外,请确保keypass
和storepass
参数相同。
7.2. 导出公钥
下一步, 我们需要从生成的JKS文件中导出我们的公钥,我们可以使用下面的命令:
keytool -list -rfc --keystore mytest.jks | openssl x509 -inform pem -pubkey
返回的结果大概是下面这样子:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgIK2Wt4x2EtDl41C7vfp
OsMquZMyOyteO2RsVeMLF/hXIeYvicKr0SQzVkodHEBCMiGXQDz5prijTq3RHPy2
/5WJBCYq7yHgTLvspMy6sivXN7NdYE7I5pXo/KHk4nz+Fa6P3L8+L90E/3qwf6j3
DKWnAgJFRY8AbSYXt1d5ELiIG1/gEqzC0fZmNhhfrBtxwWXrlpUDT0Kfvf0QVmPR
xxCLXT+tEe1seWGEqeOLL5vXRLqmzZcBe1RZ9kQQm43+a9Qn5icSRnDfTAesQ3Cr
lAWJKl2kcWU1HwJqw+dZRSZ1X4kEXNMyzPdPBbGmU6MHdhpywI7SKZT7mX4BDnUK
eQIDAQAB
-----END PUBLIC KEY-----
-----BEGIN CERTIFICATE-----
MIIDCzCCAfOgAwIBAgIEGtZIUzANBgkqhkiG9w0BAQsFADA2MQswCQYDVQQGEwJ1
czELMAkGA1UECBMCY2ExCzAJBgNVBAcTAmxhMQ0wCwYDVQQDEwR0ZXN0MB4XDTE2
MDMxNTA4MTAzMFoXDTE2MDYxMzA4MTAzMFowNjELMAkGA1UEBhMCdXMxCzAJBgNV
BAgTAmNhMQswCQYDVQQHEwJsYTENMAsGA1UEAxMEdGVzdDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAICCtlreMdhLQ5eNQu736TrDKrmTMjsrXjtkbFXj
Cxf4VyHmL4nCq9EkM1ZKHRxAQjIhl0A8+aa4o06t0Rz8tv+ViQQmKu8h4Ey77KTM
urIr1zezXWBOyOaV6Pyh5OJ8/hWuj9y/Pi/dBP96sH+o9wylpwICRUWPAG0mF7dX
eRC4iBtf4BKswtH2ZjYYX6wbccFl65aVA09Cn739EFZj0ccQi10/rRHtbHlhhKnj
iy+b10S6ps2XAXtUWfZEEJuN/mvUJ+YnEkZw30wHrENwq5QFiSpdpHFlNR8CasPn
WUUmdV+JBFzTMsz3TwWxplOjB3YacsCO0imU+5l+AQ51CnkCAwEAAaMhMB8wHQYD
VR0OBBYEFOGefUBGquEX9Ujak34PyRskHk+WMA0GCSqGSIb3DQEBCwUAA4IBAQB3
1eLfNeq45yO1cXNl0C1IQLknP2WXg89AHEbKkUOA1ZKTOizNYJIHW5MYJU/zScu0
yBobhTDe5hDTsATMa9sN5CPOaLJwzpWV/ZC6WyhAWTfljzZC6d2rL3QYrSIRxmsp
/J1Vq9WkesQdShnEGy7GgRgJn4A8CKecHSzqyzXulQ7Zah6GoEUD+vjb+BheP4aN
hiYY1OuXD+HsdKeQqS+7eM5U7WW6dz2Q8mtFJ5qAxjY75T0pPrHwZMlJUhUZ+Q2V
FfweJEaoNB9w9McPe1cAiE+oeejZ0jq0el3/dJsx3rlVqZN+lMhRJJeVHFyeb3XF
lLFCUGhA7hxn2xf3x1JW
-----END CERTIFICATE-----
我们仅需要公钥,并将其复制到资源服务器src/main/resources/public.txt
中:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgIK2Wt4x2EtDl41C7vfp
OsMquZMyOyteO2RsVeMLF/hXIeYvicKr0SQzVkodHEBCMiGXQDz5prijTq3RHPy2
/5WJBCYq7yHgTLvspMy6sivXN7NdYE7I5pXo/KHk4nz+Fa6P3L8+L90E/3qwf6j3
DKWnAgJFRY8AbSYXt1d5ELiIG1/gEqzC0fZmNhhfrBtxwWXrlpUDT0Kfvf0QVmPR
xxCLXT+tEe1seWGEqeOLL5vXRLqmzZcBe1RZ9kQQm43+a9Qn5icSRnDfTAesQ3Cr
lAWJKl2kcWU1HwJqw+dZRSZ1X4kEXNMyzPdPBbGmU6MHdhpywI7SKZT7mX4BDnUK
eQIDAQAB
-----END PUBLIC KEY-----
或者,我们可以通过添加-noout
参数仅导出公钥:
keytool -list -rfc --keystore mytest.jks | openssl x509 -inform pem -pubkey -noout
7.3. Maven 配置
接下来,Maven打包时,我们不希望JKS文件被处理,否则可能导致证书不可用,因此需要在pom.xml
中将其排除在外。
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<excludes>
<exclude>*.jks</exclude>
</excludes>
</resource>
</resources>
</build>
如果使用Spring Boot,我们需要确保通过Spring Boot Maven插件– addResources将JKS文件添加到应用程序classpath中:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<addResources>true</addResources>
</configuration>
</plugin>
</plugins>
</build>
7.4. 授权服务器
现在,我们将配置JwtAccessTokenConverter使用mytest.jks
中的密钥对,如下所示:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
KeyStoreKeyFactory keyStoreKeyFactory =
new KeyStoreKeyFactory(new ClassPathResource("mytest.jks"), "mypass".toCharArray());
converter.setKeyPair(keyStoreKeyFactory.getKeyPair("mytest"));
return converter;
}
7.5. 资源服务器
最后,我们需要配置资源服务器使用公钥,如下所示:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
Resource resource = new ClassPathResource("public.txt");
String publicKey = null;
try {
publicKey = IOUtils.toString(resource.getInputStream());
} catch (final IOException e) {
throw new RuntimeException(e);
}
converter.setVerifierKey(publicKey);
return converter;
}
8. 总结
本教程中,我们重点介绍了如何配置Spring Security OAuth2项目以使用JSON Web Tokens。
本教程完整源代码实现,可从GitHub上获取。