从有状态应用(Session)到无状态应用(JWT),以及 SSO 和 OAuth2

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_29726869/article/details/83020046

从有状态应用(Session)到无状态应用(JWT),以及 SSO 和 OAuth2

不管用哪种方式认证用户,都可能被中间人攻击窃取 SessionID 或 Token,从而发生 CSRF 攻击。解决方式就是全站 HTTPS。现在 Let’s Encrypt 已经支持免费的通配符 HTTPS 证书了。

0. 引子

HTTP 协议是无状态的,要保存用户状态需要额外的机制。

0.1 开始

刚开始时,多数公司使用的技术栈是:单台云服务器上安装所需的所有软件,包括 Nginx 提供 Web 服务,MySQL 数据库,PHP-FPM 应用程序服务。这时候使用的用户认证协议使用最简单的 Session。客户端的每个请求都会携带 Cookie,其中保存了 SessionID 字段,服务器可以通过这个 SessionID 字段访问到对应的 Session(例如 PHP 中的 $_SESSION),从而识别出用户登录状态。Session 中还可以添加一些常用的字段进来(比如用户名、手机号等),避免对数据库的频繁访问。

0.2 发展

后来,随着用户量增大、并发增大,单台服务器搞不定了,于是搞了个水平扩展的服务器集群,通过 Nginx 或 LVS 实现负载均衡。这时发现个问题,用户登录后 Session 是保存到集群中的某一台服务器上的。要使 Session 机制可以在分布式环境下继续工作,需要一些额外操作。而且对于现在的大前端(浏览器、APP、小程序)趋势来说,Cookie 机制略显累赘。

而这时,JWT 认证协议完全满足需求。协议简单清晰,花一个下午就可以搞清楚。

0.3 壮大

多产品线

公司发展过程中,产品线会慢慢增多,比如百度的贴吧、网盘、浏览器等。这时,需要一套单点登录机制 SSO(Single sign-on),用户只要一次登录,就可以使用这一系列产品。SSO 描述了认证的问题。

SSO 需要一个独立的认证中心 CAS(Central Authentication Service,中央认证服务),只有认证中心能提供登录入口,接受用户的用户名密码等凭证,其他系统无登录入口,只接受认证中心的间接授权。这里有个开源的 CAS:apereo CAS,其服务端用 Java 实现,客户端支持多种语言。其架构文档可以参考 这里

微服务

单体项目拆分成微服务后,可以更加灵活。通常所有的服务都在网关之后,所有请求都发送到网关,由网关统一转发。微服务的网关通常实现了 OAuth,成为认证授权中心,用于判断是否有足够权限。微服务之间可以通过 JWT 进行访问鉴权,避免身份认证。

成为开放平台

随着公司用户增多(假设跟微信一样,有几亿用户),合作企业也越来越多。如果每次都要在后台通过人工给合作伙伴配置账号密码,分配权限管理,那太麻烦了。同时,一些企业有自己的平台,想要利用我的用户账号体系实现在这些平台上的登录(授权登录)。对于用户的图片,一些图片打印公司也想在经过用户同意后,直接访问到我服务器上的用户图片,优化体验。

总之,就是只要用户同意,他可以分享自己的所有资源(账号、图片等)。这时,就需要 OAuth2 了。这是一个授权框架,描述了各种授权的问题。

0.4 关于 authorization(授权) 和 authentication(认证)

  • authorization(授权):表示允许做某些事情
  • authentication(认证):判断真实性

例如,用户登录论坛时,需要先用用户名和密码认证用户有没有权限登录,如果密码正确则认证通过,登录成功。用户登录后,判断其角色并授予相应的权限,例如超级管理员可以删除所有人,版主可以删除其版块的帖子。

1. Session

1.1 Session 原理

最传统的用户认证方式。用户首次访问应用服务器后建立会话,服务器可以使用 Set-Cookie 这个 HTTP Header,将会话的 SessionID 写入在用户端保存的 Cookie 中(具体的名字可以自行设置,系统中统一即可)。下次用户再次向这个域名发请求时会携带所有 Cookie 信息,包括这个 SessionID。

Session 信息保存在服务器端,而用于唯一标识这个 Session 的 SessionID 则保存在对应客户端的 Cookie 中。SessionID 这个会话标识符本质上是一个随机字符串,每个用户的 SessionID 都不一样。

Session 中可以保存很多信息。例如设置一个 IsLogin 字段,用户通过账号密码登录后,将这个字段设置为 TRUE。这样,在 Session 的有效期内(比如 2 小时),即使用户关闭网页,再次打开后仍会保持登录状态(除非用户清理了 Cookie,导致其访问服务器时没有携带 SessionID 字段)。对于其他的常用字段(如 userID、userName等)也可以添加到 Session 中,以减少数据库的访问压力,但注意不要太大,因为所有用户的会话信息都是保存在服务器的内存中的。

1.2 通过 Fiddler 抓包分析 Session

下面的示例,基于 PHP 语言,CodeIgniter 框架。同时,省略了无关的 HTTP Header,重点分析 Session 相关字段。

1. 首次访问某个网站

在第一次访问一个网站时,浏览器中没有对应 Cookie 信息,所有请求的 HTTP Header 中没有 Cookie 这个字段。如果应用服务器支持会话,可以在为这个用户创建 Session 后,通过在响应的 HTTP Header 中使用 Set-Cookie 字段将这个会话的 SessionID 保存到浏览器的 Cookie 中。可以看到我这里对应的 SessionID 的名字是 ci_session:

-----------------------------------------请求的 HTTP Header-----------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
If-Modified-Since: Thu, 10 May 2018 06:20:36 GMT
...

-----------------------------------------响应的 HTTP Header-----------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:21:13 GMT
Content-Type: text/html; charset=UTF-8
Set-Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf; expires=Thu, 10-May-2018 08:21:13 GMT; Max-Age=7200; path=/; HttpOnly
...

这里 Set-Cookie 中的各个字段解释如下,完整的中文版解释参考 这里

  • ci_session:SessionID,这个会话对应的服务器上的 Session 的唯一标识符。
  • expires:Cookie 的有效期。
  • Max-Age:Cookie 过期前的秒数。
  • path:可以在 Header 中使用这个 Cookie 的 URL 路径,这里表示这个域名下的所有请求都会携带这个 Cookie。
  • HttpOnly:表示这个 Cookie 无法通过 JavaScript 的 Document.cookie 属性或 XMLHttpRequest 和 Request 这两个 API 访问,避免 XSS(cross-site scripting,跨站脚本攻击)。

2. 再次访问这个网站

每次通过域名或 IP 地址访问时,浏览器都会检查是否有可用的 Cookie,如果有,则放到请求的 HTTP Header 中一同发送到服务器:

-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:22:02 GMT
Content-Type: text/html; charset=UTF-8
...

3. 登录

登录成功之后,登录请求对应的响应会再次设置 Cookie 字段,重新设置 Cookie 字段的有效期。我的应用程序中设置 Session 为两个小时的有效期:

这里演示的是通过 AJAX 登录,所以有 Origin 和 X-Requested-With 这两个由浏览器自动设置的字段:

-----------------------------------------请求的 HTTP Header-------------------------------------------
POST http://tuan.local.cn/index/login_password HTTP/1.1
Host: tuan.local.cn
Origin: http://tuan.local.cn
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Referer: http://tuan.local.cn/
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

{"Mobile":"18866668888","Password":"888666"}

-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Set-Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf; expires=Thu, 10-May-2018 08:22:33 GMT; Max-Age=7200; path=/; HttpOnly
...

4. 登录后的访问

跟正常访问没有区别,只是携带的 Cookie 中有 SessionID,且服务器端对应的 Session 中需要(比如 IsLogin=true,自己设置)标识已登录状态:

-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:22:34 GMT
...

1.3 Session 的不足

Session 的主要问题有:

  • 服务器压力大:每个用户在认证后,Session 信息都会保存在服务器的内存中,开销大。
  • 难以扩展:对于基于 Session 的分布式系统,要实现负载均衡,有两个办法:确保同一用户始终访问同一个服务器,或在多台服务器之间同步 Session。对于前者,Nginx 也可以用 ip_hash 把同一来源的 IP(同一 C 段)指向后端的同一台机器。对于后者则需要通过 Session Sticky 机制在多台服务器之间同步 Session(例如 Nginx 的扩展模块 nginx-sticky-module。假设 Session 存储在 A 服务器上,而用户访问了 B 服务器,则可以将 Session 从 A 同步到 B,但是如果存储 Session 的 A 服务器挂掉,还是会导致用户掉线)。

还有,就是目前大前端的发展,除了浏览器外,各种 APP、小程序层出不穷,而非浏览器下环境下避免使用 Cookie 可能会更简单。

2. JWT

JWT 官网的详细介绍 
Larval + Vue 案例

Session 之所以这么麻烦,是因为需要在服务器端保存信息,那我把信息保存在客户端,不就可以避免这个麻烦了嘛。JWT 就是这么个思路,服务器端保存加密机制及密钥,对用户指定字段进行加密后的字符串保存在客户端,用户下次请求时携带加密前的字段和加密后的字符串,如果跟服务器加密结果匹配,则认为登录成功。

2.1 JWT 原理

JWT(JSON web token)是一种认证协议,可以发布接入令牌(Access Token,保持在客户端)并对发布的签名接入令牌进行验证。令牌(Token)本身包含一系列声明,应用程序可以根据这些声明限制用户对资源的访问。

JWT 由三段信息构成的:

  • header
  • payload
  • signature

JWT 示例:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjU5NDM4MTksIm5iZiI6MTUyNTk0Mzg3OSwiZXhwIjoxNTI1OTQ3NDE5LCJ1aWQiOjF9.jL-Hrl8obZlLGutjr-nVPCSoF2ObFh-rWfSwSZxoxzs
  • 1

1. header 部分

Header 部分用于声明协议类型和加密方式。

上面的 JWT 示例的 header 部分经过 base64_decode 后得到原始 JSON 字符串,内容如下:

{
    "typ":"JWT",
    "alg":"HS256",
    "jti":"4f1g23a12aa"
}

其中,typ 内容固定为 JWT,alg 表示加密算法,这里使用的是 HMAC SHA256。

2. payload 部分

payload 部分用于存放负载,将明文信息经过 base64 编码后存储,未经加密,不可存储敏感信息。包括以下三种:

  • JWT 标准中注册的声明
  • 公共声明
  • 私有声明

JWT 标准中注册的声明(不强制使用)有以下几种,完整版可以 参考这里

  • iat:Issued At,签发时间
  • iss:Issuer,JWT 签发者
  • sub:subject,JWT 所面向的订阅者,每个 Issuer 范围内是唯一的
  • aud:Audience,JWT 的接收方
  • exp:Expiration Time,过期时间,这个过期时间必须要大于签发时间
  • nbf:定义在什么时间之前,该 JWT 都是不可用的.
  • jti:JWT 的唯一身份标识,主要用来作为一次性 Token,避免重放攻击

上面 JWT 示例中的 payload 部分对应的 JSON 字符串为:

{
    "iss":"http:\/\/example.com",
    "aud":"http:\/\/example.org",
    "jti":"4f1g23a12aa",
    "iat":1525943995,
    "nbf":1525944055,
    "exp":1525947595,
    "userID":6666,
    "userName":"kika",
    "userSex":"m"
}

这个 payload 中添加了几个自定义字段。

3. signature 部分

将 header 和 payload 经过 base64 编码后,用 . 句点拼接成一个字符串,通过 HMACSHA256(Java 的方法)或 hash_hmac(PHP 的方法),使用指定密钥加密这个字符串得到 signature。

JAVA:

sig = HMACSHA256(base64UrlEncode(header) + "." +  base64UrlEncode(payload),  secret);
  •  

PHP:

$sig = hash_hmac('sha256', base64_encode($header) + "." +  base64_decode($payload), $secret);
  •  

JWT 支持两种签名方式:

  • 密钥:基于字符串,简单,安全性低
  • RSA 和 ECDSA 签名:基于公钥和私钥,需要先生成私钥文件,签名时指定这个文件的位置

2.2 JWT 特点

  • 信息基于 base64 编码转换为 ASCII 码,传输可靠。
  • 信息是不加密存储的,不可存敏感信息。
  • JWT 本质上是通过时间换空间,服务器不存储用户状态信息,但是每个用户请求都会消耗 CPU 时间来验证 Token。
  • 基于 Token 的鉴权机制保持了 HTTP 协议的无状态型,从而实现更简单的水平扩展。
  • 需要在服务器端额外编程(Session 则不用)。
  • 生成签名字段时,支持使用密钥字符串签名(安全性较低),也支持使用 RSA、ECDSA 私钥签名。

用户登陆后,可以把一些常用字段(用户标识,是否是管理员,权限有哪些等等可以公开的信息)用 JWT 编码存储在 Cookie 中,每次服务器读取到 Cookie 后就可以解析到当前用户对应的信息,减小数据库压力。也可以用 Authorization: Bearer <jwttoken> 的方式通过 HTTP Header 仅发送 JWT 的 Token。

2.3 JWT 工作流程

  1. 用户通过账号密码发起登录请求
  2. 服务器验证通过后,设置 header 和 payload,并得到加密后的签名,然后将这三部分作为 Token 发送给用户
  3. 客户端保存 Token,并在每个请求中附加这个 Token
  4. 如果请求携带了 Token,服务器会验证这个 Token 并根据验证结果进行不同处理

发送请求时,Token 放在请求的 HTTP Header 中。另外,如果发生跨域,例如 www.xx.com 下发出到 api.xx.com 的请求,需要在服务端开启 CORS(跨域资源共享):

Access-Control-Allow-Origin: *
  •  

2.4 通过 Fiddler 抓包分析 JWT

下面的示例,基于 PHP 语言,CodeIgniter 框架。同时,省略了无关的 HTTP Header,重点分析 JWT 相关字段。

1. 登录成功,Token 创建并设置客户端的 Cookie

-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/create_token HTTP/1.1
Host: jwt.com
...

-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Fri, 11 May 2018 02:27:19 GMT
Set-Cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDU2MzksIm5iZiI6MTUyNjAwNTY5OSwiZXhwIjoxNTI2MDA5MjM5LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.MvYG6L71mM_AJj5FT4--RzCluIQ__nqgYSe9RTj8VCk; expires=Fri, 11-May-2018 04:27:19 GMT; Max-Age=7200; path=/
Content-Length: 1052
...
  •  

2. 用户再次访问时,携带 Cookie

服务器端从 Cookie 中提取 jwt 这个字段后验证签名,如果通过验证则认为内容可靠,解析其中的内容并以此决定用户登录状态、权限等:

-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/ HTTP/1.1
Host: jwt.com
Cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDU2MzksIm5iZiI6MTUyNjAwNTY5OSwiZXhwIjoxNTI2MDA5MjM5LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.MvYG6L71mM_AJj5FT4--RzCluIQ__nqgYSe9RTj8VCk
...

通过 HTTP Header 字段 Authorization 实现

1. 登录成功,服务器创建并设置客户端的 Authorization 这个 HTTP Header

-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/create_token HTTP/1.1
Host: jwt.com
...

-----------------------------------------响应的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Fri, 11 May 2018 02:35:19 GMT
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDc3NTQsIm5iZiI6MTUyNjAwNzgxNCwiZXhwIjoxNTI2MDExMzU0LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.CBsw7_rDC-GeJBob2JwCOITp7L80g_VT9KUtSVmKYKY
Host: jwt.com

2. 用户再次访问时,携带 Authorization

-----------------------------------------请求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/ HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDc3NTQsIm5iZiI6MTUyNjAwNzgxNCwiZXhwIjoxNTI2MDExMzU0LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.CBsw7_rDC-GeJBob2JwCOITp7L80g_VT9KUtSVmKYKY
Host: jwt.com

后端服务器对这个 Authorization 进行判断即可。

2.5 示例(基于 PHP)

对于 PHP,可以使用的 JWT 库有 jwtjwt-auth。这里以第一个 jwt 为例,具体操作请结合所使用语言及框架和安装的 JWT 库。

2.5.1 使用 composer 安装 JWT 库

composer require lcobucci/jwt
  •  

注意,PHP 版本需要 5.5+,同时需要开启 OpenSSL 扩展。

2.5.2 通过 JWT 库生成 Token

使用秘钥签名

use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;

public function create_token() {
    $builder = new Builder();
    $signer = new Sha256();

    // 设置签发者
    $builder->setIssuer('http://xx.com');
    // 设置接收者
    $builder->setAudience('http://xx.com');
    // 设置 ID,可以用来区分
    $builder->setId('4f1g23a12aa', true);
    // 设置签发时间
    $builder->setIssuedAt(time());
    // 在 60 秒内该 token 无法使用
    $builder->setNotBefore(time() + 60);
    // 设置过期时间位 2 小时
    $builder->setExpiration(time() + 7200);
    // 设置自定义的 payload 信息
    $builder->set('userID', 6666);
    $builder->set('userName', 'kika');
    $builder->set('userSex', 'm');
    // sha256 签名,密钥字符串可以自定义
    $builder->sign($signer, 'signatureString');
    // 获取生成的token
    $token = $builder->getToken();

    // 可以通过 Cookie 传输
    set_cookie('jwt', $token, 7200);
    // 也可以通过 HTTP Header 传输,在前端保存 token 后添加到 HTTP Header 即可:Authorization: Bearer xx.xx.xx

    // 查看字段内容
    $token = explode('.', $token);
    echo base64_decode($token[0]).'<br/>';
    echo base64_decode($token[1]).'<br/>';
}

使用 RSA 和 ECDSA 签名

把上面使用字符串加密的这一行:

    $builder->sign($signer, 'signatureString');
  • 1

替换为使用密钥文件加密即可,需要提供私钥地址:

    $builder->sign($signer, $keychain->getPrivateKey('私钥地址'));
  • 1

在每一个请求头里加入 Authorization,并加上 Bearer:

fetch('api/user', {
  headers: {
    'Authorization': 'Bearer ' + token
  }
})

2.5.4 验证签名

通过 Cookie 传输 JWT 信息:

if ($token = get_cookie('jwt')) {
    $rs = $this->verify_token($token);
    if ($rs) {
        echo 'you have right jwt<br />';
    } else {
        echo 'error<br />';
    }
}

通过 HTTP Header 传输 JWT 信息:

$headers = apache_request_headers();
if (!empty($headers['Authorization']) && $token = $headers['Authorization']) {
    $token = substr($token, strpos($token, 'Bearer ') + 7);
    $rs = $this->verify_token($token);
    if ($rs) {
        echo 'you have right jwt from Authorization<br />';
    } else {
        echo 'error Authorization<br />';
    }
}

2.5.5 提取数据

直接从 $token 中获取所有数据:

public function get_claims ($token) {
    $parser = new Parser();
    $parse = $parser->parse($token);
    return $parse->getClaims();
}

也可以获取单条数据:

$parse->getClaim('aud');

猜你喜欢

转载自blog.csdn.net/qq_29726869/article/details/83020046