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

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

0. 引子

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

0.1 开始

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

0.2 发展

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

而这时,JWT 认证协议彻底知足需求。协议简单清晰,花一个下午就能够搞清楚。nginx

0.3 壮大

多产品线

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

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

微服务

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

成为开放平台

随着公司用户增多(假设跟微信同样,有几亿用户),合做企业也愈来愈多。若是每次都要在后台经过人工给合做伙伴配置帐号密码,分配权限管理,那太麻烦了。同时,一些企业有本身的平台,想要利用个人用户帐号体系实如今这些平台上的登陆(受权登陆)。对于用户的图片,一些图片打印公司也想在通过用户赞成后,直接访问到我服务器上的用户图片,优化体验。web

总之,就是只要用户赞成,他能够分享本身的全部资源(帐号、图片等)。这时,就须要 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. 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 相关字段。

-----------------------------------------请求的 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
...

服务器端从 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');

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

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

在每个请求头里加入 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');

3. OAuth 2.0

内容比较多,另写一篇,参考 这里

相关文章
相关标签/搜索