前言
随着Web应用程序的出现,直接在客户端上存储用户信息的需求也随之出现。者背后的想象时合法的:与特定用户相关的信息都应该保存在用户的机器上。无论是登录信息、个人偏好、还是其他数据,Web应用程序提供者都需要有办法 将他们保存在客户端。对于这个问题,第一个解决方案就是cookie。
今天cookie只是在客户端存储数据的一个选项。
1. Cookie
1.1 什么是cookie?
HTTP cookie也叫cookie,最初用于在客户端存储会话信息。这个规范要求:
- 服务器通过发送Set-Cookie HTTP头部包含会话信息(服务器创建),例如下面是包含头部的一个HTTP响应:
HTTP/1.1 200 OK Content-type: text/html Set-Cookie: name=value //HTTP响应会设置一个名为“name”,值为“value”的cookie Other-header: other-header-value
- 浏览器会保存这些会话信息(浏览器保存),并在之后每个请求中都会通过HTTP头部再将cookie发给服务器,比如:
这些发送给服务器的额外信息,可用于唯一标识发送请求的客户端。GET /index.jsl HTTP/1.1 Cookie: name=value Other-header: other-header-value
1.2 cookie特点?
-
cookie是与特定域绑定的。设置cookie后,他会与请求一起发送到创建他的域。这个限制能保证cookie中存储的信息只对被认可的接收者开放,不能被其他域访问。
-
cookie存储在客户端上,所以保证了它不会被恶意利用,浏览器会施加限制。
-
它不会占用太多磁盘空间,遵循以下限制,在任何浏览器中都不会碰到问题:
- 磁盘中不超过300个cookie;
- 每个cookie不超过4096字节;
- 每个域不超过20个cookie;
- 每个域不超过81920字节。
-
每个域的cookie个数是受限的,但是不同浏览器的限制不同:
- 最新版IE和Edge限制每个域不超过50个cookie;
- 最新版Firefox限制每个域不超过150个cookie;
- 最新版Opera限制每个域不超过180个cookie;
- Safair和Chrome对每个域的cookie数量没有硬性限制。
如果cookie总数超过了单个域的上线,浏览器就会删除之前的cookie。不同浏览器方案不同,IE和Opera删除最近最少使用的;Firefox随机删除之前的cookie。
磁盘中的cookie超过了最大限制,该cookie会被静默删除(自动删除,不会发起提示)。
1.3 cookie的构成
- 名称: 当前cookie的唯一标识,不区分大小写。
- 值: 存储在当前cookie里的字符串值。
- 域: cookie的有效域。发送到这个域的所有请求都应该包含对应的cookie,也可能包含子域。
- 路径: 请求URL中包含这个路径才会把cookie发送到服务器。
- 过期时间: 表示何时删除cookie。默认在浏览器会话结束后删除所有cookie。这个值是GMT格式(Wdy,DD-Mon-YYYY HH:MM:SS GMT),用于指定删除cookie的具体时间,这样,即使关闭浏览器cookie也不会被删除,直到过期。
- 安全标志secure: 设置之后,只在使用SSL安全连接的情况下才会把cookie发送到服务器。只需要添加secure就行,没有值。
这些参数在Set-Cookie头部中使用 分号+空格 分隔开,比如:
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value; expires=Mon,22-Jan-07 07:10:24 GMT; domain=.worx.com; path=/; secure
Other-header: other-header-value
1.4 子cookie
为了绕过浏览器对每个域cookie数的限制,有些开发者提出了子cookie的概念。
子cookie是在单个cookie存储的最小块数组,也就是cookie由很多子cookie组成。
本质上是使用cookie的值中存储多个子cookie,最常用的格式:
name=name1=value1&name2=vlue2&name3=value3...
1.5 怎么验证cookie的正确性?
Cookie正确性的验证一般有两种方式:
- 一种是使用签名验证,即服务器端在生成Cookie时,会将Cookie和私钥一起使用数字签名的方法生成签名,并将签名和Cookie一起发送给客户端,客户端收到签名和Cookie后,将签名和Cookie一起发送给服务器,服务器收到签名和Cookie后,会使用公钥对Cookie和签名进行验证,如果验证通过,则表明Cookie是有效的;
- 另一种是使用IP地址验证,即服务器端会对客户端发送的Cookie进行IP地址验证,如果IP地址匹配,则表明Cookie是有效的。
1.6 cookie使用注意事项
- 因为所有cookie都会作为请求头部发送给服务器,所以cookie太大可能会影响客户端对特定域的请求性能。保存的cookie越大,请求完成的时间就越长。对于大量的数据cookie并非最佳存储方式,于是出现了Web Storage。
- 不要在cookie中存储重要或敏感的信息。cookie不是保存在安全的环境中的,因此所有人都能获得。应该避免把重要信息保存在cookie中。
2. Web Storage
Web Storage的目的就是为了解决通过客户端存储不需要频繁发送回服务器的数据时使用cookie的问题。
Web Storage第2版定义了两个对象:localStorage(永久储存机制)和sessionStorage(会话储存机制)。这两种浏览器存储API提供了在浏览器中不受页面刷新而影响存储数据的两种方式。
2.1 Storage类
Storage类用于保存键值对数据,直至存储空间上限(由浏览器决定)。Storage的实例与其他对象一样,但增加了以下方法:
- clear():删除所有值
- getIteam(name):取得给定name的值
- key(index):取得给定位置的名称
- removeIteam(name):删除给定name的键值对
- setIteam(name, value):设置给定name的值
注意: Storage只能存储字符串。非字符串会在存储之前自动转换为字符串。这种转换不能在获取数据时撤销。
2.2 sessionStorage对象
sessionStorage对象只存储会话数据,这意味着数据只会存储到浏览器关闭。存储在sessionStorage中的数据不受页面刷新的影响,可以在浏览器崩溃并重启后恢复。
sessionStorage是Storage类的实例对象,因而可以使用Storage的setIteam()方法或直接给属性赋值给sessionStorage添加数据:
// 使用方法存储数据
sessionStorage.setIteam("name", "value");
// 使用属性存储数据
sessionStorage.book = "value";
同样可以使用Storage的getIteam()方法或属性名来获取数据:
// 使用方法获取数据
let name = sessionStorage.getIteam("name);
// 使用属性名获取数据
let book = sessionStorage.book;
删除数据:
// 使用delete删除
delete sessionStorage.name;
// 使用方法删除
sessionStorage.removeItem("name")
注意: sessionStorage对象主要用于存储只在会话期间有效的小块数据。如果需要跨会话持久存储的话,可以使用localStorage。
2.3. localStorage对象
在HTML5规范里,localStorage对象取代了globalStorage,作为哭护短持久存储数据的机制。要访问同一个localStorage对象,页面必须来着同一个域(子域不可以)、在相同的端口上使用相同的协议。
因为localStorage是Storage的实例对象,因而localStorage可以使用Storage的方法来操作数据:
// 使用方法存储数据
localStorage.setIteam("name", "value");
// 使用属性存储数据
localStorage.book = "value";
// 使用方法取得数据
let name = localStorage.getIteam("name");
// 使用属性取得数据
let book = localStorage.book;
2.4 sessionStorage和localStorage的区别
两种存储机制的区别在于,存储在localStorage中的数据会保留到通过JavaScript代码删除或者用户手动清除浏览器缓存。localStorage数据不受页面刷新的影响,也不会因为关闭窗口、标签或重启浏览器而丢失,只有不手动删除就会一直持久的保存在浏览器。
3. Session
3.1 广义上的Session技术
HTTP是无状态的,为了能够在HTTP协议上保持住状态,比如用户是否登陆接需要一种方案来把用户的一个个无状态HTTP请求关联起来。这种技术就叫Session。
Session的功能就是个一个个分离的HTTP请求关联起来,只要实现这个功能,基本上本能叫Session的一种实现。
- 在Cookie里放个JSESSIONID,在服务器上保持状态,用户请求来了,根据这个JSESESSIONID去服务器里查状态。这是Tomcat的实现方法。
- 把所有状态都存在Cookie里,服务器给个签名防止伪造,每次请求来了,直接充Cookie里面获取状态,这是JWT的实现方法。
- 在Cookie里放个token,状态不存在中间件里,而是存在Redis里,这也是一种Session实现方法。
把Sessin存储在Web中间件中(比如Tomcat),这种做法正在淘汰,因为这种方案对负载均衡不友好,也不利于快速伸缩。
把Session存在Redis和前端的才是最佳方案,尤其在微服务架构大行其道的情况下。
只要HTTP还是无状态的,只要保存状态的是刚需,Session就不会消失,变化的只是它的实现方式。
3.2 Java中的Session域
- Java通过HttpSession接口来实现Session技术;
- 每个客户端都有一个自己的Session会话;
- Session由服务器创建,保存在服务器中。
- session可以设置超时时间(以秒为单位),超过这个时间,session就会被销毁。
- session的超时指的是,客户端两次请求的最大间隔时常,超过这个时常才算超时。
- 若两次请求之间没有超过这个时常,则会重置计时。
- tomcat服务器中session的默认超时时间为30分钟。在tomcat的web.xml配置文件中配置了。
Session域的创建
session的创建和获取都使用同一个API:request.getSession()
- 第一次调用时,创建Session会话,
- 之后每次调用,获取前面创建的会话
通过isNew()
方法判断是否新创建的会话:
- true:新创建的。
- false:获取之前创建的。
每个会话都有一个唯一ID,可以通过getId()方法获取。
public class SessionServlet extends HttpServlet {
protected void createSession(HttpServletRequest request, HttpServletResponse response) throws Exception{
// 创建和获取Session
HttpSession session = request.getSession();
// 判断当前Session是否最新
boolean isNew = session.isNew();
// 获取SessionID
String id = session.getId();
}
}
Session域数据的操作
public class SessionServlet extends HttpServlet {
protected void getAttribute(HttpServletRequest request, HttpServletResponse response) throws Exception{
// 向session域中保存数据
request.getSession().setAttribute("key","value")
// 获取session域的数据
Object key = resuest.getSession().getAttribute("key")
// 获取session
HttpSession session = request.getSession();
// 设置当前session超时时间
session.setMaxInactiveInterval(35);
// 立刻销毁当前session
session.invalidate();
}
}
3.3 浏览器和Session是如何关联的
session底层是基于cookie技术实现的,因为具有cookie的生命周期特性,浏览器关闭,session也被销毁。
Session保存在浏览器的Cookie中。
3.4 Session正确性如何验证?
Session正确性的验证一般有两种方式:
- 一种是使用时间戳验证:即服务器端在生成Session时,会将Session和一个时间戳一起发送给客户端,客户端收到Session和时间戳后,将Session和时间戳一起发送给服务器,服务器收到Session和时间戳后,会对时间戳进行验证,如果时间戳有效,则表明Session是有效的。
- 另一种是使用签名验证:即服务器端在生成Session时,会将Session和私钥一起使用数字签名的方法生成签名,并将签名和Session一起发送给客户端,客户端收到签名和Session后,将签名和Session一起发送给服务器,服务器收到签名和Session后,会使用公钥对Session和签名进行验证,如果验证通过,则表明Session是有效的。签名是为了防止伪造
4. Token
Token的意思是“令牌”,是一种网络认证方式,是服务端生成的一串字符串,用于识别客户端的身份,不能用来存储客户端信息,可作为客户端进行请求的一个标识。
当用户第一次登录后,服务器生成一个Token并将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。
4.1 Tken的组成
- uid:用户唯一的身份标识
- time:当前时间的时间戳
- sign:签名(防止伪造)
token的前几位以哈希算法压缩成的一定长度的十六进制字符串。为防止token泄露。
4.2 为什么要用Token?
- Token 完全由应用管理,所以它可以避开同源策略(跨域)
- Token 可以避免 CSRF 攻击
- Token 可以是无状态的,可以在多个服务间共享
Token无状态,也就是说,Token不会记录客户端之间的状态,也不会存储客户端相关信息,只会记录用户的身份,以保客户端发送的请求是合法的。
4.3 基于token机制的身份认证
使用token机制的身份验证方法,在服务器端不需要存储用户的登录记录。大概的流程:
- 客户端使用用户名和密码请求登录。
- 服务端收到请求,验证用户名和密码。
- 验证成功后,服务端会生成一个token,然后把这个token发送给客户端。
- 客户端收到token后把它存储起来,可以放在cookie或者Storage里。
- 客户端每次向服务端发送请求的时候都需要带上服务端发给的token。
- 服务端收到请求,然后去验证客户端请求里面带着token,如果验证成功,就向客户端返回请求的数据。(如果这个 Token 在服务端持久化(比如存入数据库),那它就是一个永久的身份令牌。)
4.4 解决Token失效的问题
- 一种方案是在服务器端保存 Token 状态,用户每次操作都会自动刷新(推迟) Token 的过期时间——Session 就是采用这种策略来保持用户登录状态的。然而仍然存在这样一个问题,在前后端分离、单页 App 这些情况下,每秒种可能发起很多次请求,每次都去刷新过期时间会产生非常大的代价。如果 Token 的过期时间被持久化到数据库或文件,代价就更大了。所以通常为了提升效率,减少消耗,会把 Token 的过期时保存在缓存或者内存中。
- 另一种方案,使用 Refresh Token,它可以避免频繁的读写操作。这种方案中,服务端不需要刷新 Token 的过期时间,一旦 Token 过期,就反馈给前端,前端使用 Refresh Token 申请一个全新 Token 继续使用。这种方案中,服务端只需要在客户端请求更新 Token 的时候对 Refresh Token 的有效性进行一次检查,大大减少了更新有效期的操作,也就避免了频繁读写。当然 Refresh Token 也是有有效期的,但是这个有效期就可以长一点了,比如,以天为单位的时间。
上面的时序图中并未提到 Refresh Token 过期怎么办。不过很显然,Refresh Token 既然已经过期,就该要求用户重新登录了。
当然还可以把这个机制设计得更复杂一些,比如,Refresh Token 每次使用的时候,都更新它的过期时间,直到与它的创建时间相比,已经超过了非常长的一段时间(比如三个月),这等于是在相当长一段时间内允许 Refresh Token 自动续期。
到目前为止,Token 都是有状态的,即在服务端需要保存并记录相关属性。那说好的无状态呢,怎么实现?JWT来实现。
5. JWT
5.1 无状态Token
如果我们把所有状态信息都附加在 Token 上,服务器就可以不保存。
但是服务端仍然需要认证 Token 有效。不过只要服务端能确认是自己签发的 Token,而且其信息未被改动过,那就可以认为 Token 有效—— “签名” 可以作此保证。
平时常说的签名都存在一方签发,另一方验证的情况,所以要使用非对称加密算法。
但是在这里,签发和验证都是同一方,所以对称加密算法就能达到要求,而对称算法比非对称算法要快得多(可达数十倍差距)。
更进一步思考,对称加密算法除了加密,还带有还原加密内容的功能,而这一功能在对 Token 签名时并无必要——既然不需要解密,摘要(散列)算法就会更快。可以指定密码的散列算法,自然是 HMAC。
5.2 什么是JWT?
JWT(json web token)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。
JWT真正实现了Token的无状态。
JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。比如用户登录。在传统的用户登录认证中,因为http是无状态的,所以都是采用session方式。用户登录成功,服务端会保存一个session,服务端会返回给客户端一个sessionId,客户端会把sessionId保存在cookie中,每次请求都会携带这个sessionId。
cookie + session这种模式通常是保存在内存中,而且服务从单服务到多服务会面临的session共享问题。虽然目前存在使用Redis进行Session共享的机制,但是随着用户量和访问量的增加,Redis中保存的数据会越来越多,开销就会越来越大,多服务间的耦合性也会越来越大,Redis中的数据也很难进行管理,例如当Redis集群服务器出现Down机的情况下,整个业务系统随之将变为不可用的状态。而JWT不是这样的,只需要服务端生成token,客户端保存这个token,每次请求携带这个token,服务端认证解析就可。
5.3 JWT正确性如何验证?
JWT正确性的验证一般有两种方式:
- 一种是使用公钥验证签名,即客户端收到Token后,会使用公钥对Token中的签名进行验证,如果验证通过,则表明Token是有效的;
- 另一种是使用算法进行验证,即客户端收到Token后,会使用指定的算法对Token中的签名进行验证,如果验证通过,则表明Token是有效的。
5.4 JWT的利弊以及并发处理
1、 使用 JWT 的优势
使用 JSON Web Token 保护应用安全,你至少可以获得以下几个优势:
-
更少的数据库连接:因其基于算法来实现身份认证,在使用 JWT 时查询数据的次数更少(更少的数据连接不等于不连接数据库),可以获得更快的系统响应时间。构建更简单:如果你的应用程序本身是无状态的,那么选择 JWT 可以加快系统构建过程。
-
跨服务调用:你可以构建一个认证中心来处理用户身份认证和发放签名的工作,其他应用服务在后续的用户请求中不需要(理论上)在询问认证中心,可使用自有的公钥对用户签名进行验证。
-
无状态:你不需要向传统的 Web 应用那样将用户状态保存于 Session 中。
2、使用 JWT 的弊端
严重依赖于秘钥:JWT 的生成与解析过程都需要依赖于秘钥(Secret),且都以硬编码的方式存在于系统中(也有放在外部配置文件中的)。如果秘钥不小心泄露,系统的安全性将收到威胁。
服务端无法管理客户端的信息:如果用户身份发生异常(信息泄露,或者被攻击),服务端很难向操作 Session 那样主动将异常用户进行隔离。
服务端无法主动推送消息:服务端由于是无状态的,他将无法使用像 Session 那样的方式推送消息到客户端,例如过期时间将至,服务端无法主动为用户续约,需要客户端向服务端发起续约请求。
冗余的数据开销:一个 JWT 签名的大小要远比一个 Session ID 长很多,如果你对有效载荷(payload)中的数据不做有效控制,其长度会成几何倍数增长,且在每一次请求时都需要负担额外的网络开销。
JSON Web Token 很流行,但是它相比于 Session,OIDC(OpenId Connect)等技术还比较新,支持 JSON Web Token 的库还比较少,而且 JWT 也并非比传统 Session 更安全,他们都没有解决 CSRF 和 XSS 的问题。因此,在决定使用 JWT 前,你需要仔细考虑其利弊。
6. 分离认证服务
当 Token 无状态之后,单点登录就变得容易了。前端拿到一个有效的 Token,它就可以在任何同一体系的服务上认证通过——只要它们使用同样的密钥和算法来认证 Token 的有效性。就样这样:
当然,如果 Token 过期了,前端仍然需要去认证服务更新 Token:
可见,虽然认证和业务分离了,实际即并没产生多大的差异。当然,这是建立在认证服务器信任业务服务器的前提下,因为认证服务器产生 Token 的密钥和业务服务器认证 Token 的密钥和算法相同。换句话说,业务服务器同样可以创建有效的 Token。
如果业务服务器不能被信任,该怎么办?
7. 不受信的业务服务器
遇到不受信的业务服务器时,很容易想到的办法是使用不同的密钥。认证服务器使用密钥1签发,业务服务器使用密钥2验证——这是典型非对称加密签名的应用场景。认证服务器自己使用私钥对 Token 签名,公开公钥。信任这个认证服务器的业务服务器保存公钥,用于验证签名。幸好,JWT 不仅可以使用 HMAC 签名,也可以使用 RSA(一种非对称加密算法)签名。
不过,当业务服务器已经不受信任的时候,多个业务服务器之间使用相同的 Token 对用户来说是不安全的。因为任何一个服务器拿到 Token 都可以仿冒用户去另一个服务器处理业务……悲剧随时可能发生。
为了防止这种情况发生,就需要在认证服务器产生 Token 的时候,把使用该 Token 的业务服务器的信息记录在 Token 中,这样当另一个业务服务器拿到这个 Token 的时候,发现它并不是自己应该验证的 Token,就可以直接拒绝。
现在,认证服务器不信任业务服务器,业务服务器相互也不信任,但前端是信任这些服务器的——如果前端不信任,就不会拿 Token 去请求验证。那么为什么会信任?可能是因为这些是同一家公司或者同一个项目中提供的若干服务构成的服务体系。
但是,前端信任不代表用户信任。如果 Token 不没有携带用户隐私(比如姓名),那么用户不会关心信任问题。但如果 Token 含有用户隐私的时候,用户得关心信任问题了。这时候认证服务就不得不再啰嗦一些,当用户请求 Token 的时候,问上一句,你真的要授权给某某某业务服务吗?而这个“某某某”,用户怎么知道它是不是真的“某某某”呢?用户当然不知道,甚至认证服务也不知道,因为公钥已经公开了,任何一个业务都可以声明自己是“某某某”。
为了得到用户的信任,认证服务就不得不帮助用户来鉴别业务服务。所以,认证服器决定不公开公钥,而是要求业务服务先申请注册并通过审核。只有通过审核的业务服务器才能得到认证服务为它创建的,仅供它使用的公钥。如果该业务服务泄漏公钥带来风险,由该业务服务自行承担。现在认证服务可以清楚的告诉用户,“某某某”服务是什么了。如果用户还是不够信任,认证服务甚至可以问,某某某业务服务需要请求 A、B、C 三项个人数据,其中 A 是必须的,不然它不工作,是否允许授权?如果你授权,我就把你授权的几项数据加密放在 Token 中……
废话了这么多,有没有似曾相识……对了,这类似开放式 API 的认证过程。
看了那么多,我希望下次面试官问我登录页面怎么设计的时候,我可以说一番长篇大论哈哈哈。
8. 总结
角度一:是否有状态
- Cookie、Storage、Session 是有状态的,都用于存储用户信息。
- Token、JWT 是无状态的,用于户身份验证的,不存储用户信息,实际上Token还是有状态的,因为需要在服务器保存一些属性用于验证Token,JWT真正做到了无状态。
角度二:存储位置
- Cookie、Storage是浏览器存储数据方案
- Session是服务器存储数据方案
角度三:创建者
- Cookie、Sessin、Token、JWT都是由服务器生成
角度四:传输方式
- 通过HTTP请求头或请求参数传输
- 通过Cookie传输
- 通过URL传输
- 通过Storage传输