OAuth2.0+JWT实现单点登录
写在前面
通过前面几篇的学习,我们对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 | { |
之后用户与服务端进行通信的时候,都需要携带这个JSON对象。服务端完全靠这个JSON对象来确定用户身份。当然了,为了防止用户篡改数据,服务端会在生成这个JSON对象的时候,会加上签名,这一点后续会介绍。这样服务端无需保存任何session数据,也就是说此时服务端变成无状态了,进而比较容易实现扩展。
JWT数据格式
首先看一下实际的JWT实例,如下图所示:
可以看到这个JWT是一个非常长的字符串,中间用点号(.)
分隔成三部分。请注意,实际的JWT内部是没有换行的,此处只是为了便于展示才将它写成了四行。
JWT包含三部分数据,一是Header(头部)、二是Playload(载荷)、三是Signature(签名)。写成一行就是Header.Payload.Signature
这一格式,也就是如下所示的格式:
第一部分:Header(头部),它是一个JSON对象,用于描述JWT的元数据。举个例子,如下所示:
1 | { |
它通常包括两个部分:(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 | { |
请注意,JWT默认是不加密的,任何人都可以读到,因此不能把私密信息存放在此部分。同样我们会对其使用Base64URL进行编码,进而得到第二部分数据。
第三部分:Signature(签名),Signature部分是对前两部分的签名,用于防止数据篡改。
首先需要在服务器上指定一个密钥(secret),该密钥只有服务器才知道,不能泄露给用户,之后使用Header
里面指定的签名算法(默认是HMAC SHA256
)来按照下面的公式产生签名:
1 | HMACSHA256( |
在计算出签名之后,将Header
、Payload
、Signature
这三部分拼成一个字符串,注意各个部分之间使用点号(.)
进行分割,之后就可以返回给用户。
在前面我们多次提到,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 | @Bean |
毫无疑问这段代码在高并发情况下执行是有问题的,如果使用JWT将用户信息保持在JWT中,那么就能解决上述问题。
密码模式使用JWT
前面也提到JWT模式和密码模式流程非常相似,因此这里就以密码模式为例来进行升级改造。
授权服务器改造
首先打开auth_server
授权服务器,找到其中的AccessTokenConfig
类,将里面的代码修改为如下所示:
1 | @Configuration |
可以看到此处我们在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 | @Component |
可以看到笔者自定义了一个CustomAdditionalInformation
类,实现了TokenEnhancer
接口,并重写了该接口的enhance方法。请注意,enhance方法中的OAuth2AccessToken
对象其实就是已经生成的access_token信息,我们可以从OAuth2AccessToken
对象中取出已经生成的额外信息,并在此基础上添加自己所添加的信息即可。需要说明的是,我们在AccessTokenConfig
类中配置的JwtAccessTokenConverter
,它也是一个TokenEnhancer
实例。
接下来需要修改AuthorizationServerConfig
类,主要是对tokenServices方法进行修改,修改后的代码如下所示:
1 | @Autowired |
可以看到我们只是实例化了一个TokenEnhancerChain
对象,并将之前定义的jwtAccessTokenConverter
、customAdditionalInformation
两个实例注入进来并构成一个集合,最后将其作为TokenEnhancerChain
对象的属性进行设置。
资源服务器改造
在完成了对授权服务器的改造之后,接下来我们开始对资源服务器进行改造。首先打开user-server
资源服务器,我们将auth_server
授权服务器中的AccessTokenConfig
类复制一份到其config包中,之后我们在资源服务器上就不再需要配置远程校验地址,只需配置一个TokenStore
即可。打开config包下面的ResourceServerConfig
类,将其中的代码修改为如下所示:
1 | @Configuration |
可以看到此处我们将之前在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 | private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) { |
简单分析一下该方法:
(1)可以看到默认生成的access_token
其实是一个UUID字符串;
(2)调用getAccessTokenValiditySeconds()
方法来获取access_token
的有效期。查看一下这个getAccessTokenValiditySeconds()
方法的源码,如下所示:
1 | protected int getAccessTokenValiditySeconds(OAuth2Request clientAuth) { |
可以看到它首先通过客户端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 | public class TokenEnhancerChain implements TokenEnhancer { |
可以看到里面定义了一个delegates属性,里面保存了我们定义的TokenEnhancer
,前面也说了我们在auth-server
授权服务器中定义了jwtAccessTokenConverter
和customAdditionalInformation
,也就是说access_token
信息其实就是在这两个类中进行二次处理。处理的顺序是依照集合中保存的顺序,也就是先在jwtAccessTokenConverter
中进行处理,之后在customAdditionalInformation
中进行处理,这个顺序不能发生颠倒,这也就意味着开发人员在auth-server
授权服务器中应当首先注入JwtAccessTokenConverter
对象,之后才注入CustomAdditionalInformation
对象。
需要说明的是,无论是JwtAccessTokenConverter
,还是CustomAdditionalInformation
,其中核心的方法都是enhance方法。首先查看一下JwtAccessTokenConverter
类中enhance方法的源码,如下所示:
1 | public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { |
可以看到这段代码较长,但是逻辑较为清晰:
(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。请注意,如果refreshToken
为ExpiringOAuth2RefreshToken
的实例,那么就说明refreshToken
已经过期了,此时需要重新生成一个refreshToken
。
在(3)中我们提到,会调用encode()
方法来对result对象进行编码,而这个也是将用户信息转换为jwt字符串的过程。查看一下该方法的源码,如下所示:
1 | protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { |
可以看到它首先会将accessToken
和用户信息authentication
生成一个字符串,之后调用JwtHelper.encode()
方法来对生成的字符串进行编码,最后就返回了一个jwt字符串。
(完)