一、前言
前文梳理了一下Cookie、Session相关知识点;我们知道HTTP 是无状态的,但随着交互式Web应用的兴起在特点场景下我们需要维护这种状态。
那解决方式是什么呢?
1. server签名(Cookie 是维持 HTTP 请求状态的基石)
在学校,入学那一天起,会录入你的身份、账户信息,然后给你发个卡,今后在园区内,你的门禁、打卡、消费都只需要刷这张卡。
- 学校——server
- 卡——凭证/签名(Session、Token)
2. 前端储存
前提是,你要把卡带在身上。也就是说我们需要保存这个凭证,用于后面的认证,要不还是打不开门。
前端的存储方式有很多,如:
- 简单的,挂到全局变量上,但这是个「体验卡」,一次刷新页面就没了。
- 复杂点,存到 Cookie、localStorage 等里,这属于「会员卡」,无论怎么刷新,只要浏览器没清掉或者过期,就一直拿着这个状态。
3. 发送时携带签名(也就是凭证)去认证
拿着卡是刷门禁,嘀~~门开了!
这里就是HTTP请求发送了,需要带着这个凭证去server认证,认证成功就可以得到想要的东西。
二、几个重要概念
什么是凭证(Credentials)
- 实现认证和授权的前提是需要一种媒介(证书) 来标记访问者的身份
- 现实生活中,每个人都会有一张专属的居民身份证,是用于证明持有人身份的一种法定证件。通过身份证,我们可以办理手机卡/银行卡/个人贷款/交通出行等等,这就是认证的凭证。
- 在互联网应用中,一般网站(如掘金)会有两种模式,游客模式和登录模式。游客模式下,可以正常浏览网站上面的文章,一旦想要点赞/收藏/分享文章,就需要登录或者注册账号。当用户登录成功后,服务器会给该用户使用的浏览器颁发一个令牌(Token),这个令牌用来表明你的身份,每次浏览器发送请求时会带上这个令牌,就可以使用游客模式下无法使用的功能。
什么是认证(Authentication)
通俗地讲就是验证当前用户的身份,证明“你是你自己”(比如:你进宿舍需要刷门禁卡,当你卡里的信息与在系统中相匹配的时候,门就开了;)
认证几个场景:
- 用户名密码登录
- 邮箱发送登录链接
- 手机号接收验证码
- 只要你能收到邮箱/验证码,就默认你是账号的主人
什么是授权(Authorization)
- 用户授予第三方应用访问该用户某些资源的权限。
- 在安装手机应用的时候,APP 会询问是否允许授予权限(访问相册、地理位置等权限)。
- 在访问微信小程序时,当登录时,小程序会询问是否允许授予权限(获取昵称、头像、地区、性别等个人信息)。
- 实现授权的方式有(有凭证):Cookie、Session、Token、OAuth
三、方案
常见的前后端鉴权方式
- Session-Cookie
- Token 验证(包括 JWT,SSO)
- OAuth2.0(开放授权,这里暂时不说)
前面说了维护用户状态的解决方式,首先就是要生成签名或者凭证,现在生成签名基本上是两种方案分别是Session和Token,针对这两种种方案让我们理清Cookie、Session、Token、JWT、单点登录之间的关系…
方案一:Session-Cookie
- Session 是另一种记录服务器和客户端会话状态的机制。
- Session 是基于 cookie 实现的,Session 存储在服务器端,SessionId 会被存储到客户端的Cookie 中(不过也可以储存在webStorage中)。
- 但是Cookie 可以借助 HTTP 头、浏览器能力,做到前端无感知!
1. 验证流程
典型的 Session 登陆/验证流程:
- 用户第一次请求服务器的时候,服务端根据浏览器提交的信息,查询用户库,校验用户。
- 服务端把用户登录状态存为 Session,生成一个 SessionId,服务端需要一定的空间存储Session,且一般为了提高响应速度,都是存储在内存中。
- 通过登录接口返回,把 SessionId set 到 Cookie 上,同时 cookie 记录此 SessionID 属于哪个域名。
- 此后浏览器再请求业务接口,SessionId 随 Cookie 带上。
- 服务端查 SessionId 校验 Session。
- 成功后正常做业务处理,返回结果。
使用Session的方案,服务端生成一个签名SessionId,这个签名有三个特性
- 签名本身没有任何意义
- 签名应该是唯一的
- 服务端必须存储该签名(redis,内存,文件系统等等),方式任选。
2. Session 的存储方式
服务端只是给 Cookie 一个 SessionId,而 Session 的具体内容(可能包含用户信息、Session 状态等),要自己存一下。存储的方式有几种:
- Redis(推荐):内存型数据库。以 key-value 的形式存,正合 SessionId-SessionData 的场景;且访问快。
- 内存:直接放到变量里。一旦服务重启就没了
- 数据库:普通数据库,性能不高(一般不用)。
Session 的分布式:
通常服务端是集群,而用户请求过来会走一次负载均衡,不一定打到哪台机器上。那一旦用户后续接口请求到的机器和他登录请求的机器不一致,或者登录请求的机器宕机了,Session 不就失效了吗?
这个问题现在有几种解决方式。
- 一是从「存储」角度,把 Session 集中存储。如果我们用独立的 Redis 或普通数据库,就可以把 Session 都存到一个库里。
- 二是从「分布」角度,让相同 IP 的请求在负载均衡时都打到同一台机器上。以 nginx 为例,可以配置 ip_hash 来实现。
但通常还是采用第一种方式,因为第二种相当于阉割了负载均衡,且仍没有解决「用户请求的机器宕机」的问题。
3. Session 的过期和销毁
只要把存储的 Session 数据销毁就可以了。
方案二:Token 验证
原理
Token方案的原理,同使用Session方案的原理基本差不多!不同的地方就是:
- 服务端生成的签名不再是无意义的了,而是把一些有用的信息加密后得到的一份签名,也就是说该签名是存储有信息的。
- 该签名也不需要存储在服务端了。当然,该签名只能由特定的服务端进行解密。
分类
- Acesss Token
- Refresh Token
- JSON Web Token(简称 JWT)
1. Acesss Token
简概
- Token是访问资源接口(API)时所需要的资源凭证
- 简单 Token 的组成: uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,Token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)
特点
- 服务端无状态化、可扩展性好
- 支持移动端设备
- 安全
- 支持跨程序调用
1.1 验证流程
-
客户端使用用户名跟密码请求登录。
-
服务端收到请求,去数据库验证用户名与密码。
-
验证成功后,服务端根据传过来的唯一标识比如userId(也可以是mac地址等唯一标识),服务端会通过一些算法,如常用的HMAC-SHA256算法,然后加一个密钥,生成一个Token,然后通过BASE64编码一下之后将这个Token发送给客户端;
-
客户端收到 Token 以后,会把它存储起来,比如放在 Cookie 里或者 localStorage 里
-
客户端每次向服务端请求资源的时候需要带着服务端签发的 Token。
-
服务端收到请求,然后去验证客户端请求里面带着的 Token ,如果验证成功,就向客户端返回请求的数据。
- 每一次请求都需要携带 Token,需要把 Token 放到 HTTP 的 Header 里
- 基于 Token 的用户认证是一种服务端无状态的认证方式,服务端不用存放 token 数据。用解析 Token 的计算时间换取 Session 的存储空间,从而减轻服务器的压力,减少频繁的查询数据库。
- Token 完全由应用管理,所以它可以避开同源策略
2. Refresh Token
Token,作为权限守护者,最重要的就是「安全」。
业务接口用来鉴权的 Token,我们称之为 Access token。越是权限敏感的业务,我们越希望 Access token 有效期足够短,以避免被盗用。但过短的有效期会造成 Access token 经常过期,过期后怎么办呢?
- 让用户重新登录获取新 Token,显然不够友好,要知道有的 Access token 过期时间可能只有几分钟。
- 另外一种 Token——Refresh token(专用于刷新 Access token 的 Token)
- Access token 用来访问业务接口,由于有效期足够短,盗用风险小,也可以使请求方式更宽松灵活
- Refresh token 用来获取 Access token,有效期可以长一些,通过独立服务和严格的请求方式增加安全性;由于不常验证,也可以如前面的 Session 一样处理
2.1 验证流程
- Access Token 的有效期比较短,当 Acesss Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Token,如果 Refresh Token 也失效了,用户就只能重新登录了。
- Refresh Token 及过期时间是存储在服务器的数据库中,只有在申请新的 Acesss Token 时才会验证,不会对业务接口响应时间造成影响,也不需要向 Session 一样一直保持在内存中以应对大量的请求。
3. JWT
它是一种成熟的 Token 字符串生成方案,JWT本身就是Token的优化版本,认证流程几乎相同,只是在生成Token时进行加密,解析成功后大部分情况可直接拿Token中的信息进行使用。
-
JSON Web Token(简称 JWT)是目前最流行的跨域认证解决方案。 阮一峰老师的 JSON Web Token 入门教程
-
是一种认证授权机制。
-
JWT 是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC 7519)。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。比如用在用户登录上。
-
可以使用 HMAC 算法或者是 RSA 的公/私秘钥对 JWT 进行签名。因为数字签名的存在,这些传递的信息是可信的。
-
JWT组成Header(头部)+ Payload(负载)+ Signature(签名)
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用:前端可以安装jwt-decode包,解析一个JWT看一下。
- iss: JWT签发者。
- sub: JWT所面向的用户(主题)。
- aud: 接收jwt的一方(受众)。
- exp: JWT的过期时间,这个过期时间必须要大于签发时间。
- nbf: 定义在什么时间之前,该JWT都是不可用的.(生效时间)。
- iat: JWT的签发时间。
- jti: JWT的唯一身份标识,主要用来作为一次性Token,从而回避重放攻击。
3.1 验证流程
- 用户输入用户名/密码登录,服务端认证成功后,会返回给客户端一个 JWT
- 客户端将 Token 保存到本地(通常使用 localstorage,也可以使用 Cookie)
- 当用户希望访问一个受保护的路由或者资源的时候,需要请求头的 Authorization 字段中使用Bearer 模式添加 JWT,其内容看起来是下面这样:
Authorization: Bearer复制代码
- 服务端的保护路由将会检查请求头 Authorization 中的 JWT 信息,如果合法,则允许用户的行为。
- 因为 JWT 是自包含的(内部包含了一些会话信息),因此减少了需要查询数据库的需要。
- 因为 JWT 并不使用 Cookie 的,所以你可以使用任何域名提供你的 API 服务而不需要担心跨域资源共享问题(CORS)。
- 因为用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机制。
3.2 储存&使用
储存
客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。
使用
- 当用户希望访问一个受保护的路由或者资源的时候,可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求头信息的 Authorization 字段里,使用 Bearer 模式添加 JWT。
GET /calendar/v1/events
Host: api.example.com
Authorization: Bearer <token>
- 跨域的时候,可以把 JWT 放在 POST 请求的数据体里。
- 通过 URL 传输
http://www.example.com/user?token=xxx
3.3 特点
- JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
- JWT 不加密的情况下,不能将秘密数据写入 JWT。
- JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
- JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
- JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
- 为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
- 由于 JWT 是自包含的,因此减少了需要查询数据库的需要
- JWT 的这些特性使得我们可以完全依赖其无状态的特性提供数据 API 服务,甚至是创建一个下载流服务。
方案三:SSO单点登录 验证
前面我们已经知道了,在同域下的客户端/服务端认证系统中,通过客户端携带凭证,维持一段时间内的登录状态。
但当我们业务线越来越多,就会有更多业务系统分散到不同域名下,就需要「一次登录,全线通用」的能力,叫做「单点登录」。
“虚假”的单点登录(主域名相同)
简单的,如果业务系统都在同一主域名下,比如wenku.baidu.com tieba.baidu.com,就好办了。可以直接把 Cookie domain 设置为主域名 baidu.com,百度也就是这么干的。
“真实”的单点登录(主域名不同)
比如滴滴这么潮的公司,同时拥有didichuxing.com xiaojukeji.com didiglobal.com等域名,种 Cookie 是完全绕不开的。
这要能实现「一次登录,全线通用」,才是真正的单点登录。
这种场景下,我们需要独立的认证服务,通常被称为 SSO。
3.1 流程
一次「从 A 系统引发登录,到 B 系统不用登录」的完整流程:
- 用户进入 A 系统,没有登录凭证(ticket),A 系统给他跳到 SSO
- SSO 没登录过,也就没有 SSO 系统下的凭证(注意这个和前面 A ticket 是两回事),输入账号密码登录
- SSO 账号密码验证成功,通过接口返回做两件事:一是种下 SSO 系统下凭证(记录用户在 SSO 登录状态);二是下发一个 ticket
- 客户端拿到 ticket,保存起来,带着请求系统 A 接口
- 系统 A 校验 ticket,成功后正常处理业务请求
- 此时用户第一次进入系统 B,没有登录凭证(ticket),B 系统给他跳到 SSO
- SSO 登录过,系统下有凭证,不用再次登录,只需要下发 ticket
- 客户端拿到 ticket,保存起来,带着请求系统 B 接口
3.2 完善流程
上面的过程看起来没问题,实际上很多 APP 等端上这样就够了。但考虑浏览器的场景就会出现问题。
对浏览器来说,SSO 域下返回的数据要怎么存,才能在访问 A 的时候带上?浏览器对跨域有严格限制,Cookie、localStorage 等方式都是有域限制的。
这就需要也只能由 A 提供 A 域下存储凭证的能力。一般我们是这么做的:
图中我们通过颜色把浏览器当前所处的域名标记出来。注意图中灰底文字说明部分的变化。
- 在 SSO 域下,SSO 不是通过接口把 ticket 直接返回,而是通过一个带 code 的 URL 重定向到系统 A 的接口上,这个接口通常在 A 向 SSO 注册时约定
- 浏览器被重定向到 A 域下,带着 code 访问了 A 的 callback 接口,callback 接口通过 code 换取 ticket
- 这个 code 不同于 ticket,code 是一次性的,暴露在 URL 中,只为了传一下换 ticket,换完就失效
- callback 接口拿到 ticket 后,在自己的域下 set cookie 成功
- 在后续请求中,只需要把 Cookie 中的 ticket 解析出来,去 SSO 验证就好
访问 B 系统也是一样
四、总结
1. Token 和 Session 的区别
- Session 是一种记录服务器和客户端会话状态的机制,使服务端有状态化,可以记录会话信息。而 Token 是令牌,访问资源接口(API)时所需要的资源凭证。Token 使服务端无状态化,不会存储会话信息。
- Session是一种HTTP储存机制, 为无状态的HTTP提供持久机制; Token就是令牌, 比如你授权(登录)一个程序时,它就是个依据,判断你是否已经授权该软件;
- Session 和 Token 并不矛盾,作为身份认证 Token 安全性比 Session 好,因为每一个请求都有签名还能防止监听以及重放攻击,而 Session 就必须依赖链路层来保障通讯安全了。如果你需要实现有状态的会话,仍然可以增加 Session 来在服务器端保存一些状态。
- 所谓 Session 认证只是简单的把 User 信息存储到 Session 里,因为 SessionID 的不可预测性,暂且认为是安全的。而 Token ,如果指的是 OAuth Token 或类似的机制的话,提供的是 认证 和 授权 ,认证是针对用户,授权是针对 App 。其目的是让某 App 有权利访问某用户的信息。这里的 Token 是唯一的。不可以转移到其它 App上,也不可以转到其它用户上。Session 只提供一种简单的认证,即只要有此 SessionID ,即认为有此 User 的全部权利。是需要严格保密的,这个数据应该只保存在站方,不应该共享给其它网站或者第三方 App。所以简单来说:如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。如果永远只是自己的网站,自己的 App,用什么就无所谓了。
2. Cookie和Session机制的问题
- 服务器需要记录每个用户的状态信息,内存开销大
- 多机session问题,扩展性差
- CORS(跨域资源共享):当我们需要让数据跨多台移动设备上使用时,跨域资源共享难。
- CSRF(跨站请求伪造):用户在访问银行网站时,他们很容易受到跨站请求伪造的攻击,并且能够被利用其访问其他的网站。
3. Token 对比 Session的优势
- 服务端不需要缓存用户信息,减少服务器压力
- Token缓存在客户端,服务器重启,登录状态不会失效
- Session是浏览器特有的,app要支持会比较繁琐,Token就没有这样的限制
- 易于扩展,存在多台服务器的情况下,使用负载均衡,第一次登录请求转发到A服务器,在A服务器的session中缓存了用户的登录信息,如果第二次请求转发到了B服务器,就丢失了登录状态。虽然可以使用redis等手段共享Session,但Token就简单很多,不同的服务器只需要使用相同的一段解密代码即可。
4. 普通Token 和 JWT 的相同和不同
相同
- 都是Token
- 都是访问资源的令牌(凭证)
- 都可以记录用户的信息
- 都是使服务端无状态化
- 都是只有验证成功后,客户端才能访问服务端上受保护的资源
不同
- 这里说的是普通Token:服务端验证客户端发送过来的 Token 时,还需要查询数据库获取用户信息(这里跟session很像了),然后验证 Token 是否有效。
- JWT:将 Token 和 Payload 加密后存储于客户端,服务端只需要使用密钥解密进行校验(校验也是 JWT 自己实现的)即可,不需要查询或者减少查询数据库,因为 JWT 自包含了用户信息和加密的数据。
- Token认证流程:
1. 用户输入用户名和密码,发送给服务器。
2. 服务器验证用户名和密码,正确的话就返回一个签名过的token(token 可以认为就是个长长的字符串),浏览器客户端拿到这个token。
3. 后续每次请求中,浏览器会把token作为http header发送给服务器,服务器验证签名是否有效(一般需要查询数据库)。
4. 如果有效那么认证就成功,可以返回客户端需要的数据。 特点: 这种方式的特点就是客户端的token中自己保留有大量信息,服务器没有存储这些信息。
- JWT(JSON WEB TOKEN)认证流程:
1. 用户输入用户名和密码,发送给服务器。
2. 校验用户名和密码是否正确,如果正确通过JWT加密算法把用户信息放入(不可逆算法,需要通过指定的方法解析文本,加密方式可在官网查询)。
3. 后续每次请求中,浏览器会把token作为http header发送给服务器,服务器直接将token解析。
4. 如果成功解析则代表此令牌有效,否则无效(如果token被篡改,会解析失败),解析成功之后可直接使用token中存储的信息
5. JWT缺陷
JWT使用起来超级简单方便对不对,但它对于整体应用存在一个设计缺陷,JWT 的最大缺点是,由于服务器不保存 session 状态,因此服务器无法在使用过程中主动废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。就是如果我再次登录(可能是别的客户端)获取到新的Token,之前的Token是不失效(两个客户端可以同时登录),还可以再使用获取服务资源。所以JWT在某个方面安全性上不及Session。
如果要让服务端能够主动失效token,就要在服务端维持Token状态,这又回到有状态的Token机制,这与JWT目前的应用机制是相勃的。所以在经常需要验证的场景中,建议使用Redis来管理一个token的状态,key可以是用户ID,value可以是Token,这里的Token没必要使用JWT了,可以自己来实现。
这里是服务器无法主动失效Token而不是说JWT没有过期时间!生成JWT的payload中是包含过期时间的,服务端解析完Token获取过期时间做对比!
6. Token的身份验证是无状态
- 用户的状态不再存储在服务端的内存中,所以这是一种无状态的认证机制
- 我们不将用户信息存在服务器中。这种概念解决了在服务端存储信息时的许多问题。NoSession意味着你的程序可以根据需要去增减机器,而不用去担心用户是否登录,不用去担心扩展性的问题。
7. 状态到底是什么状态
现在我们来想一个复杂的场景,如在购物网站上买一个书包,流程如下:
输入账号密码登陆 /login 用户信息
选择一款你喜欢的书包加入到购物车中 /cart 用户信息,产品信息
购买支付 /pay 用户信息,商品信息,金额信息
所谓的登录只是验证你是否是一个合法用户,若是合法则跳转到信息的页面,不合法则告知用户名密码错误。
但是我们在第一步给服务器发完/login接口后,服务器就忘记了。。。忘记了你这个人,到底有没有经过认证。
所以在添加商品时/cart 你还是需要将你的账号密码和商品信息一起提交给 addCart接口,再让服务器做验证。
上面的无状态是指的,无登录状态,即服务器不知道某个用户是否已登录过了。因为愚蠢的服务器不知道客户端是否已登录过了,所以每次都要在交互场景(会话)中请求中带上上一次的请求信息,如账号、密码。明明只需要在/login接口中,才需要对比数据库中的账号密码和客户端传的是否一致来确定合法性。这下在添加购物车中也需要再一次的进行同样的重复且没有必要的操作,即降低了响应速度,又对用户不友好(因为每次都需要填账号,密码)。
缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。另外人们常说的“会话”概念则是上面的交互行为的另一种表述方式。