什么是认证(Authentication)
通俗地讲,认证就是验证当前用户的身份是否合法的过程。比如指纹打卡,当你的指纹和系统里录入的指纹相匹配时,就打卡成功。
像用户名密码登录、邮箱发送登录链接、手机接收验证码等等都属于互联网中的常见认证方式,只要你能收到验证码,就默认你是账号的主人。认证主要是为了保护系统的隐私数据与资源。
而一旦认证通过,那么一定时间内就不用再认证了(银行转账等高度敏感操作除外),这时就可以将用户信息保存在会话中。会话是系统为了保存当前用户的登录状态所提供的机制,实现方式通常是 Session 或 Token,后面说。
什么是授权(Authorization)
简单来讲就是谁(who)对什么(what)进行了什么操作(how),和认证不同,认证是确认用户的合法性,以及让服务端知道你是谁,而授权则是为了更细粒度地对资源进行权限上的划分。所以授权是在认证通过后,控制不同的用户访问不同的资源。
并且授权是双向的,可以是用户给服务端授权,也可以是服务端给用户授权。
- 用户给服务端授权:比如你在安装手机应用的时候,APP 会询问是否允许授予权限(访问相册、地理位置等权限);你在登录微信小程序时,小程序会询问是否允许授予权限(获取昵称、头像、地区、性别等个人信息)。
- 服务端给用户授权:比如你想追一个很火的剧,但被告知必须是 VIP 才能观看,于是你充钱成为了 VIP,那么服务端便会给你授予观看该剧(访问该资源)的权限。
而实现授权的方式业界通常使用 RBAC,这个 R 有两种解读:第一种是基于角色的访问控制,即 Role-Based Access Control;第二种是基于资源(权限)的访问控制,系统设计时定义好某项操作的权限标识,即 Resource-Based Access Control。
什么是凭证(Credentials)
实现认证和授权的前提是需要一种媒介(证书)来标记访问者的身份。
例如在战国时期,商鞅变法,发明了照身帖。照身帖由官府发放,是一块打磨的光滑细密的竹板,上面刻有持有人的头像和籍贯信息。国人必须持有,如若没有就被认为是黑户,或者间谍之类的。在现实生活中,每个人都会有一张专属的居民身份证,是用于证明持有人身份的一种法定证件。通过身份证,我们可以办理手机卡/银行卡/个人贷款/交通出行等等,这就是认证的凭证。
在互联网应用中,一般网站会有两种模式,游客模式和登录模式。游客模式下,可以正常浏览网站上面的文章,一旦想要点赞/收藏/分享文章,就需要登录或者注册账号。
当用户登录成功后,服务器会给该用户使用的浏览器颁发一个令牌(token),这个令牌用来表明你的身份。每次浏览器发送请求时会带上这个令牌,就可以使用游客模式下无法使用的功能。
什么是 Cookie 和 Session
HTTP 是无状态的协议,对于事务处理没有记忆能力,客户端和服务端完成会话时,服务端不会保存任何会话信息。也就是说每个请求都是完全独立的,服务端无法确认当前访问者的身份信息,无法分辨上一次请求的发送者和这一次的发送者是不是同一个人。
所以服务器与浏览器为了进行会话跟踪(知道是谁在访问),就必须主动地去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器,而这个状态就需要 Cookie 和 Session 共同去实现。
那 Cookie 是什么呢?Cookie 本质上就是服务器发送给用户浏览器的一小块数据,一旦用户认证成功,那么浏览器就会收到服务器发送的 Cookie,然后将其并保存在本地。当浏览器下次再向同一服务器发起请求时,会携带该 Cookie(放在请求头中),并发送给服务器。服务器拿到 Cookie 后,会检测 Cookie 是否过期,如果没有过期再通过 Cookie 判断用户的信息。
注意:Cookie 是不可跨域的,每个 Cookie 都会绑定单一的域名,无法在别的域名下获取使用。一级域名和二级域名之间是允许共享使用的(靠的是 domain)。
以上就是 Cookie,但是问题来了,我们能把一些敏感信息存在 Cookie 里面吗?把用户名、密码都放在 Cookie 里面,然后浏览器发请求的时候再读取 Cookie 里面的内容,验证用户的合法性,可以这么做吗?显然是不能的,因为这样太不安全了,所以就有了 Session。
注意:Session 它并不是真实存在的,而是我们引入的一个抽象概念,它的实现依赖于 Cookie。因为我们不能把敏感信息放在 Cookie 里面,所以最好的方式就是直接放在服务端,用户的这些敏感信息可以看成是一个 Session,我们可以用一个映射保存起来。然后为每一个 Session 都创建一个与之关联的 SessionID,举个例子:
{ "不重复的 sessionID-1": {"user_name": "xx1", "password": "yy1", "phone": 135...}, "不重复的 sessionID-2": {"user_name": "xx2", "password": "yy2", "phone": 136...}, ... }
使用映射保存是最方便的,这些敏感信息我们存在服务端,然后将 SessionID 返回即可,那么这个 SessionID 放在哪里呢?没错,放在 Cookie 中,所以 Session 的实现要依赖于 Cookie。我们以用户登录来模拟一下整个过程:
- 用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session;
- 请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器;
- 浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名;
- 当用户第二次访问服务器的时候,浏览器会自动判断此域名下是否存在 Cookie 信息,如果存在,会将 Cookie 信息也发送给服务端;服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作;
根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来「验证用户登录状态」。
Session 的痛点
通过 Cookie + Session 的方式虽然能实现认证, 但是存在一个问题,上述情况能正常工作是因为我们假设 Server 是单机的。但实际在生产上,为了保障高可用,一般服务器至少需要两台机器,通过负载均衡的方式来决定请求到底该打到哪台机器上。
假设第一次的登录请求打到了 A 机器,A 机器生成了 Session 并在 Cookie 里添加 SessionId 返回给了浏览器。
那么问题来了:下次如果请求(比如添加购物车)打到了 B 或者 C,由于 Session 是在 A 机器生成的,此时的 B、C 是找不到 Session 的,因此就会发生无法添加购物车的错误,就得重新登录了,此时该怎么办呢?解决方式主要有以下三种:
Session 复制
A 生成 Session 后复制到 B、C,这样每台机器都有一份 Session,无论添加购物车的请求打到哪台机器,由于 Session 都能找到,所以不会有问题。
这种方式虽然可行,但缺点也很明显:
- 同样的一份 Session 保存了多份,数据冗余;
- 如果节点少还好,但如果节点多的话,特别是像阿里,微信这种由于 DAU 上亿,可能需要部署成千上万台机器,这样由于节点增多导致复制造成的性能消耗也会很大;
Session 粘连
这种方式是让每个客户端请求只打到固定的一台机器上,比如浏览器登录请求打到 A 机器后,后续所有的添加购物车的请求也都打到 A 机器上。Nginx 的 sticky 模块可以支持这种方式,支持按 ip 或 cookie 粘连等等,比如按 ip 粘连:
这样的话每个 client 请求到达 Nginx 后,只要它的 ip 不变,根据 ip hash 算出来的值就会打到固定的机器上,也就不存在 Session 找不到的问题了。
当然这种方式的缺点也是很明显,对应的机器挂了怎么办?此外,这种方式还会造成一个问题,那就是 Nginx 会变得有状态。
Session 共享
这种方式也是目前各大公司普遍采用的方案,将 Session 保存在 Redis,Memcached 等中间件中,请求到来时,各个机器去这些中间件取一下 Session 即可。
缺点其实也不难发现,就是每个请求都要去 Redis 取一下 Session,多了一次内部连接,消耗了一点性能。另外为了保证 Redis 的高可用,必须做集群。当然了,对于大部分公司来说,Redis 集群基本都会部署,所以这方案可以说是首选了。
什么是 Token(令牌)
通过上文分析,我们知道通过在服务端共享 Session 的方式可以完成用户的身份定位,但是背后也有一个小小的瑕疵:搞个校验机制我还得搭个 Redis 集群?
大厂确实 Redis 用得比较普遍,但对于小厂来说可能它的业务量还未达到用 Redis 的程度,所以有没有其它不用 Server 存储 Session 的用户身份校验机制呢,答案是使用 Token。
基于 Token 的鉴权机制类似于 http 协议,也是无状态的,它不需要在服务端保留用户的认证信息或者会话信息。这就意味着基于 Token 认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。
- 用户使用用户名密码来请求服务器;
- 服务器验证用户的信息;
- 认证通过后,发送给用户一个 Token;
- 客户端存储 Token,并在后续的请求中附上这个 Token 值;
- 服务端验证 Token 值,并返回数据;
这个 Token 必须要在每次请求时传递给服务端,它应该保存在请求头里。 如果保存在 Cookie 里面,服务端还要支持 CORS(跨来源资源共享)策略。
而基于 Token 认证一般采用 JWT,它由三段信息构成,将这三段信息用 . 连接起来就构成了 JWT 字符串。就像如下这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
第一部分我们称之为头部(Header)、第二部分我们称之为载荷(Payload)、第三部分我们称之为签名(Signature),下面来分别介绍。
Header
JWT 的头部承载两部分信息:
- 声明类型,这里是 JWT;
- 声明加密的算法 通常直接使用 HMAC SHA256
{ 'typ': 'JWT', 'alg': 'HS256' }
然后将头部进行 base64 编码(可以对称解码),构成了第一部分。
import base64 import orjson header = { 'typ': 'JWT', 'alg': 'HS256' } print( base64.urlsafe_b64encode(orjson.dumps(header)).decode() ) # eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
Payload
载荷就是存放有效信息的地方,说人话就是用户的一些信息,里面可以放任意内容。我们同样采用 base64 编码,就得到了 JWT 的第二部分。注意:不要放一些敏感内容,因为 base64 是可以对称解码的。
import base64 import orjson payload = { 'name': '最上川', 'phone': '12345678901', 'user_id': '001', 'admin': False, } print( base64.urlsafe_b64encode(orjson.dumps(payload)).decode() ) # eyJuYW1lIjoi5pyA5LiK5bedIiwicGhvbmUiOiIxMjM0NT......
除了我们添加的一些声明之外,JWT 还提供了一些标准声明(建议但不强制使用):
- iss:JWT 签发者;
- sub:JWT 所面向的用户;
- aud:接收 JWT 的一方;
- exp:JWT 的过期时间,这个过期时间必须要大于签发时间;
- nbf:定义在什么时间之前,该 JWT 都是不可用的;
- iat:JWT 的签发时间;
- jti:JWT的唯一身份标识,主要用来作为一次性 Token,从而回避重放攻击;
Signature
JWT 的第三部分是一个签名信息,这个签名信息由三部分组成:
- base64 之后的 Header;
- base64 之后的 Payload;
- secret;
首先将 base64 编码后的 Header 和 base64 编码后的 Payload 使用 . 连接起来,然后通过 Header 中声明的加密方式进行加盐,再进行 base64 编码,然后就构成了 JWT 的第三部分。我们举个例子:
import base64 import hmac import orjson header = { 'typ': 'JWT', 'alg': 'HS256' } payload = { 'name': '最上川', 'phone': '12345678901', 'user_id': '001', 'admin': False, } # 得到 JWT 的第一部分,和 JWT 的第二部分 jwt_one = base64.urlsafe_b64encode( orjson.dumps(header)) jwt_two = base64.urlsafe_b64encode( orjson.dumps(payload)) # 计算 JWT 第三部分 # 指定一个密钥,这里就叫 b"secret" jwt_three = base64.urlsafe_b64encode( hmac.new(b"secret", jwt_one + b"." + jwt_two, "SHA256").digest() ).decode() print(jwt_three) # 4oRUd9Diyp_C0W_LD1of0MxzIuRXvfSroUR_VhP-dSQ=
再将这三部分用 . 连接起来,得到一个字符串,即可构成最终的 JWT。当用户登录成功之后,服务端会生成 JWT 然后交给浏览器保存。
后续当 Server 收到浏览器传过来的 Token 时,它会首先取出 Token 中的 Header + Payload,根据密钥生成签名,然后再与 Token 中的签名比对,如果成功则说明签名是合法的,即 Token 是合法的。然后根据 Payload 中保存的信息,我们便可得知该用户是谁,避免了像 Session 那样要从 Redis 获取的开销。
你会发现这种方式确实很妙,只要 Server 保证密钥不泄露,那么生成的 Token 就是安全的。因为伪造 Token 的话在签名验证环节是无法通过的,可以判定出 Token 的合法性。
注意:secret(密钥)是保存在服务器端的,JWT 的生成也是在服务器端的,Secret 就是用来进行 JWT 的签发和 JWT 的验证。所以,它就是你服务端的私钥,在任何场景都不应该流露出去。否则一旦客户端得知这个 Secret,那就意味着客户端可以自我签发 JWT 了。
通过这种方式就有效地避免了 Token 必须保存在 Server 的弊端,实现了分布式存储。不过需要注意的是,Token 一旦由 Server 生成,直到过期之前它都是有效的,并且无法让 Token 失效,除非在 Server 端为 Token 设立一个黑名单,在校验 Token 前先过一遍此黑名单,如果在黑名单里则此 Token 失效。
但一旦这样做的话,那就意味着黑名单必须保存在 Server,这又回到了 Session 的模式,那直接用 Session 不香吗。所以一般的做法是当客户端登出,或者 Token 过期时,直接在本地移除 Token,下次登录再重新生成 Token。而判断 Token 是否过期,可以在生成 JWT 的时候,在里面加上时间信息,拿到 Token 时再进行比对。
另外需要注意的是 Token 一般是放在 Header 的 Authorization 自定义头里,不是放在 Cookie 里,这主要是为了解决跨域不能共享 Cookie 的问题。
# 一般会加上一个 Bearer 标注 Authorization: Bearer <Token>
总结:Token 解决什么问题(为什么要用 Token)?
1)完全由应用管理,可以避开同源策略;
2)支持跨域访问,Cookie 不支持。Cookie 跨站是不能共享的,这样的话如果你要实现多应用(多系统)的单点登录(SSO),使用 Cookie 来做的话就很困难了。但如果用 Token 来实现 SSO 会非常简单,只要在 header 中的 Authorize 字段(或其他自定义)加上 Token 即可完成所有跨域站点的认证;
3)token是无状态的,可以在多个服务器间共享;
4)Token 可以避免 CSRF 攻击(跨站请求攻击);
5)易于扩展,在移动端的原生请求是没有 Cookie 之说的,而 Sessionid 依赖于 Cookie,所以 SessionID 就不能用 Cookie 来传了。如果用 Token 的话,由于它是随着 header 的 Authorization 传过来的,也就不存在此问题,换句话说 Token 天生支持移动平台,可扩展性好;
扩展阅读
下面再来补充一些额外内容。
什么是 CSRF
攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过(Cookie 里面带有 SessionId 等身份认证的信息),所以被访问的网站会认为是真正的用户而去执行操作。
如果正常的用户误点了恶意链接,由于相同域名的请求会自动带上 Cookie,而 Cookie 里带有正常登录用户的 Sessionid,类似上面这样的转账操作在 Server 就会成功,会造成极大的安全风险。
就连 Google,都因为 CSRF 而吃过严重的亏。
CSRF 攻击的根本原因在于对于同样域名的每个请求来说,它的 Cookie 都会被自动带上,这个是浏览器的机制决定的。至于完成一次 CSRF 攻击需要两个步骤:
- 1. 首先登录了一个正常的网站 A,并且在本地生成了 Cookie;
- 2. 在 Cookie 有效时间内,访问了危险网站 B(就获取了身份信息);
如果访问完正常网站之后,关闭浏览器呢?即使关闭浏览器,Cookie 也不保证一定立即失效,而且关闭浏览器并不能结束会话,Session 的生命周期跟这些都没关系。
什么是同源策略
如果两个 URL 的协议、域名和端口号都相同,那么就是同源的;而有一个不同,那么就是非同源的。不同源的客户端脚本在没有明确授权的情况下,不准读写对方的资源。
同源策略是由 Netscape 提出的著名安全策略,是浏览器最核心、基本的安全功能,它限制了一个源中的脚本与来自其它源中的资源之间的交互方式。
什么是跨域?如何解决
当浏览器执行 JS 脚本时会检查当前网页的接口和请求的接口是否同源,只有同源的脚本才会执行,如果不同源即为跨域。它是由「浏览器的同源策略」造成的,是浏览器对 JavaScript 实施的安全限制。
那么该如何解决呢?
1)Nginx (静态服务器)反向代理解决跨域(前端常用),客户端访问代理服务器,代理服务器再去目标服务器拿数据,然后返回给客户端。由于服务器之间不存在跨域问题,所以浏览器是允许的。
2)jsonp:通常为了减轻 web 服务器的负载,我们把 js、css、图片等静态资源分离到另一台独立域名的服务器上,在 html 页面中再通过相应的标签从不同域名下加载静态资源,而被浏览器允许。
3)服务端在返回的时候添加几个特殊的响应头,浏览器在看到这几个响应头的时候,知道服务端允许跨域,那么会允许客户端的这次请求。而响应头如下:
# 允许的源地址 Access-Control-Allow-Origin: * # 允许的请求头 Access-Control-Allow-Headers: * # 允许的请求方法 Access-Control-Allow-Methods: *
为什么 Token 易于扩展
比如有使用了「负载均衡」的多台服务器,第一次登录请求转发到了 A,然后 A 中的 Seesion 缓存了用户的登录信息。但之后的操作转发到了 B,这时候就丢失了登录状态,会再次让用户重新登陆。当然可以通过共享 Session 的方式,但 Token 只需要所有的服务器使用相同的解密手段即可。
无状态
服务端不保存客户端请求者的任何信息,客户端每次请求必须自备描述信息,通过这些信息来识别客户端身份。服务端只需要确认该 Token是否是自己亲自签发即可,签发和验证都在服务端进行。
什么是单点登录
所谓单点登录,是指在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
Token 的缺点
那有人就问了,既然 Token 这么好,那为什么各个大公司几乎都采用共享 Session 的方式呢。原因是 Token 有以下两点劣势:
1)Token 太长了:Token 是 Header, Payload, Signature 编码后的样式,所以一般要比 SessionId 长很多。如果你在 Token 中存储的信息越多,那么 Token 本身也会越长,这样的话由于你每次请求都会带上 Token,那么对请求来说是个不小的负担;
2)不太安全:网上很多文章说 Token 更安全,其实不然。细心的你可能发现了,我们说 Token 是存储在浏览器的,再细问,存在浏览器的哪里呢?首先不能放在 Cookie 里,那就只好放在 local storage 里,这样会造成安全隐患。因为 local storage 这类的本地存储是可以被 JS 直接读取的,另外上面也提到,Token 一旦生成无法让其失效,必须等到过期才行。这样的话即便服务端检测到了一个安全威胁,也无法使相关的 Token 失效。
所以 Token 更适合一次性的命令认证,设置一个比较短的有效期。
其实不管是 Cookie 还是 Token,从存储角度来看其实都不安全(实际上防护 CSRF 攻击的正确方式是用 CSRF Token),都有暴露的风险。我们所说的安全更多的是强调传输中的安全,可以用 HTTPS 协议来传输, 这样的话请求头都能被加密,也就保证了传输中的安全。
当然我们把 Cookie 和 Token 放在一起比较本身就不合理,一个是存储方式,一个是验证方式,正确的比较应该是 Session 和 Token。
Token 和 Session 的区别
Token 和 Session 其实都是为了身份验证,Session 一般翻译为会话,而 Token 更多的时候是翻译为令牌;Session 和 Token 都是有过期时间一说,都需要去管理过期时间。
1)Session 是一种「记录服务器和客户端会话状态的机制」,使服务端有状态化,可以保存会话信息(一般保存在缓存中)。而 Token 是「令牌」,是访问资源接口(API)时所需要的资源凭证,Token 使服务端无状态化,不会存储会话信息;
2)其实 Token 与 Session 的区别就是一个时间与空间的博弈问题,Session 是空间换时间,而 Token 是时间换空间,两者的选择要看具体情况而定。
虽然确实都是「客户端记录,每次访问携带」,但 Token 很容易设计为自包含的,也就是说,后端不需要记录什么东西,每次一个无状态请求,每次解密验证,每次当场得出合法/非法的结论。这一切判断依据,除了固化在 CS 两端的一些逻辑之外,整个信息是自包含的,这才是真正的无状态。
而 Session 需要依赖一段随机字符串作为 SessionID,每次请求都要去缓存中检索 ID 的有效性。但这存在风险,如果服务器重启导致内存里的 Session 没了怎么办?万一 Redis 服务器挂了怎么办?所以此时会使得服务端变得有状态。
3)所谓 Session 认证只是简单的把 User 信息存储到 Session 里,因为 SessionID 的不可预测性,暂且认为是安全的。
而 Token ,如果指的是 OAuth Token 或类似的机制的话,提供的是认证和授权 。认证是针对用户,授权是针对 App,其目的是让某 App 有权利访问某用户的信息。这里的 Token 是唯一的,不可以转移到其它 App上,也不可以转到其它用户上。
Session 只提供一种简单的认证,只要有此 SessionID ,即认为有此 User 的全部权利,因此需要严格保密,这个数据应该只保存在站方,不应该共享给其它网站或者第三方 App。
所以简单来说:如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token ;如果只是自己的网站,自己的 App,用什么就无所谓了。
常见的前后端鉴权方式有哪些
- Session-Cookie;
- Token 验证(包括 JWT,SSO);
- OAuth2.0(开放授权);
本文深度参考自