写在前面

通过前面几篇的学习,我们对OAuth2.0中的四种授权模式有了较为清晰的认识,同时可以发现这些文章都是侧重于OAuth2.0的登录流程,而对于登录的一些细节并没有过多的深究,其实这些细节在实际开发过程中也非常重要,因此接下来开始对登录中的一些细节进行深度分析。

我们知道传统的Web应用都是采用Session来记录用户认证信息,这一方式可以理解为是一种有状态的登录,实际上还可以采用无状态这种方式来实现这一目的,最为代表的就是JWT。无状态登录很容易实现单点登录这一功能,因此对于无状态登录的研究对于提升自身技术水平有非常大的帮助。

状态

有状态

所谓的有状态服务是指服务端需要记录每次会话的客户信息,并据此识别客户端身份,验证通过后根据客户端身份进行请求处理,如Tomcat中的session就是此类的具体实现。当用户登录成功后,会将用户的信息保存在服务端的Session中,并将sessionId放在Cookie中用于返回给用户。等到用户下次访问的时候,用户会携带含有Cookie信息的请求到服务器,服务器会验证sessionId是否合法,当验证无误通过后会据此找到用户信息。

但是这种方式存在一些缺陷,当时间不断累计,服务端会保存大量的数据,这势必会增加服务端压力,还有由于服务器中保存的是用户的状态,因此对于集群化部署不太友好,极易造成用户信息访问断层现象。

基于有状态实现的登录流程:
1、用户输入用户名和密码,点击提交;
2、调用 login()命令, 后端程序会根据用户名密码生成sessionId并保存在数据库中;
3、用户登录之后,需要通过这个sessionId取出相关的基本信息。

无状态

说完了有状态,接下来学习无状态,单纯的介绍无状态似乎意义不大,因此结合目前流行的微服务架构来讲显得更有说服力。

我们知道微服务集群中每个服务对外都使用RESTful风格的接口,而RESTful风格有一个重要的规范就是“服务无状态性”。怎么理解这个“服务无状态性”呢?这里有两点需要注意:(1)服务端不保存客户端请求发起者的信息;(2)客户端的每次请求中必须包含描述发送者的信息,以便服务端通过这些信息唯一确定其身份。

那么问题来了,无状态相比于有状态有哪些优势呢?首先由于客户端发起的请求中已经包含了其发送者的描述信息,因此并不要求多次发送请求访问的必须是同一台服务器,这使得服务器集群化成为可能;其次,由于存在集群化部署,极大地降低了服务端的压力。

基于无状态实现的登录流程:
1、用户输入用户名和密码,点击提交;
2、调用 login()命令, 后端程序会将用户信息加密并编码成一个token,之后将其返回给客户端;
3、此后客户端每次发送请求都需要携带token,服务端对该token进行解密和验证,确认通过后即能获取用户基本信息。

JWT

JWT简介

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

JWT作为一种规范,并没有和语言进行绑定,因此不同的语言对此都有具体的实现。常用的Java语言实现是jjwt,其开源地址为https://github.com/jwtk/jjwt

JWT原理

JWT的原理是,服务端认证以后,生成一个 SON对象并返回给用户,就像下面这样:

1
2
3
4
5
{
"name": "envy",
"role": "admin",
"expires": "2021-01-02 10:01:06"
}

之后用户与服务端进行通信的时候,都需要携带这个JSON对象。服务端完全靠这个JSON对象来确定用户身份。当然了,为了防止用户篡改数据,服务端会在生成这个JSON对象的时候,会加上签名,这一点后续会介绍。这样服务端无需保存任何session数据,也就是说此时服务端变成无状态了,进而比较容易实现扩展。

JWT数据格式

首先看一下实际的JWT实例,如下图所示:

可以看到这个JWT是一个非常长的字符串,中间用点号(.)分隔成三部分。请注意,实际的JWT内部是没有换行的,此处只是为了便于展示才将它写成了四行。

JWT包含三部分数据,一是Header(头部)、二是Playload(载荷)、三是Signature(签名)。写成一行就是Header.Payload.Signature这一格式,也就是如下所示的格式:

第一部分:Header(头部),它是一个JSON对象,用于描述JWT的元数据。举个例子,如下所示:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

它通常包括两个部分:(1)加密算法,这个开发者可以自定义;(2)声明类型,此处就是JWT。在上面代码中,alg属性表示签名的算法(algorithm),默认是HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT令牌统一写为JWT。通常我们会对头部使用Base64URL进行编码,进而得到第一部分数据,但是这种编码方式是可逆的,安全性不高。

第二部分:Playload(载荷),它也是一个JSON对象,用于存放实际需要传递的数据,说白了就是有效数据。在其官方文档(RFC7519)中规定了7个可供选择的字段,如下所示:
(1)iss(issuer)表示签发人;(2)exp(expiration time)表示token过期时间;(3)sub(subject)表示主题;(4)aud(audience)表示受众;(5)nbf(Not Before)表示生效时间;(6)iat(Issued At)表示签发时间;(7)jti(JWT ID)表示编号。

当然,除了官方字段,开发者还可以在此部分定义私有字段,举个例子:

1
2
3
4
5
{
"sub": "1234567890",
"name": "Envy Think",
"admin": true
}

请注意,JWT默认是不加密的,任何人都可以读到,因此不能把私密信息存放在此部分。同样我们会对其使用Base64URL进行编码,进而得到第二部分数据。

第三部分:Signature(签名),Signature部分是对前两部分的签名,用于防止数据篡改。

首先需要在服务器上指定一个密钥(secret),该密钥只有服务器才知道,不能泄露给用户,之后使用Header里面指定的签名算法(默认是HMAC SHA256)来按照下面的公式产生签名:

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

在计算出签名之后,将HeaderPayloadSignature这三部分拼成一个字符串,注意各个部分之间使用点号(.)进行分割,之后就可以返回给用户。

在前面我们多次提到,Header和Payload串型化均采用了Base64URL算法,该算法和Base64算法基本类似,但是存在一些不同的地方。我们知道JWT作为一个令牌(token),在某些场景下会放在URL中,如https://api.example.com/?token=xxx。Base64中存在三个特殊字符+/=,由于它们在URL中有特殊含义,因此要被替换掉,其中=被省略、+替换成-/替换成_,这就是Base64URL算法。

JWT数据流转

下面是JWT数据流转的示意图:

乍一看可能觉得JWT模式与四种授权模式中的密码模式非常相似,其实两者是存在一个较为明显的区别。JWT模式下服务端不保存用户信息,且不要求客户端对于服务端高度信任。也正是由于JWT签发的令牌token中已经包含了用户身份信息,且每次请求时都会携带上,因此服务端无需保存用户信息,毫无疑问这是非常符合RESTful的无状态要求。

JWT缺点

尽管JWT存在上述所列的优点,但是JWT也存在一些问题,具体如下所示:
(1)续签问题。这也是JWT被许多人嗤之以鼻的原因,我们知道传统的cookie+session方案支持续签,但是JWT由于服务端不保存用户信息,因此很难解决续签问题。当然开发者可以引入Redis来解决这个问题,但是这并不是单纯的使用JWT。
(2)注销问题。尽管JWT模式下的服务端不保存用户信息,但是开发者可以通过修改secret来实现注销,当服务端的secret被修改后,请注意已经颁发的未过期的令牌token就会认证失败,进而实现注销这一目的,但是这种方式肯定没有传统注销那样简单。
(3)密码重置问题。请注意当用户将密码进行重置后,之前的令牌token依旧可以访问系统,此时就需要强制修改secret。
(4)由于注销问题和密码重置问题,因此一般建议不同的用户采用不同的secret,以增强安全性。

OAuth2中存在的问题

在《一个完整的授权码模式实例》一文中我们知道,当授权服务器派发了access_token之后,客户端便拿着access_token去资源服务器请求资源,此时资源服务器需要校验access_token信息,因此我们需要在资源服务器上配置RemoteTokenServices,进而让资源服务器进行校验,如下代码:

1
2
3
4
5
6
7
8
@Bean
RemoteTokenServices tokenServices(){
RemoteTokenServices services = new RemoteTokenServices();
services.setCheckTokenEndpointUrl("http://127.0.0.1:8080/oauth/check_token");
services.setClientId("envythink");
services.setClientSecret("1234");
return services;
}

毫无疑问这段代码在高并发情况下执行是有问题的,如果使用JWT将用户信息保持在JWT中,那么就能解决上述问题。

密码模式使用JWT

前面也提到JWT模式和密码模式流程非常相似,因此这里就以密码模式为例来进行升级改造。

授权服务器改造

首先打开auth_server授权服务器,找到其中的AccessTokenConfig类,将里面的代码修改为如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class AccessTokenConfig {
private String SIGNING_KEY = "envythink";

@Bean
JwtAccessTokenConverter jwtAccessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SIGNING_KEY);
return converter;
}

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

可以看到此处我们在tokenStore方法中提供的是一个JwtTokenStore实例。如果不采用JWT模式,那么之前我们将access_token无论是存储在内存还是Redis中,最后都是需要将access_token保存下来的,这样当客户端携带access_token请求服务端的时候,服务端还需要对其进行校验。但是如果我们采用了JWT这一模式,那么服务端就无需保存access_token,因为用户信息都保存在jwt中,因此此处配置的JwtTokenStore其实并不是用于存储access_token。

JwtTokenStore实例化的过程中我们还传入了一个JwtAccessTokenConverter对象,这个JwtAccessTokenConverter对象可以实现用户信息和JWT字符串的相互转换,也就是可以从JWT字符串中取出用户信息或者将用户信息转为JWT字符串。需要说明的是,在JWT字符串生成的时候,我们还需要生成一个签名,这个签名非常重要。

注意JWT默认生成的信息主要包含用户角色、名称等,当开发者希望在生成的JWT字符串中包含其他信息,那么可以自定义一个类,然后实现TokenEnhancer接口即可。新建一个CustomAdditionalInformation类,并实现TokenEnhancer接口,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
@Component
public class CustomAdditionalInformation implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
Map<String,Object> info = oAuth2AccessToken.getAdditionalInformation();
info.put("author","余思");
((DefaultOAuth2AccessToken)oAuth2AccessToken).setAdditionalInformation(info);
return oAuth2AccessToken;
}
}

可以看到笔者自定义了一个CustomAdditionalInformation类,实现了TokenEnhancer接口,并重写了该接口的enhance方法。请注意,enhance方法中的OAuth2AccessToken对象其实就是已经生成的access_token信息,我们可以从OAuth2AccessToken对象中取出已经生成的额外信息,并在此基础上添加自己所添加的信息即可。需要说明的是,我们在AccessTokenConfig类中配置的JwtAccessTokenConverter,它也是一个TokenEnhancer实例。

接下来需要修改AuthorizationServerConfig类,主要是对tokenServices方法进行修改,修改后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Autowired
private CustomAdditionalInformation customAdditionalInformation;

@Bean
AuthorizationServerTokenServices tokenServices(){
DefaultTokenServices services = new DefaultTokenServices();
services.setClientDetailsService(clientDetailsService);
services.setSupportRefreshToken(true);
services.setTokenStore(tokenStore);
services.setAccessTokenValiditySeconds(60*60*2);
services.setRefreshTokenValiditySeconds(60*60*24*3);

TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter,customAdditionalInformation));
services.setTokenEnhancer(tokenEnhancerChain);
return services;
}

可以看到我们只是实例化了一个TokenEnhancerChain对象,并将之前定义的jwtAccessTokenConvertercustomAdditionalInformation两个实例注入进来并构成一个集合,最后将其作为TokenEnhancerChain对象的属性进行设置。

资源服务器改造

在完成了对授权服务器的改造之后,接下来我们开始对资源服务器进行改造。首先打开user-server资源服务器,我们将auth_server授权服务器中的AccessTokenConfig类复制一份到其config包中,之后我们在资源服务器上就不再需要配置远程校验地址,只需配置一个TokenStore即可。打开config包下面的ResourceServerConfig类,将其中的代码修改为如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;

@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("res1").tokenStore(tokenStore);
}

@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated();
}
}

可以看到此处我们将之前在AccessTokenConfig类中配置的tokenStore实例注入进来,这样系统会自动调用JwtAccessTokenConverter对象将jwt进行解析,jwt中由于已经保存了用户的基本信息,因此就不需要服务端来校验access_token了。

项目测试

在完成对授权服务器和资源服务器的改造之后,注意此时我们无需关注应用端,因此对其无需进行改造,同时处于测试方便的考虑,这里使用Postman来对项目进行测试。

启动授权服务器和资源服务器,首先我们向授权服务器请求access_token:

可以看到我们获取到了jwt字符串,也就是此处的access_token,同时可以看到我们之前自定义的author字段也已经有值了。当然如果开发者想验证access_token中包含的信息是否是之前介绍过的内容,此时可以利用Base64URL解码工具来进行解码,也可以使用check_token接口来进行解析:

此时就能看到jwt字符串中保存的用户信息了。在获取到access_token之后,接下来我们就可以去访问资源服务器中的资源了,如访问hello接口,注意此时需要在请求头中携带access_token信息:

可以看到此时我们就获取到了hello接口中的信息,也就是通过上面的配置我们成功的将OAuth2.0和JWT结合起来进行使用了。

原理分析

现在问题来了,前面介绍的access_token是如何变成了jwt呢?jwt和认证信息又是如何实现自动转换的呢?接下来我们通过阅读源码来解答这些疑问。

首先思考一个问题,acess_token是在什么地方生成的?此时就需要回到auth-server授权服务器的AuthorizationServerConfig类中,可以看到我们定义了一个tokenServices方法,在该方法中我们构建了一个DefaultTokenServices对象,查看DefaultTokenServices类的源码,可以看到里面有一个名为createAccessToken的方法,该方法的源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
int validitySeconds = this.getAccessTokenValiditySeconds(authentication.getOAuth2Request());
if (validitySeconds > 0) {
token.setExpiration(new Date(System.currentTimeMillis() + (long)validitySeconds * 1000L));
}

token.setRefreshToken(refreshToken);
token.setScope(authentication.getOAuth2Request().getScope());
return (OAuth2AccessToken)(this.accessTokenEnhancer != null ? this.accessTokenEnhancer.enhance(token, authentication) : token);
}

简单分析一下该方法:
(1)可以看到默认生成的access_token其实是一个UUID字符串;
(2)调用getAccessTokenValiditySeconds()方法来获取access_token的有效期。查看一下这个getAccessTokenValiditySeconds()方法的源码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
protected int getAccessTokenValiditySeconds(OAuth2Request clientAuth) {
if (this.clientDetailsService != null) {
ClientDetails client = this.clientDetailsService.loadClientByClientId(clientAuth.getClientId());
Integer validity = client.getAccessTokenValiditySeconds();
if (validity != null) {
return validity;
}
}

return this.accessTokenValiditySeconds;
}

可以看到它首先通过客户端Id去数据库中查询得到客户端信息,其次调用客户端的getAccessTokenValiditySeconds()方法来得到有效期,其实这个就是我们之前配置的access_token的有效期,注意单位为秒。
(3)回到createAccessToken()方法中,接着判断access_token设置的有效期是否大于0,如果大于0则调用setExpiration()方法来设置过期时间,过期时间就是在系统当前时间的基础上加上用户设置的过期时间,由于用户设置的过期时间单位为秒,因此需要乘以1000,将其转换为毫秒。
(4)接着调用setRefreshToken()方法来设置刷新token,同时调用setScope()方法来设置授权范围scope。请注意,刷新token的生成过程也是在createAccessToken()方法中完成的,这个过程和access_token生成的过程类似,因此就跳过介绍。
(5)在return语句中会对accessTokenEnhancer是否存在进行判断,如果不为空,则调用accessTokenEnhancer.enhance()方法对其进行处理,其实这就是将access_token转换为jwt字符串的过程。

前面也说了此处accessTokenEnhancer的实现类为TokenEnhancerChain,查看一下其源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class TokenEnhancerChain implements TokenEnhancer {
private List<TokenEnhancer> delegates = Collections.emptyList();

public TokenEnhancerChain() {
}

public void setTokenEnhancers(List<TokenEnhancer> delegates) {
this.delegates = delegates;
}

public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
OAuth2AccessToken result = accessToken;

TokenEnhancer enhancer;
for(Iterator var4 = this.delegates.iterator(); var4.hasNext(); result = enhancer.enhance(result, authentication)) {
enhancer = (TokenEnhancer)var4.next();
}

return result;
}
}

可以看到里面定义了一个delegates属性,里面保存了我们定义的TokenEnhancer,前面也说了我们在auth-server授权服务器中定义了jwtAccessTokenConvertercustomAdditionalInformation,也就是说access_token信息其实就是在这两个类中进行二次处理。处理的顺序是依照集合中保存的顺序,也就是先在jwtAccessTokenConverter中进行处理,之后在customAdditionalInformation中进行处理,这个顺序不能发生颠倒,这也就意味着开发人员在auth-server授权服务器中应当首先注入JwtAccessTokenConverter对象,之后才注入CustomAdditionalInformation对象。

需要说明的是,无论是JwtAccessTokenConverter,还是CustomAdditionalInformation,其中核心的方法都是enhance方法。首先查看一下JwtAccessTokenConverter类中enhance方法的源码,如下所示:

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
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
DefaultOAuth2AccessToken result = new DefaultOAuth2AccessToken(accessToken);
Map<String, Object> info = new LinkedHashMap(accessToken.getAdditionalInformation());
String tokenId = result.getValue();
if (!info.containsKey("jti")) {
info.put("jti", tokenId);
} else {
tokenId = (String)info.get("jti");
}

result.setAdditionalInformation(info);
result.setValue(this.encode(result, authentication));
OAuth2RefreshToken refreshToken = result.getRefreshToken();
if (refreshToken != null) {
DefaultOAuth2AccessToken encodedRefreshToken = new DefaultOAuth2AccessToken(accessToken);
encodedRefreshToken.setValue(refreshToken.getValue());
encodedRefreshToken.setExpiration((Date)null);

try {
Map<String, Object> claims = this.objectMapper.parseMap(JwtHelper.decode(refreshToken.getValue()).getClaims());
if (claims.containsKey("jti")) {
encodedRefreshToken.setValue(claims.get("jti").toString());
}
} catch (IllegalArgumentException var11) {
}

Map<String, Object> refreshTokenInfo = new LinkedHashMap(accessToken.getAdditionalInformation());
refreshTokenInfo.put("jti", encodedRefreshToken.getValue());
refreshTokenInfo.put("ati", tokenId);
encodedRefreshToken.setAdditionalInformation(refreshTokenInfo);
DefaultOAuth2RefreshToken token = new DefaultOAuth2RefreshToken(this.encode(encodedRefreshToken, authentication));
if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
Date expiration = ((ExpiringOAuth2RefreshToken)refreshToken).getExpiration();
encodedRefreshToken.setExpiration(expiration);
token = new DefaultExpiringOAuth2RefreshToken(this.encode(encodedRefreshToken, authentication), expiration);
}

result.setRefreshToken((OAuth2RefreshToken)token);
}

return result;
}

可以看到这段代码较长,但是逻辑较为清晰:
(1)首先实例化一个DefaultOAuth2AccessToken对象,之后调用getAdditionalInformation()方法将accessToken中的额外信息提取出来,注意accessToken默认是没有附加信息的;
(2)调用result.getValue()方法得到之前生成的UUID字符串,并判断(1)得到的附加信息中是否包含jti,如果不包含则将其放入其中,如果包含则直接获取其信息,并将其值作为tokenId的值。之后就是将获取到的额外信息和值添加到result中。
(3)调用encode()方法来对result对象进行编码,并将结果作为新的access_token,注意这个编码的过程其实就是将用户信息转换为jwt字符串的过程。
(4)接下来就是设置token的刷新问题,首先判断refreshToken是否不为空,如果不为空,同时token为jwt字符串,那么就需要一个解码操作,否则直接刷新token。请注意,如果refreshTokenExpiringOAuth2RefreshToken的实例,那么就说明refreshToken已经过期了,此时需要重新生成一个refreshToken

在(3)中我们提到,会调用encode()方法来对result对象进行编码,而这个也是将用户信息转换为jwt字符串的过程。查看一下该方法的源码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
String content;
try {
content = this.objectMapper.formatMap(this.tokenConverter.convertAccessToken(accessToken, authentication));
} catch (Exception var5) {
throw new IllegalStateException("Cannot convert access token to JSON", var5);
}

String token = JwtHelper.encode(content, this.signer).getEncoded();
return token;
}

可以看到它首先会将accessToken和用户信息authentication生成一个字符串,之后调用JwtHelper.encode()方法来对生成的字符串进行编码,最后就返回了一个jwt字符串。

(完)