统一授权认证架构设计及实现

统一鉴权认证是一个基础服务。它几乎在所有企业内部都需要,企业内部只要有两个以上系统存在,就有必要实现一套统一的授权系统,否则用户使用非常地麻烦,需要在不同系统之间来回登录切换。特别是在微服务大行其道的今天,这个统一授权认证服务更是一个基础和关键入口。实现的方案有很多种,但都大同小异。

本文主要介绍授权认证服务架构方案设计及实现,这个实践也是本人在企业内部成功实现的经验总结。从旧有系统Cas认证方式到升级Oauth2.0认证,怎么保持两套认证体(Cas和Oauth2)互认整个过程遇到不少问题,以及针对问题解决。将从如下几个方面进行描述。文章比较长,各位看官花点耐心呀!

目录结构

1、概念介绍

   1.1、什么是认证

   1.2、什么是授权

   1.3、什么是鉴权

   1.4、什么是权限控制

   1.5、三者关系

2、Http认证方案

  2.1 认证流程图

  2.2 认证步骤解析

  2.3 优缺点对比

  2.4 使用场景

3、Session-Cookie认证方案

  3.1 认证流程图

  3.2 认证步骤解析

  3.3 优缺点对比

  3.4 使用场景

  3.5 代码实现

4、Token认证方案

  4.1 Token认证原理

  4.2 刷新Token

  4.3 Token与Session-Cookie区别

5、OAuth2认证方案

  5.1 OAuth2定义

  5.2 OAuth2角色

  5.3 OAuth2认证流程

6、JWT认证方案

  6.1 JWT定义

  6.2 JWT组成

  6.3 JWT使用

    6.4 JWT认证流程

  6.5 JWT优缺点

7、集团统一授权认证架构方案

  5.1 方案设计

  5.2 关键问题

  5.3 企业内部实践

  5.4 关键代码

8、总结

 

1、概念介绍

 

1.1、什么是认证

认证(Identification)是指根据声明者所特有的识别信息,确认声明者的身份。

白话文的意思就是:你需要用身份证证明你自己是你自己。

比如我们常见的认证技术:

  • 身份证
  • 用户名和密码
  • 用户手机:手机短信、手机二维码扫描、手势密码
  • 用户的电子邮箱
  • 用户的生物学特征:指纹、语音、眼睛虹膜
  • 用户的大数据识别
  • 等等

1.2、什么是授权

授权(Authorization):在信息安全领域是指资源所有者委派执行者,赋予执行者指定范围的资源操作权限,以便对资源的相关操作。

在现实生活领域例如:银行卡(由银行派发)、门禁卡(由物业管理处派发)、钥匙(由房东派发),这些都是现实生活中授权的实现方式。

在互联网领域例如:web 服务器的 session 机制、web 浏览器的 cookie 机制、颁发授权令牌(token)等都是一个授权的机制。

 

1.3、什么是鉴权

鉴权(Authentication)在信息安全领域是指对于一个声明者所声明的身份权利,对其所声明的真实性进行鉴别确认的过程

若从授权出发,则会更加容易理解鉴权。授权和鉴权是两个上下游相匹配的关系,先授权,后鉴权

在现实生活领域:门禁卡需要通过门禁卡识别器,银行卡需要通过银行卡识别器;

在互联网领域:校验 session/cookie/token 的合法性和有效性

鉴权是一个承上启下的一个环节,上游它接受授权的输出,校验其真实性后,然后获取权限(permission),这个将会为下一步的权限控制做好准备。

 

1.4、什么是权限控制

权限控制(Access/Permission Control)将可执行的操作定义为权限列表,然后判断操作是否允许/禁止

对于权限控制,可以分为两部分进行理解:一个是权限,另一个是控制。权限是抽象的逻辑概念,而控制是具体的实现方式。

在现实生活领域中:以门禁卡的权限实现为例,一个门禁卡,拥有开公司所有的门的权限;一个门禁卡,拥有管理员角色的权限,因而可以开公司所有的门。

在互联网领域:通过 web 后端服务,来控制接口访问,允许或拒绝访问请求。

 

1.5 认证、授权、鉴权和权限控制的关系

看到这里,我们应该明白了认证、授权、鉴权和权限控制这四个环节是一个前后依次发生、上下游的关系,如下图所示:

需要说明的是,这四个环节在有些时候会同时发生。例如在下面的几个场景:

  • 使用门禁卡开门:认证、授权、鉴权、权限控制四个环节一气呵成,在瞬间同时发生
  • 用户的网站登录:用户在使用用户名和密码进行登录时,认证和授权两个环节一同完成,而鉴权和权限控制则发生在后续的请求访问中,比如在选购物品或支付时。

 

2、Http认证方案

在 HTTP 中,基本认证方案(Basic Access Authentication)是允许客户端(通常指的就是网页浏览器)在请求时,通过用户提供用户名和密码的方式,实现对用户身份的验证。

因为几乎所有的线上网站都不会走该认证方案,所以该方案大家了解即可

2.1 认证流程图

 

 

2.2 认证步骤解析

(1)客户端(如浏览器):向服务器请求一个受限的列表数据或资源,例如字段如下

 GET /list/ HTTP/1.1
 Host: www.baidu.com
 Authorization: Basic aHR0cHdhdGNoOmY=

(2)服务器:客户端你好,这个资源在安全区 baidu.com里,是受限资源,需要基本认证;

并且向客户端返回 401 状态码(Unauthorized 未被授权的)以及附带提供了一个认证域www-Authenticate: Basic realm=”baidu.com”要求进行身份验证;

其中Basic就是验证的模式,而realm="baidu.com"说明客户端需要输入这个安全域的用户名和密码,而不是其他域的

 HTTP/1.1 401 Unauthorized
 www-Authenticate: Basic realm= "baidu.com"

(3)客户端:服务器,我已经携带了用户名和密码给你了,你看一下;(注:如客户端是浏览器,那么此时会自动弹出一个弹窗,让用户输入用户名和密码);

输入完用户名和密码后,则客户端将用户名及密码以 Base64 加密方式发送给服务器

传送的格式如下 (其中 Basic 内容为:用户名:密码 的 ase64 形式):

 GET /list/ HTTP/1.1
 Authorization: Basic Ksid2FuZzp3YW5n==

(4)服务器:客户端你好,我已经校验了Authorization字段你的用户名和密码,是正确的,这是你要的资源。

 成功:HTTP/1.1 200 OK
 失败:HTTP/1.1 403 Forbidden

2.3 优缺点对比

  2.3.1 优点

  实现简单,基本所有流行的浏览器都支持

  2.3.2 缺点

  (1)不安全:

  • 由于是基于 HTTP 传输,所以它在网络上几乎是裸奔的,虽然它使用了 Base64 来编码,但这个编码很容易就可以解码出来。
  • 即使认证内容无法被解码为原始的用户名和密码也是不安全的,恶意用户可以再获取了认证内容后使用其不断的享服务器发起请求,这就是所谓的重放攻击

  (2)无法主动注销:

     由于 HTTP 协议没有提供机制清除浏览器中的 Basic 认证信息,除非标签页或浏览器关闭、或用户清除历史记录。

2.4 使用场景

  内部网络,或者对安全要求不是很高的网络。

 

3、Session-Cookie认证方案

Session-Cookie认证是利用服务端的Session(会话)和浏览器(客户端)的 Cookie 来实现的前后端通信认证模式。

在理解这句话之前我们先简单了解下什么是 Cookie以及什么是 Session?

3.1 什么是 Cookie

众所周知,HTTP 是无状态的协议(对于事务处理没有记忆能力,每次客户端和服务端会话完成时,服务端不会保存任何会话信息);

所以为了让服务器区分不同的客户端,就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态可以通过Cookie去实现。

特点:

  • Cookie 存储在客户端,可随意篡改,不安全
  • 有大小限制,最大为 4kb
  • 有数量限制,一般一个浏览器对于一个网站只能存不超过 20 个 Cookie,浏览器一般只允许存放 300个 Cookie
  • Android 和 IOS 对 Cookie 支持性不好
  • Cookie 是不可跨域的,但是一级域名和二级域名是允许共享使用的(靠的是 domain)

3.2 什么是 Session

  Session 的抽象概念是会话,是无状态协议通信过程中,为了实现中断/继续操作,将用户和服务器之间的交互进行的一种抽象;

具体来说,是服务器生成的一种 Session 结构,可以通过多种方式保存,如内存、数据库、文件等,大型网站一般有专门的 Session 服务器集群来保存用户会话;

原理流程:

  1. 客户端:用户向服务器首次发送请求;
  2. 服务器:接收到数据并自动为该用户创建特定的 Session / Session ID,来标识用户并跟踪用户当前的会话过程;
  3. 客户端:浏览器收到响应获取会话信息,并且会在下一次请求时带上 Session / Session ID;
  4. 服务器:服务器提取后会与本地保存的 Session ID进行对比找到该特定用户的会话,进而获取会话状态;
  5. 至此客户端与服务器的通信变成有状态的通信;

特点:

  • Session 保存在服务器上;
  • 通过服务器自带的加密协议进行;

与 Cookie 的差异:

  • 安全性:Cookie 由于保存在客户端,可随意篡改,Session 则不同存储在服务器端,无法伪造,所以 Session 的安全性更高;
  • 存取值的类型不同:Cookie 只支持字符串数据,Session 可以存任意数据类型;
  • 有效期不同:Cookie 可设置为长时间保持,Session 一般失效时间较短;
  • 存储大小不同:Cookie 保存的数据不能超过 4K;

看到这里可能就有人想到了,Session-Cookie是不是就是把Session存储在了客户端的Cookie中呢?是的,的确是这样的,我们接着往下看

3.3 Session-Cookie 的认证流程图

 

3.4 Session-Cookie 认证步骤解析

  1. 客户端:向服务器发送登录信息用户名/密码来请求登录校验;
  2. 服务器:验证登录的信息,验证通过后自动创建 Session(将 Session 保存在内存中,也可以保存在 Redis 中),然后给这个 Session 生成一个唯一的标识字符串会话身份凭证session_id(通常称为sid),并在响应头Set-Cookie中设置这个唯一标识符;

注:可以使用签名对sid进行加密处理,服务端会根据对应的secret密钥进行解密 (非必须步骤)

  1. 客户端:收到服务器的响应后会解析响应头,并自动将sid保存在本地 Cookie 中,浏览器在下次 HTTP 请求时请求头会自动附带上该域名下的 Cookie 信息;
  2. 服务器:接收客户端请求时会去解析请求头 Cookie 中的sid,然后根据这个sid去找服务端保存的该客户端的sid,然后判断该请求是否合法;

3.5 Session-Cookie 优缺点对比

 优点

  • Cookie 简单易用
  • Session 数据存储在服务端,相较于 JWT 方便进行管理,也就是当用户登录和主动注销,只需要添加删除对应的 Session 就可以了,方便管理
  • 只需要后端操作即可,前端可以无感等进行操作;

     缺点

  • 依赖 Cookie,一旦用户在浏览器端禁用 Cookie,这就完蛋,在google浏览器由于考虑用户安全模式下,经常会禁用cookie,所以这个方案局限性还是比较大的;
  • 非常不安全,Cookie 将数据暴露在浏览器中,增加了数据被盗的风险(容易被 CSRF 等攻击);
  • Session 存储在服务端,增大了服务端的开销,用户量大的时候会大大降低服务器性能;
  • 对移动端的支持性不友好;
  • 还有一个非常重要的问题是:如果没一个独立于应用中间session服务器,每个应用服务器必须要开启会话保持才能确保业务达到正确目的,即每个服务器必须得保持有状态,这在分布式环境高并发条件,服务器实现按需求横向扩容的目标是相违背的,也是让人无法接受的。

3.6 使用场景

  • 一般中大型的网站都适用(除了 APP 移动端);
  • 由于一般的 Session 需集中存储在内存服务器上(如 Redis);

 

4、Token认证方案

 

现在我们已经得知,Session-Cookie的一些缺点,以及 Session 的维护给服务端造成很大困扰,我们必须找地方存放它,又要考虑分布式的问题,甚至要单独为了它启用一套 Redis 集群。那有没有更好的办法?

那Token就应运而生了

4.1 Token认证原理

Token是一个令牌,客户端访问服务器时,验证通过后服务端会为其签发一张令牌,之后客户端就可以携带令牌访问服务器,服务端只需要验证令牌的有效性即可。

一句话概括;访问资源接口(API)时所需要的资源凭证

一般 Token 的组成:

uid(用户唯一的身份标识) +time(当前时间的时间戳) +sign(签名,Token的前几位以哈希算法压缩成的一定长度的十六进制字符串)

Token 的认证流程图:

 

Token 认证步骤解析:

  1. 客户端:输入用户名和密码请求登录校验;
  2. 服务器:收到请求,去验证用户名与密码;验证成功后,服务端会签发一个 Token 并把这个 Token 发送给客户端;
  3. 客户端:收到 Token 以后需要把它存储起来,web 端一般会放在 localStorage 或 Cookie 中,移动端原生 APP 一般存储在本地缓存中;
  4. 客户端发送请求:向服务端请求 API 资源的时候,将 Token 通过 HTTP 请求头 Authorization 字段或者其它方式发送给服务端;
  5. 服务器:收到请求,然后去验证客户端请求里面带着的 Token ,如果验证成功,就向客户端返回请求的数据,否则拒绝返还(401);

Token 的优点:

  • 服务端无状态化、可扩展性好:Token 机制在服务端不需要存储会话(Session)信息,因为 Token 自身包含了其所标识用户的相关信息,这有利于在多个服务间共享用户状态
  • 支持 APP 移动端设备;
  • 安全性好:有效避免 CSRF 攻击(因为不需要 Cookie)
  • 支持跨程序调用:因为 Cookie 是不允许跨域访问的,而 Token 则不存在这个问题

Token 的缺点:

  • 配合:需要前后端配合处理;
  • 占带宽:正常情况下比sid更大,消耗更多流量,挤占更多宽带
  • 性能问题:虽说验证 Token 时不用再去访问数据库或远程服务进行权限校验,但是需要对 Token 加解密等操作,所以会更耗性能;
  • 有效期短:为了避免 Token 被盗用,一般 Token 的有效期会设置的较短,所以就有了Refresh Token;

4.2 刷新 Token

业务接口用来鉴权的 Token,我们称之为Access Token。

为了安全,我们的Access Token有效期一般设置较短,以避免被盗用。但过短的有效期会造成Access Token经常过期,过期后怎么办呢?

一种办法是:刷新 Access Token,让用户重新登录获取新 Token,会很麻烦;

另外一种办法是:再来一个 Token,一个专门生成 Access Token 的 Token,我们称为Refresh Token;

  • Access Token用来访问业务接口,由于有效期足够短,盗用风险小,也可以使请求方式更宽松灵活;
  • Refresh Token用来获取 Access Token,有效期可以长一些,通过独立服务和严格的请求方式增加安全性;由于不常验证,也可以如前面的 Session 一样处理;

Refresh Token 的认证流程图:

 

Refresh Token 认证步骤解析:

  1. 客户端:输入用户名和密码请求登录校验;
  2. 服务端:收到请求,验证用户名与密码;验证成功后,服务端会签发一个Access Token和Refresh Token并返回给客户端;
  3. 客户端:把Access Token和Refresh Token存储在本地;
  4. 客户端发送请求:请求数据时,携带Access Token传输给服务端;
  5. 服务端
    • 验证 Access Token 有效:正常返回数据
    • 验证 Access Token 过期:拒绝请求
  6. 客户端( Access Token 已过期)则重新传输 Refresh Token 给服务端;
  7. 服务端( Access Token 已过期)验证 Refresh Token ,验证成功后返回新的 Access Token 给客户端;
  8. 客户端:重新携带新的 Access Token 请求接口;

4.3 Token 和 Session-Cookie 的区别

Session-Cookie和Token有很多类似的地方,但是Token更像是Session-Cookie的升级改良版。

  • 存储地不同:Session 一般是存储在服务端;Token 是无状态的,一般由前端存储;
  • 安全性不同:Session 和 Token 并不矛盾,作为身份认证 Token 安全性比 Session 好,因为每一个请求都有签名还能防止监听以及重放攻击;
  • 支持性不同:Session-Cookie 认证需要靠浏览器的 Cookie 机制实现,如果遇到原生 NativeAPP 时这种机制就不起作用了,或是浏览器的 Cookie 存储功能被禁用,也是无法使用该认证机制实现鉴权的;而 Token 验证机制丰富了客户端类型。

如果你的用户数据可能需要和第三方共享,或者允许第三方调用API接口,用Token 。如果永远只是自己的网站,自己的App,用什么就无所谓了。

5、OAuth2.0认证方案

5.1 OAuth2.0定义

OAuth 2.0 是一个开放授权标准,它允许用户让第三方应用基于令牌Token的授权,在无需暴露用户密码的情况下使第三应用能获取对用户数据的有限访问权限 。

OAuth 2.0定义了四种授权许可类型:

  1. Authorization Code:授权码
  2. Implicit:隐式许可
  3. Resource Owner Password Credentials:密码凭证
  4. Client Credentials :客户端凭证。

5.2 OAuth2.0角色

 

(A)资源拥有者(RO)
(B)客户端(Client)
(C)资源服务器(RS)
(D)授证服务器(AS)。

 

5.3 OAuth2.0认证流程

5.3.1、OAuth 2.0流程图

关键步骤:

(A)用户打开客户端以后,客户端要求用户给予授权。
(B)用户同意给予客户端授权。
(C)客户端使用上一步获得的授权,向认证服务器申请令牌。
(D)授权认证服务器对客户端进行认证以后,确认无误,同意发放令牌。
(E)客户端使用令牌,向资源服务器申请获取资源。
(F)资源服务器确认令牌无误,同意向客户端开放资源。

 

5.3.2、授权码模式

授权码模式(authorization code)是功能最完整、流程最严密的授权模式。
它的特点就是通过客户端的后台服务器,与“服务提供商”的授权认证中心进行互动

关键步骤:

(A)用户访问客户端,后者将前者导向认证服务器。
(B)用户选择是否给予客户端授权。
(C)假设用户给予授权,认证服务器将用户导向客户端事先指定的"重定向URI"(redirection URI),同时附上一个授权码。
(D)客户端收到授权码,附上早先的"重定向URI",向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。
(E)认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。

说明备注:

第1步骤中,客户端申请认证的URI,包含以下参数:
response_type:表示授权类型,必选项,此处的值固定为"code"
client_id:表示客户端的ID,必选项
redirect_uri:表示重定向URI,可选项
scope:表示申请的权限范围,可选项
state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。

 

5.3.2、隐式许可模式

隐式许可模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了"授权码"这个步骤,因此得名。

所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。

 

关键步骤:

(A)客户端将用户导向认证服务器。
(B)用户决定是否给于客户端授权。
(C)假设用户给予授权,认证服务器将用户导向客户端指定的"重定向URI",并在URI的Hash部分包含了访问令牌。
(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。
(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。
(F)浏览器执行上一步获得的脚本,提取出令牌。
(G)浏览器将令牌发给客户端。

说明备注:

A步骤中,客户端发出的HTTP请求,包含以下参数:
response_type:表示授权类型,此处的值固定为"token",必选项。
client_id:表示客户端的ID,必选项。
redirect_uri:表示重定向的URI,可选项。
scope:表示权限范围,可选项。
state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。

 

5.3.3、密码凭证模式

密码凭证模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。

客户端使用这些信息,向"服务提供商"索要授权。
在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。
这通常用在用户对客户端高度信任的情况下,并且只有授权认证中心在其他授权模式无法执行的情况下,才能考虑使用这种模式

关键步骤:

(A)客户端将用户导向认证服务器。
(B)用户决定是否给于客户端授权。
(C)假设用户给予授权,认证服务器将用户导向客户端指定的"重定向URI",并在URI的Hash部分包含了访问令牌。
(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。
(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。
(F)浏览器执行上一步获得的脚本,提取出令牌。
(G)浏览器将令牌发给客户端。

说明备注:

B步骤中,客户端发出的HTTP请求,包含以下参数:
grant_type:表示授权类型,此处的值固定为"password",必选项。
username:表示用户名,必选项。
password:表示用户的密码,必选项。
scope:表示权限范围,可选项。

 

5.3.4 客户端凭证模式

客户端凭证模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向授权认证中心进行认证。严格地说,客户端凭证模式并不属于OAuth框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求"服务提供商"提供服务,其实不存在授权问题。

关键步骤:

(A)客户端向认证服务器进行身份认证,并要求一个访问令牌。
(B)认证服务器确认无误后,向客户端提供访问令牌。

备注说明:

A步骤中,客户端发出的HTTP请求,包含以下参数:
granttype:表示授权类型,此处的值固定为"clientcredentials",必选项。
scope:表示权限范围,可选项。

 

6、JWT Token验证

我们知道了Token的使用方式以及组成,我们不难发现,服务端验证客户端发送过来的 Token 时,还需要查询数据库获取用户基本信息,然后验证 Token 是否有效;

这样每次请求验证都要查询数据库,增加了查库带来的延迟等性能消耗;

那么这时候业界常用的JWT就应运而生了!!!

6.1 JWT定义

JWT是Auth0提出的通过对 JSON 进行加密签名来实现授权验证的方案;

就是登录成功后将相关用户信息组成 JSON 对象,然后对这个对象进行某种方式的加密,返回给客户端;客户端在下次请求时带上这个 Token;服务端再收到请求时校验 token 合法性,其实也就是在校验请求的合法性。

6.2 JWT 的组成

JWT 由三部分组成:Header 头部、Payload 负载和Signature 签名

它是一个很长的字符串,中间用点(.)分隔成三个部分。列如 :

 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header 头部:

在 Header 中通常包含了两部分:

  • typ:代表 Token 的类型,这里使用的是 JWT 类型;
  • alg:使用的 Hash 算法,例如 HMAC SHA256 或 RSA.

 {
   "alg": "HS256",
   "typ": "JWT"
 }

Payload 负载:

它包含一些声明 Claim (实体的描述,通常是一个 User 信息,还包括一些其他的元数据) ,用来存放实际需要传递的数据,JWT 规定了7个官方字段:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

 {
   "sub": "1234567890",
   "name": "John Doe",
   "admin": true
 }

Signature 签名

Signature 部分是对前两部分的签名,防止数据篡改。

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

 HMACSHA256(
   base64UrlEncode(header) + "." +
   base64UrlEncode(payload),
   secret)

JWT 加密、解密标例

 

6.3 JWT使用

客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。

此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

 Authorization: Bearer <token>

6.4 JWT认证流程

其实 JWT 的认证流程与 Token 的认证流程差不多,只是不需要再单独去查询数据库查找用户用户;简要概括如下:

 

6.5 JWT优缺点

 优点:

  • 不需要在服务端保存会话信息(RESTful API 的原则之一就是无状态),所以易于应用的扩展,即信息不保存在服务端,不会存在 Session 扩展不方便的情况;
  • JWT 中的 Payload 负载可以存储常用信息,用于信息交换,有效地使用 JWT,可以降低服务端查询数据库的次数

 缺点:

  • 加密问题:JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
  • 到期问题:由于服务器不保存 Session 状态,因此无法在使用过程中废止某个 Token,或者更改 Token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

 

7、集团统一授权认证架构方案

首先感谢各位看官!文章很长,为了普及认证基本原理不得花重手笔详细描述清楚知识点。能一路看到这里,恭喜你!应该对认证体系有了系统性的了解和认识。

通过上面各种方案分析整理对比,我们已经有了一个清晰结构认知。下面我们来设计一套适用于集团内部的统一的授权认证方案。

我们集团内部有很多个业务系统包括(三大体系:职能、制造、营销):

职能系统:OA系统、商旅系统、财务系统、培训系统、BPM、ERP;

制造系统:MES、MLP、MOM、MSCS、WCC、MTCS、TIMS、SRM、WMS、PLM、......

营销系统:用户中心、设计软件、客服400系统、客情调查问卷、订单系统,账号系统、活动系统、促销系统、会员系统、CRM、电商引流、营销补贴、学习培训、传单系统等各种独立的业务中台和数据中台共有60多个子系统。

这些系统全部都要打通统一授权体系,实现单点登录。所以整体实施方案是比较复杂的。

当时在设计授权方案是遇到不小挑战,基于已有业务主要存在几个重要困难点:

1、集团内部已经有非常多个子系统,算下来大大小小有130多个子系统;

2、集团职能体系和制造体系已经有一套相对早期的基于CAS单点登录系统,而且很难改得动;

3、关键难搞的是只要涉及到财务和生产制造系统,保持系统稳定的指标压倒其他一切;

4、营销体系的各个系统相对比较新,采用Oauth2协议认证,实现营销体系内部统一认证。

虽然制造体系与营销体系系统相对独立,各有一个单点登录但还是不能满足业务的要求,两套认证体系各自为政,这是认人不可接受的。BOSS的目标是:集团内部所有系统一次登录必须互通互联。

 

7.1 集团认证方案设计

 

7.1.1 早期基于CAS集成SSO服务

整体逻辑概图

①用户请求访问业务系统。

②业务系统在系统中查看是否有对应请求的有效令牌,若有,则读取对应的身份信息,允许其访问;若没有或令牌无效,则把用户重定向到统一身份认证平台,并携带业务系统地址,进入第③步。

③在统一身份认证平台提供的页面中,用户输入身份凭证信息,平台验证此身份凭证信息,若有效,则生成一个有效的令牌给用户,进入第④步;若无效,则继续进行认证,直到认证成功或退出为止。

④用户携带第③步获取的令牌,再次访问业务系统。

⑤业务系统获取用户携带的令牌,提交到认证平台进行有效性检查和身份信息获取。

⑥若令牌通过有效性检查,则认证平台会把令牌对应的用户身份信息返回给业务系统,业务系统把身份信息和有效令牌写入会话状态中,允许用户以此身份信息进行业务系统的各种操作;若令牌未通过有效性检查,则会再次重定向到认证平台,返回第③步。

通过统一身份认证平台获取的有效令牌,可以在各个业务系统之间实现应用漫游。

 

验证流程

SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

当用户第一次访问应用系统1的时候,因为还没有登录,会被引导到认证系统中进行登录;根据用户提供的登录信息,认证系统进行身份效验,如果通过效验,应该返回给用户一个认证的凭据--ticket;用户再访问别的应用的时候,就会将这个ticket带上,作为自己认证的凭据,应用系统接受到请求之后会把ticket送到认证系统进行效验,检查ticket的合法性(4,6)。如果通过效验,用户就可以在不用再次登录的情况下访问其他子系统。

sso的实现技术点:

1)所有应用系统共享一个身份认证系统。

 统一的认证系统是SSO的前提之一。认证系统的主要功能是将用户的登录信息和用户信息库相比较,对用户进行登录认证;认证成功后,认证系统应该生成统一的认证标志(ticket),返还给用户。另外,认证系统还应该对ticket进行效验,判断其有效性。

2)所有应用系统能够识别和提取ticket信息

要实现SSO的功能,让用户只登录一次,就必须让应用系统能够识别已经登录过的用户。应用系统应该能对ticket进行识别和提取,通过与认证系统的通讯,能自动判断当前用户是否登录过,从而完成单点登录的功能。

基于CAS PC端认证流程时序图

SSO采用是开源cas单点登录系统,在java web系统集成比较方便,也就是说,我们在jsp方面是基本不用做太大改动的,需要修改认证方式,以及需要验证票据等为数不多的修改。但是针对于移动端或是前后端分离的系统,需要做一些改造设计。

下面对每个场景做单独阐述

一、在jsp下场景的应用,这个时候我们需要修改handler、resolver,如果是在http协议上工作的,那么一定要允许http才行,不然默认不支持单点登录,handler处理登录请求,以工作流的方式作为时间驱动,一步一步的进行单点登录验证以及tgt、st的处理。

二、移动端的认证方式,(Android、iOS)原理是一样的,移动端在本地是有一个小型的关系数据库,叫做sqllite,不过这个关系数据库比较小,就像一个文件夹似的内置,因为移动端本身并没有像我们web端的cookie机制,所以我们使用这个sqlite作为票据存储。

基于CAS 移动端认证流程时序图

工作步骤如下:

1、在手机端的登录页,提交用户名、密码至sso服务器

2、认证通过、返回tgt,然后本地存储这个tgt

3、请求st、并且以上一步请求到的tgt作为参数

4、sso服务器返回st

5、移动端访问业务系统,并且携带st作为参数

6、业务系统接收到请求,会向sso服务器校验st是否有效

7、若有效,则返回该用户信息,若无效,即此次登录无效

基于CAS关键类图

基于CAS集成存在的问题:

1、移动端集成起比较复杂和繁琐。

2、在实际生产使用过程当经常出现tgt串号的问题,多次被业务方投诉。

3、性能问题,随着接入系统越来越多,认证中点不堪其负,出现压力故障。

4、营销体系的CAS产生的TGT与制造体系没有互信机制,两边体系需要多次登录,使用非常麻烦。

 

7.1.2 基于OAuth2集成认证中心

营销体系与其他外部的集成示例

营销体系Oauth2认证方案

 

统一授权认证中心时序图

 

经过微服务SpringCloud体系升级改造,统一认证中心集成了gateway,授权服务authorization,鉴权服务authentication,其时序图如下所示:

关键步骤:

1、用户客户端所有请求都先经过网关gateway(如前后端分离设计的API请求);

2、网关转发到授权服务,请求生成系统token和accessToken;

3、用户发起API请求,先经过网关系统token验证,通过才能进入下一步;

4、网关通过后,拿accessToken调用鉴权服务请求鉴权,鉴权通过,转发到真正的业务服务去;

5、业务服务返回API请求结果。

 

营销体系授权认证中心与集团SSO认证中心两套体系互认示例

 

 

7.2 关键问题

授权认证中心主要提供四个端点:认证端点、令牌颁发端点、令牌校验端点和注销端点。

集团内部CAS单点与营销系统Oauth2.0认证内部打通隧道建立互信机制。

1、用户登录职能或制造系统在CAS认证通过,发起一次营销系统的Oauth2认证服务消息通知,并完成一次授信;

2、用户登录营销系统在oauth2认证服务鉴权通过,发起一次CAS通讯通过用户关键信息进行TGT交换,并做一次token和tgt绑定动作。

3、用户在任意一个体系注销,两边系统都会互发一次消息通知。

通过上次机制设计实现了两套独立认证体系互通互信。不用修改原有旧系统登录逻辑,也是最少代价方案实现全集团系统单点登录 。

 

7.3 核心代码实现

 

 

对外提供访问入口API

@RestController
@RequestMapping("/oauth")
@Module("令牌授权")
public class AccessTokenController implements AccessTokenRemoteService {
    @Resource
    private AccessTokenService accessTokenService;


    @RestApi(name = "授权码模式授权",no = "Auth02",idx = 1)
    @PostMapping(value = "/token/authCode")
    public Response<AccessToken> authByAuthCode(@RequestBody AuthCodeAuthentication authentication){
        return accessTokenService.authByAuthCode(authentication);
    }

    @RestApi(name = "密码模式授权",no = "Auth03",idx = 2)
    @PostMapping(value = "/token/password")
    public Response<AccessToken> authByPassword(@RequestBody PasswordAuthentication authentication) {
        return accessTokenService.authByPassword(authentication);
    }

    @RestApi(name = "客户端凭证模式授权",no = "Auth04",idx = 3)
    @PostMapping(value = "/token/clientCredentials")
    public Response<AccessToken> authByRefreshToken(@RequestBody ClientCredentialsAuthentication authentication) {
        return accessTokenService.authByClientCredentials(authentication);
    }


    @RestApi(name = "刷新令牌授权",no = "Auth05",idx = 4)
    @PostMapping(value = "/token/refreshToken")
    public Response<AccessToken> authByRefreshToken(@RequestBody RefreshTokenAuthentication authentication) {
        return accessTokenService.authByRefreshToken(authentication);
    }

 

服务层代码
 @Service
public class AccessTokenService {
    @Resource
    private RedisTokenStore redisTokenStore;
    @Resource
    private ClientDetailsRepository clientDetailsRepository;
    @Resource
    private LoginUserRepository loginUserRepository;

    @Value("${miop.auth.defaultClientId:}")
    public String defaultClientId;
    @Resource
    private RedisTemplate redisTemplate;

    /**
     * 獲取登錄用戶
     * @param accessToken
     * @return
     */
    public Response<ShiroUser> getUserByToken(String accessToken) {
        OAuth2Authentication auth2Authentication = redisTokenStore.readAuthentication(accessToken);
        if(auth2Authentication == null){
            return Response.failure("令牌已失效");
        }
        UserCO user =  (UserCO)auth2Authentication.getUserAuthentication().getPrincipal();
        if(user == null){
            return Response.failure("令牌已失效");
        }
        SpringContextUtil.getBean(AccessTokenService.class).renewalAccessToken(accessToken);
        ShiroUser shiroUser = BeanToolkit.instance().copy(user,ShiroUser.class);
        shiroUser.setAccessToken(accessToken);
        return Response.of(shiroUser);
    }

    /**
     * 一分钟内只更新一次token的过期时间
     * @param accessToken
     * @return
     */
    @Cacheable(value = "renewalAccessToken",key = "#accessToken")
    public char renewalAccessToken(String accessToken){
        redisTokenStore.renewalAccessToken(accessToken);
        return '1';
    }


    /**
     * 註銷用戶
     * @param accessToken
     * @return
     */
    public Response logout(String accessToken) {
        OAuth2Authentication auth2Authentication = redisTokenStore.readAuthentication(accessToken);
        if(auth2Authentication != null){
            OAuth2AccessToken oAuth2AccessToken = redisTokenStore.getAccessToken(auth2Authentication);
            if(oAuth2AccessToken != null){
                redisTokenStore.removeRefreshToken(oAuth2AccessToken.getRefreshToken().getValue());
                redisTokenStore.removeAccessToken(accessToken);
            }
        }
        loginUserRepository.logout(accessToken);
        return Response.success();
    }

    public Response<String> createJwt(String accessToken) {
        Response<ShiroUser> shiroUser = getUserByToken(accessToken);
        Map claims = (Map) JSON.toJSON(shiroUser);
        String jwt = JwtUtils.createJwt(claims,20);
        return Response.of(jwt);
    }


    /**
     * 用户密码授权
     * @param authentication
     * @return
     */
    public Response<AccessToken> authByPassword(PasswordAuthentication authentication){
        AuthLog authLog = createAuthLog(authentication);
        try {
            AccessToken accessToken = auth(authentication,GrantType.PASSWORD);
            authLog.setStatus(Status.NORMAL.getValue());
            ShiroUser user = accessToken.getUser();
            if(user != null){
                authLog.setUserName(user.getName());
            }
            return Response.of(accessToken);
        }catch (Exception e){
            authLog.setStatus(Status.UN_NORMAL.getValue());
            authLog.setMsg(e.getMessage());
            throw e;
        }finally {
            redisTemplate.opsForList().rightPush(LogConstants.LOGIN_LOG_REDIS_QUEUE, authLog);
        }
    }


    public Response<AccessToken> authByAuthCode(AuthCodeAuthentication authentication){
        AccessToken accessToken = auth(authentication,GrantType.AUTHORIZATION_CODE);
        return Response.of(accessToken);
    }

    public Response<AccessToken> authByRefreshToken(RefreshTokenAuthentication authentication){
        AccessToken accessToken = auth(authentication,GrantType.REFRESH_TOKEN);
        return Response.of(accessToken);
    }

    public Response<AccessToken> authByClientCredentials(ClientCredentialsAuthentication authentication){
        AccessToken accessToken = auth(authentication,GrantType.CLIENT_CREDENTIALS);
        return Response.of(accessToken);
    }

    private AccessToken auth(BaseAuthentication authentication,GrantType grantType){
        ClientDetails clientDetails = clientDetailsRepository.selectByIdWithCache(authentication.getClientId());
        if(!StringUtil.equals(clientDetails.getClientSecret(),authentication.getClientSecret())){
            throw new AuthException("无效的 client credentials:"+authentication.getClientSecret());
        }
        if(!clientDetails.getAuthorizedGrantTypes().contains(grantType.getValue())){
            throw new AuthException("该clientId不允许"+grantType.getValue()+"授权方式");
        }
        for (String scope : authentication.getScope().split(",")) {
            if (!clientDetails.getScope().contains(scope)) {
                throw new AuthException("不合法的scope:"+scope);
            }
        }
        TokenRequest tokenRequest = new TokenRequest((Map)JSON.toJSON(authentication), authentication.getClientId(),
                Arrays.asList(authentication.getScope().split(",")), grantType.getValue());
        OAuth2AccessToken oAuth2AccessToken = AuthorizationServer.endpoints.getTokenGranter().grant(grantType.getValue(),tokenRequest);
        AccessToken accessToken = getAccessToken(oAuth2AccessToken);
        return accessToken;
    }


    /**
     * 转成自定义的令牌对象
     * @param oAuth2AccessToken
     * @return
     */
    private AccessToken getAccessToken(OAuth2AccessToken oAuth2AccessToken) {
        DefaultOAuth2AccessToken defaultOAuth2AccessToken = (DefaultOAuth2AccessToken)oAuth2AccessToken;
        DefaultExpiringOAuth2RefreshToken refreshToken = (DefaultExpiringOAuth2RefreshToken)defaultOAuth2AccessToken.getRefreshToken();
        AccessToken accessToken = new AccessToken();
        accessToken.setTokenType(defaultOAuth2AccessToken.getTokenType());
        accessToken.setAccessToken(oAuth2AccessToken.getValue());
        accessToken.setAccessTokenExpiresIn(oAuth2AccessToken.getExpiresIn());
        if(oAuth2AccessToken.getRefreshToken() != null){
            accessToken.setRefreshToken(defaultOAuth2AccessToken.getRefreshToken().getValue());
            accessToken.setRefreshTokenExpiresIn((int)((refreshToken.getExpiration().getTime() - System.currentTimeMillis())/1000));
        }
        OAuth2Authentication auth2Authentication = redisTokenStore.readAuthentication(oAuth2AccessToken.getValue());
        if(auth2Authentication != null){
            Object principal = auth2Authentication.getUserAuthentication().getPrincipal();
            ShiroUser shiroUser = BeanToolkit.instance().copy(principal,ShiroUser.class);
            shiroUser.setAccessToken(oAuth2AccessToken.getValue());
            accessToken.setUser(shiroUser);
            Map claims = (Map) JSON.toJSON(shiroUser);
            String jwt = JwtUtils.createJwt(claims,20);
            accessToken.setJwt(jwt);
        }
        return accessToken;
    }

    private AuthLog createAuthLog(PasswordAuthentication authentication) {
        AuthLog authLog = new AuthLog();
        authLog.setClientId(authentication.getClientId());
        authLog.setGrantType(GrantType.PASSWORD.getValue());
        authLog.setAccessTime(LocalDateTime.now());
        authLog.setIp(RequestUtil.getIpAddress());
        authLog.setTraceId(Trace.traceId.get());
        authLog.setAccount(authentication.getAccount());
        authLog.setOrgNo(authentication.getOrgNo());
        return authLog;
    }

 

8、总结

1、通过本文,我们可以全面系统学习认证体系的原理和设计方案;

2、所有公司只有两个以上系统存在就有必要实现授证登录体系,单独从认证服务本身来看是比较简单的,但是跟多个系统集成的时候还是遇到不少的问题,特别是大企业里系统错综复杂,动则上个百个系统集团单点登录,要成功实施对接改造也不是那么简单的事,希望通过本文我们能学到一点启发。

3、轻巧方案,用最小的代价,最快方式去实现。认证授权还是有一些细节的坑,比如跨域问题、安全攻防的问题本文还没有提到,等有空再补了!有需要的同学持续关注吧,我会持续完善和修改,暂时先偷一下懒。

4、本文主要是工作中总结的资料方案,也有引用到一些网上的资料(时间太久也不知道原作者是谁,若有涉及侵权问题请联系本人)。

 

参考资料

[1] OAuth:https://en.wikipedia.org/wiki/OAuth
[2] RFC 6749:http://www.rfcreader.com/#rfc6749

 

posted on 2023-03-19 13:10  陈国利  阅读(6636)  评论(11编辑  收藏  举报