使用jwt完成sso单点登录

JWT

在了解 jwt 之前,先了解一下常用的会话管理

  • 基于server-session的管理方式
  • cookie-based的管理方式
  • token-based的管理方式

一.基于server-session的管理

  1. 服务端session是用户第一次访问应用时,服务器就会创建的对象,代表用户的一次会话过程,服务器为每一个session都分配一个唯一的sessionid,以保证每个用户都有一个不同的session对象。
  2. 服务器在创建完session后,会把sessionid通过cookie返回给用户所在的浏览器,这样当用户第二次及以后向服务器发送请求的时候,就会通过cookiesessionid传回给服务器,以便服务器能够根据sessionid找到与该用户对应的session对象。
  3. session通常有失效时间的设定,比如 2 个小时。当失效时间到,服务器会销毁之前的session,并创建新的session返回给用户。但是只要用户在失效时间内,有发送新的请求给服务器,通常服务器都会把他对应的session的失效时间根据当前的请求时间再延长 2 个小时。
  4. session在一开始并不具备会话管理的作用。它只有在用户登录认证成功之后,并且往session对象里面放入了用户登录成功的凭证,才能用来管理会话。管理会话的逻辑也很简单,只要拿到用户的session对象,看它里面有没有登录成功的凭证,就能判断这个用户是否已经登录。当用户主动退出的时候,会把它的session对象里的登录凭证清掉。所以在用户登录前或退出后或者session对象失效时,肯定都是拿不到需要的登录凭证的。

以上过程可简单使用流程图描述如下:
server-sessionid

它还有一个比较大的优点就是安全性好,因为在浏览器端与服务器端保持会话状态的媒介始终只是一个sessionid串,只要这个串够随机,攻击者就不能轻易冒充他人的sessionid进行操作;除非通过 CSRF 或 http 劫持的方式,才有可能冒充别人进行操作;即使冒充成功,也必须被冒充的用户session里面包含有效的登录凭证才行。但是在真正决定用它管理会话之前,也得根据自己的应用情况考虑以下几个问题:

  1. 这种方式将会话信息存储在 web 服务器里面,所以在用户同时在线量比较多时,这些会话信息会占据比较多的内存;
  2. 当应用采用集群部署的时候,会遇到多台 web 服务器之间如何做session共享的问题。因为session是由单个服务器创建的,但是处理用户请求的服务器不一定是那个创建session的服务器,这样他就拿不到之前已经放入到session中的登录凭证之类的信息了;
  3. 多个应用要共享session时,除了以上问题,还会遇到跨域问题,因为不同的应用可能部署的主机不一样,需要在各个应用做好cookie跨域的处理。

针对问题 1 和问题 2,我见过的解决方案是采用redis这种中间服务器来管理session的增删改查,一来减轻 web 服务器的负担,二来解决不同 web 服务器共享session的问题。针对问题 3,由于服务端的session依赖cookie来传递sessionid,所以在实际项目中,只要解决各个项目里面如何实现sessionidcookie跨域访问即可,这个是可以实现的,就是比较麻烦,前后端有可能都要做处理。

由于前一种方式会增加服务器的负担和架构的复杂性,所以后来就有人想出直接把用户的登录凭证直接存到客户端的方案,当用户登录成功之后,把登录凭证写到cookie里面,并给cookie设置有效期,后续请求直接验证存有登录凭证的cookie是否存在以及凭证是否有效,即可判断用户的登录状态。使用它来实现会话管理的整体流程如下:

  1. 用户发起登录请求,服务端根据传入的用户密码之类的身份信息,验证用户是否满足登录条件,如果满足,就根据用户信息创建一个登录凭证,这个登录凭证简单来说就是一个对象,最简单的形式可以只包含用户id,凭证创建时间和过期时间三个值。
  2. 服务端把上一步创建好的登录凭证,先对它做数字签名,然后再用对称加密算法做加密处理,将签名、加密后的字串,写入cookiecookie的名字必须固定(如ticket),因为后面再获取的时候,还得根据这个名字来获取cookie值。这一步添加数字签名的目的是防止登录凭证里的信息被篡改,因为一旦信息被篡改,那么下一步做签名验证的时候肯定会失败。做加密的目的,是防止cookie被别人截取的时候,无法轻易读到其中的用户信息。
  3. 用户登录后发起后续请求,服务端根据上一步存登录凭证的cookie名字,获取到相关的cookie值。然后先做解密处理,再做数字签名的认证,如果这两步都失败,说明这个登录凭证非法;如果这两步成功,接着就可以拿到原始存入的登录凭证了。然后用这个凭证的过期时间和当前时间做对比,判断凭证是否过期,如果过期,就需要用户再重新登录;如果未过期,则允许请求继续。

cookie-based

这种方式最大的优点就是实现了服务端的无状态化,彻底移除了服务端对会话的管理的逻辑,服务端只需要负责创建和验证登录cookie即可,无需保持用户的状态信息。对于第一种方式的第二个问题,用户会话信息共享的问题,它也能很好解决:因为如果只是同一个应用做集群部署,由于验证登录凭证的代码都是一样的,所以不管是哪个服务器处理用户请求,总能拿到cookie中的登录凭证来进行验证;如果是不同的应用,只要每个应用都包含相同的登录逻辑,那么他们也是能轻易实现会话共享的,不过这种情况下,登录逻辑里面数字签名以及加密解密要用到的密钥文件或者密钥串,需要在不同的应用里面共享,总而言之,就是需要算法完全保持一致。

这种方式由于把登录凭证直接存放客户端,并且需要cookie传来传去,所以它的缺点也比较明显:

  1. cookie有大小限制,存储不了太多数据,所以要是登录凭证存的消息过多,导致加密签名后的串太长,就会引发别的问题,比如其它业务场景需要cookie的时候,就有可能没那么多空间可用了;所以用的时候得谨慎,得观察实际的登录cookie的大小;比如太长,就要考虑是非是数字签名的算法太严格,导致签名后的串太长,那就适当调整签名逻辑;比如如果一开始用 4096 位的 RSA 算法做数字签名,可以考虑换成 1024、2048 位;
  2. 每次传送cookie,增加了请求的数量,对访问性能也有影响;
  3. 也有跨域问题,毕竟还是要用cookie

前面两种会话管理方式因为都用到cookie,不适合用在 native app 里面:native app 不好管理cookie,毕竟它不是浏览器。这两种方案都不适合用来做纯 api 服务的登录认证。要实现 api 服务的登录认证,就要考虑下面要介绍的第三种会话管理方式。

三.token-based的管理方式

这种方式从流程和实现上来说,跟cookie-based的方式没有太多区别,只不过cookie-based里面写到cookie里面的ticket在这种方式下称为token,这个token在返回给客户端之后,后续请求都必须通过 url 参数或者是 http header 的形式,主动带上token,这样服务端接收到请求之后就能直接从 http header 或者 url 里面取到 token 进行验证:

token-base

这种方式不通过cookie进行token的传递,而是每次请求的时候,主动把token加到 http header 里面或者 url 后面,所以即使在 native app 里面也能使用它来调用我们通过 web 发布的 api 接口。app 里面还要做两件事情:

  1. 有效存储token,得保证每次调接口的时候都能从同一个位置拿到同一个token
  2. 每次调接口的的代码里都得把token加到 header 或者接口地址里面。

看起来麻烦,其实也不麻烦,这两件事情,对于 app 来说,很容易做到,只要对接口调用的模块稍加封装即可。

这种方式同样适用于网页应用,token可以存于localStorage或者sessionStorage里面,然后每发 ajax 请求的时候,都把token拿出来放到 ajax 请求的 header 里即可。不过如果是非接口的请求,比如直接通过点击链接请求一个页面这种,是无法自动带上token的。所以这种方式也仅限于走纯接口的 web 应用。

这种方式用在 web 应用里也有跨域的问题,比如应用如果部署在 a.com,api 服务部署在 b.com,从 a.com 里面发出 ajax 请求到 b.com,默认情况下是会报跨域错误的,这种问题可以用 CORS(跨域资源共享)的方式来快速解决。

这种方式跟cookie-based的方式同样都还有的一个问题就是ticket或者token刷新的问题。有的产品里面,你肯定不希望用户登录后,操作了半个小时,结果ticket或者token到了过期时间,然后用户又得去重新登录的情况出现。这个时候就得考虑tickettoken的自动刷新的问题,简单来说,可以在验证tickettoken有效之后,自动把tickettoken的失效时间延长,然后把它再返回给客户端;客户端如果检测到服务器有返回新的tickettoken,就替换原来的tickettoken

四. 安全问题

在 web 应用里面,会话管理的安全性始终是最重要的安全问题,这个对用户的影响极大。

首先从会话管理凭证来说,第一种方式的会话凭证仅仅是一个sessionid,所以只要这个sessionid足够随机,而不是一个自增的数字 id 值,那么其它人就不可能轻易地冒充别人的sessionid进行操作;第二种方式的凭证ticket以及第三种方式的凭证token都是一个在服务端做了数字签名,和加密处理的串,所以只要密钥不泄露,别人也无法轻易地拿到这个串中的有效信息并对它进行篡改。总之,这三种会话管理方式的凭证本身是比较安全的。

然后从客户端和服务端的 http 过程来说,当别人截获到客户端请求中的会话凭证,就能拿这个凭证冒充原用户,做一些非法操作,而服务器也认不出来。这种安全问题,可以简单采用 https 来解决,虽然可能还有 http 劫持这种更高程度的威胁存在,但是我们从代码能做的防范,确实也就是这个层次了。


JWT 介绍 (https://jwt.io/)

JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑和自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。作为标准,它没有提供技术实现,但是大部分的语言平台都有按照它规定的内容提供了自己的技术实现,所以实际在用的时候,只要根据自己当前项目的技术平台,到官网上选用合适的实现库即可。

使用JWT来传输数据,实际上传输的是一个字符串,这个字符串就是所谓的 json web token 字符串。所以广义上,JWT是一个标准的名称;狭义上,JWT指的就是用来传递的那个token字符串。这个串有两个特点:

  1. 紧凑:指的是这个串很小,能通过 url 参数,http 请求提交的数据以及 http header 的方式来传递;
  2. 自包含:这个串可以包含很多信息,比如用户的 id、角色等,别人拿到这个串,就能拿到这些关键的业务信息,从而避免再通过数据库查询等方式才能得到它们。

通常一个JWT是长这个样子的:

image

要知道一个JWT是怎么产生以及如何用于会话管理,只要弄清楚JWT的数据结构以及它签发和验证的过程即可。

一. JWT的数据结构以及签发过程

一个JWT实际上是由三个部分组成:header(头部)payload(载荷)signature(签名)。这三个部分在JWT里面分别对应英文句号分隔出来的三个串:

image

先来看header部分的结构以及它的生成方法。header部分是由下面格式的 json 结构生成出来:

image

这个 json 中的typ属性,用来标识整个token字符串是一个JWT字符串;它的alg属性,用来说明这个JWT签发的时候所使用的签名和摘要算法,常用的值以及对应的算法如下:

image

typalg属性的全称其实是typealgorithm,分别是类型跟算法的意思。之所以都用三个字母来表示,也是基于JWT最终字串大小的考虑,同时也是跟JWT这个名称保持一致,这样就都是三个字符了…typalgJWT中标准中规定的属性名称,虽然在签发JWT的时候,也可以把这两个名称换掉,但是如果随意更换了这个名称,就有可能在JWT验证的时候碰到问题,因为拿到JWT的人,默认会根据typalg去拿JWT中的header信息,当你改了名称之后,显然别人是拿不到header信息的,他又不知道你把这两个名字换成了什么。JWT作为标准的意义在于统一各方对同一个事情的处理方式,各个使用方都按它约定好的格式和方法来签发和验证token,这样即使运行的平台不一样,也能够保证token进行正确的传递。

一般签发JWT的时候,header对应的 json 结构只需要typalg属性就够了。JWTheader部分是把前面的 json 结构,经过 Base64Url 编码之后生成出来的:

image

(在线 base64 编码:http://www1.tc711.com/tool/BASE64.htm)

再来看payload部分的结构和生成过程。payload部分是由下面类似格式的 json 结构生成出来:

image

payload的 json 结构并不像header那么简单,payload用来承载要传递的数据,它的 json 结构实际上是对JWT要传递的数据的一组声明,这些声明被JWT标准称为claims,它的一个“属性值对”其实就是一个claim,每一个claim的都代表特定的含义和作用。比如上面结构中的sub代表这个token的所有人,存储的是所有人的IDname表示这个所有人的名字;admin表示所有人是否管理员的角色。当后面对JWT进行验证的时候,这些claim都能发挥特定的作用。

根据JWT的标准,这些claims可以分为以下三种类型:

  1. Reserved claims(保留),它的含义就像是编程语言的保留字一样,属于JWT标准里面规定的一些claimJWT标准里面定好的claim有:

iss(Issuser):代表这个 JWT 的签发主体;
sub(Subject):代表这个 JWT 的主体,即它的所有人;
aud(Audience):代表这个 JWT 的接收对象;
exp(Expiration time):是一个时间戳,代表这个 JWT 的过期时间;
nbf(Not Before):是一个时间戳,代表这个 JWT 生效的开始时间,意味着在这个时间之前验证 JWT 是会失败的;
iat(Issued at):是一个时间戳,代表这个 JWT 的签发时间;
jti(JWT ID):是 JWT 的唯一标识。

  1. Public claims,略(不重要)

  2. Private claims,这个指的就是自定义的claim。比如前面那个结构举例中的adminname都属于自定的claim。这些claimJWT标准规定的claim区别在于:JWT规定的claimJWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证;而private claims不会验证,除非明确告诉接收方要对这些 claim 进行验证以及规则才行。

按照JWT标准的说明:保留的claims都是可选的,在生成payload不强制用上面的那些claim,你可以完全按照自己的想法来定义payload的结构,不过这样搞根本没必要:第一是,如果把JWT用于认证, 那么JWT标准内规定的几个claim就足够用了,甚至只需要其中一两个就可以了,假如想往JWT里多存一些用户业务信息,比如角色和用户名等,这倒是用自定义的claim来添加;第二是,JWT标准里面针对它自己规定的claim都提供了有详细的验证规则描述,每个实现库都会参照这个描述来提供JWT的验证实现,所以如果是自定义的claim名称,那么你用到的实现库就不会主动去验证这些claim

最后也是把这个 json 结构做 base64url 编码之后,就能生成payload部分的串:

image

(在线 base64 编码:http://www1.tc711.com/tool/BASE64.htm)

最后看signature部分的生成过程。签名是把headerpayload对应的 json 结构进行 base64url 编码之后得到的两个串用英文句点号拼接起来,然后根据header里面alg指定的签名算法生成出来的。算法不同,签名结果不同,但是不同的算法最终要解决的问题是一样的。以alg: HS256为例来说明前面的签名如何来得到。按照前面alg可用值的说明,HS256 其实包含的是两种算法:HMAC 算法和 SHA256 算法,前者用于生成摘要,后者用于对摘要进行数字签名。这两个算法也可以用 HMACSHA256 来统称。运用 HMACSHA256 实现signature的算法是:

image

正好找到一个在线工具能够测试这个签名算法的结果,比如我们拿前面的headerpayload串来测试,并把“secret”这个字符串就当成密钥来测试:

image

https://1024tools.com/hmac)

最后的结果 B 其实就是 JWT 需要的 signature。不过对比我在介绍 JWT 的开始部分给出的 JWT 的举例:

image

会发现通过在线工具生成的headerpayload都与这个举例中的对应部分相同,但是通过在线工具生成的signature与上面图中的signature有细微区别,在于最后是否有“=”字符。这个区别产生的原因在于上图中的JWT是通过JWT的实现库签发的JWT,这些实现库最后编码的时候都用的是 base64url 编码,而前面那些在线工具都是 bas64 编码,这两种编码方式不完全相同,导致编码结果有区别。

以上就是一个JWT包含的全部内容以及它的签发过程。接下来看看该如何去验证一个JWT是否为一个有效的JWT

二.JWT的验证过程

这个部分介绍JWT的验证规则,主要包括签名验证和payload里面各个标准claim的验证逻辑介绍。只有验证成功的JWT,才能当做有效的凭证来使用。

先说签名验证。当接收方接收到一个JWT的时候,首先要对这个JWT的完整性进行验证,这个就是签名认证。它验证的方法其实很简单,只要把header做 base64url 解码,就能知道JWT用的什么算法做的签名,然后用这个算法,再次用同样的逻辑对headerpayload做一次签名,并比较这个签名是否与JWT本身包含的第三个部分的串是否完全相同,只要不同,就可以认为这个JWT是一个被篡改过的串,自然就属于验证失败了。接收方生成签名的时候必须使用跟JWT发送方相同的密钥,意味着要做好密钥的安全传递或共享。

再来看payloadclaim验证,拿前面标准的claim来一一说明:

iss(Issuser):如果签发的时候这个claim的值是“a.com”,验证的时候如果这个claim的值不是“a.com”就属于验证失败;
sub(Subject):如果签发的时候这个claim的值是“liuyunzhuge”,验证的时候如果这个claim的值不是“liuyunzhuge”就属于验证失败;
(Audience):如果签发的时候这个claim的值是“[‘b.com’,’c.com’]”,验证的时候这个claim的值至少要包含 b.com,c.com 的其中一个才能验证通过;
exp(Expiration time):如果验证的时候超过了这个claim指定的时间,就属于验证失败;
nbf(Not Before):如果验证的时候小于这个claim指定的时间,就属于验证失败;
iat(Issued at):它可以用来做一些 maxAge 之类的验证,假如验证时间与这个claim指定的时间相差的时间大于通过 maxAge 指定的一个值,就属于验证失败;
jti(JWT ID):如果签发的时候这个claim的值是“1”,验证的时候如果这个claim的值不是“1”就属于验证失败;
需要注意的是,在验证一个JWT的时候,签名认证是每个实现库都会自动做的,但是payload的认证是由使用者来决定的。因为JWT里面可能不会包含任何一个标准的claim,所以它不会自动去验证这些claim

以登录认证来说,在签发JWT的时候,完全可以只用subexp两个claim,用sub存储用户的id,用exp存储它本次登录之后的过期时间,然后在验证的时候仅验证exp这个claim,以实现会话的有效期管理。

JWT SSO

场景一:用户发起对业务系统的第一次访问,假设他第一次访问的是系统 A 的 some/page 这个页面,它最终成功访问到这个页面的过程是:

image

在这个过程里面,我认为理解的关键点在于:

  1. 它用到了两个cookie(jwtsid)和三次重定向来完成会话的创建和会话的传递;

  2. jwtcookie是写在 systemA.com 这个域下的,所以每次重定向到 systemA.com 的时候,jwt这个cookie只要有就会带过去;

  3. sidcookie是写在 cas.com 这个域下的,所以每次重定向到 cas.com 的时候,sid这个cookie只要有就会带过去;

  4. 在验证jwt的时候,如何知道当前用户已经创建了 sso 的会话?
    因为jwtpayload里面存储了之前创建的 sso 会话的sessionid,所以当 cas 拿到jwt,就相当于拿到了sessionid,然后用这个sessionid去判断有没有的对应的session对象即可。

还要注意的是:CAS 服务里面的session属于服务端创建的对象,所以要考虑sessionid唯一性以及session共享(假如 CAS 采用集群部署的话)的问题。sessionid的唯一性可以通过用户名密码加随机数然后用 hash 算法如 md5 简单处理;session共享,可以用memcached或者redis这种专门的支持集群部署的缓存服务器管理session来处理。

由于服务端session具有生命周期的特点,到期需自动销毁,所以不要自己去写session的管理,免得引发其它问题,到 github 里找开源的缓存管理中间件来处理即可。存储session对象的时候,只要用sessionid作为 key,session对象本身作为value,存入缓存即可。session对象里面除了sessionid,还可以存放登录之后获取的用户信息等业务数据,方便业务系统调用的时候,从session里面返回会话数据。

场景二:用户登录之后,继续访问系统 A 的其它页面,如 some/page2,它的处理过程是:

image

从这一步可以看出,即使登录之后,也要每次跟 CAS 校验jwt的有效性以及会话的有效性,其实jwt的有效性也可以放在业务系统里面处理的,但是会话的有效性就必须到 CAS 那边才能完成了。当 CAS 拿到jwt里面的sessionid之后,就能到session缓存服务器里面去验证该sessionid对应的session对象是否存在,不存在,就说明会话已经销毁了(退出)。

场景三:用户登录了系统 A 之后,再去访问其他系统如系统 B 的资源,比如系统 B 的 some/page,它最终能访问到系统 B 的 some/page 的流程是:

image

这个过程的关键在于第一次重定向的时候,它会把sid这个cookie带回给 CAS 服务器,所以 CAS 服务器能够判断出会话是否已经建立,如果已经建立就跳过登录页的逻辑。

场景四:用户继续访问系统 B 的其它资源,如系统 B 的 some/page2:

image

这个场景的逻辑跟场景二完全一致。

场景五:退出登录,假如它从系统 B 发起退出,最终的流程是:

image

最重要的是要清除sidcookiejwtcookie可能业务系统都有创建,所以不可能在退出的时候还挨个去清除那些系统的cookie,只要sid一清除,那么即使那些jwtcookie在下次访问的时候还会被传递到业务系统的服务端,由于jwt里面的sid已经无效,所以最后还是会被重定向到 CAS 登录页进行处理。

方案总结
以上方案两个关键的前提:

  1. 整个会话管理其实还是基于服务端的session来做的,只不过这个session只存在于 CAS 服务里面;
  2. CAS 之所以信任业务系统的jwt,是因为这个jwt是 CAS 签发的,理论上只要认证通过,就可以认为这个jwt是合法的。

jwt本身是不可伪造,不可篡改的,但是不代表非法用户冒充正常用法发起请求,所以常规的几个安全策略在实际项目中都应该使用:

  1. 使用 https
  2. 使用 http-only 的cookie,针对sidjwt
  3. 管理好密钥
  4. 防范 CSRF 攻击。

尤其是 CSRF 攻击形式,很多都是钻代码的漏洞发生的,所以一旦出现 CSRF 漏洞,并且被人利用,那么别人就能用获得的jwt,冒充正常用户访问所有业务系统,这个安全问题的后果还是很严重的。考虑到这一点,为了在即使有漏洞的情况将损害减至最小,可以在jwt里面加入一个系统标识,添加一个验证,只有传过来的jwt内的系统标识与发起jwt验证请求的服务一致的情况下,才允许验证通过。这样的话,一个非法用户拿到某个系统的jwt,就不能用来访问其它业务系统了。

在业务系统跟 CAS 发起 attach/validate 请求的时候,也可以在 CAS 端做些处理,因为这个请求,在一次 SSO 过程中,一个系统只应该发一次,所以只要之前已经给这个系统签发过 jwt 了,那么后续 同一系统的 attach/validate 请求都可以忽略掉。

总的来说,这个方案的好处有:

  1. 完全分布式,跨平台,CAS 以及业务系统均可采用不同的语言来开发;
  2. 业务系统如系统 A 和系统 B,可实现服务端无状态
  3. 假如是自己来实现,那么可以轻易的在 CAS 里面集成用户注册服务以及第三方登录服务,如微信登录等。

它的缺陷是:

  1. 第一次登录某个系统,需要三次重定向;
  2. 登录后的后续请求,每次都需要跟 CAS 进行会话验证,所以 CAS 的性能负载会比较大
  3. 登陆后的后续请求,每次都跟 CAS 交互,也会增加请求响应时间,影响用户体验。

转载自:
3 种 web 会话管理的方式
看图理解 JWT 如何用于单点登录

LeoQin wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
0%