我也来聊聊Token

在技术社区里面,关于Cookie、Session、Token的技术讲解也是一个常见的话题。刚好笔者也自认为在这方面做了很多实践和工作,觉得有必要将自己在这一过程中的一些体会和认知,分享出来,希望能够促进共同提高。

需求和问题

对于一个入门的Web开发者而言,特别是使用各种各样Web框架的,会发现需要使用Cookie或者Session技术来维持一个Web应用的状态,最常见的就是登录后,在不同的页面进行切换时,需要获取或者维持当前用户的信息,然后来提供内容和信息服务。

所以,为什么要这样做?这个原因,我们应该要向Web应用的技术底层来寻找,就是HTTP协议。

我们现在讨论的Web应用和Web应用的开发,其底层技术,都是基于HTTP协议的。HTTP协议的基本工作原理和模式就是客户端/服务器的业务逻辑角色和请求/响应的操作流程。客户端通过网络发起请求,发送请求数据,服务端收到请求后和数据后,根据的请求内容进行响应,向客户端发送响应的数据和内容(借用下图)。

这个模式的最大优势,就是简单直接,操作和实现方便,系统运行高效。因为在Web技术发展的早期,主要的服务模式是提供文件、页面等静态内容的服务,它不需要复杂的业务逻辑和状态控制。但这个方式,显然有几个问题:

  • 被动

要使这个系统工作起来,必须由客户端主动发起请求。有请求才有响应,没有请求就没有响应。

  • 非连续

在初始的HTTP版本中,请求/响应都是"一次性"的。发起请求-获得响应,这个过程的周期就结束了。有其他的操作,需要再次重新发起新的请求。

  • 无状态

无状态就是非连续工作模式的后果。每次请求响应的过程,它们之间没有直接的逻辑关系。这个模式,在早期Web服务主要用于静态的文件和页面的时代,并没有太大的问题。但随后很快发展起来的Web应用,在业务层面上需要维持这个逻辑关系,就是需要有一个"会话"(session)的概念。

我们后面就会看到,针对上述的问题,在技术上是如何进行处理的。

基本解决方案

跟几乎所有的信息技术发展的过程一样,HTTP和Web技术,也是在应用过程中逐渐发展起来的。在前景并不是很明确的情况下,人们通常的处理方式就是先用起来,遇到问题或者有新的需求的时候,再想解决方案和更好的处理方式。这好像是一种客观的技术发展规律。

Web技术也是如此。人们需要Web应用提供更丰富和强大的功能,客观上需要Web应用能够提供一个逻辑上"会话"的机制。而人们就不想完全推翻HTTP的运行机制,重新开发一个新协议,因为那样可能技术和商业上的代价太高。就想到可以在基础的HTTP协议上稍微改进一下,能够提供逻辑上的状态和会话保持的效果。

处理和实现的思路很简单,就是在请求的时候,加入一些数据,来标识这个会话的状态。比如在登录成功之后,在后续请求的信息中加入登录的用户名,服务器就可以知道使用这个信息进行相关的处理了。

这个加入的信息,可是是显示的,或者是隐式的。显示的处理就是直接在请求参数中加入这个信息,比如在URL地址中,加入一个用户参数;隐式的方式,就是可以利用HTTP协议的Cookie机制,告知浏览器在请求时,自动在请求头中加入会话状态信息。显示的处理方式,需要直接在业务应用层面来修改程序,如修改所有后续的链接或者表单地址,显然不是一个可行和优雅的方式。所以,基于和具体业务解耦的考虑,当然是优先考虑隐式的处理方式。而且,这样看起来也比较安全,当然,实际上这个安全性也只是看起来而已,原理上并没有什么差异。

在了解了这个基本原理和实现机制之后,我们就可以进一步辨析和明确Cookie、Session和Token这几个概念和技术实现方式了。

首先是Cookie。首先需要先从HTTP的规范和原理上来明确一下。Cookie的原意是"曲奇饼"或者"小甜饼",在HTTP协议中,就是一小片"附加信息"。Cookie实际上是一个HTTP协议规范。它的基本机制是,服务器在进行响应的时候,使用Set-Cookie指令(在响应头中设置的文本信息),告知客户端(浏览器),在后续的请求的时候,需要自动的在请求头中,加入一些指定的内容。下面是它在HTTP请求响应中应用的一些示例:

http 复制代码
// 服务端响应
HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

// 客户端请求,自动加入头信息
GET /sample_page.html HTTP/2.0
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

// 标准语法和选项
Set-Cookie: <cookie-name>=<cookie-value>
Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>
Set-Cookie: <cookie-name>=<cookie-value>; Expires=<date>
Set-Cookie: <cookie-name>=<cookie-value>; HttpOnly
Set-Cookie: <cookie-name>=<cookie-value>; Max-Age=<number>
Set-Cookie: <cookie-name>=<cookie-value>; Partitioned
Set-Cookie: <cookie-name>=<cookie-value>; Path=<path-value>
Set-Cookie: <cookie-name>=<cookie-value>; Secure

Set-Cookie: <cookie-name>=<cookie-value>; SameSite=Strict
Set-Cookie: <cookie-name>=<cookie-value>; SameSite=Lax
Set-Cookie: <cookie-name>=<cookie-value>; SameSite=None; Secure

// Multiple attributes are also possible, for example:
Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly

可以看到,Set-Cookie有一定的格式规范,通常就是一个键值对,并且可以附加一些选项。而键值对的内容,可以由业务来确定,本身没有太多的限制。相关的选项一般都是和安全设置相关,如包括指定作用域范围,安全策略,过期时间等等,都有相应的语法规范。

随后,在后续的网络请求中,客户端应该按照规范,在发起HTTP请求的时候,自动的在请求头中,使用Cookie指令,加入前面设置的内容。服务端在接收请求后,可以从请求头中分离出前面设置的信息,来进行后续的处理。这样,就利用Cookie机制,实现了无状态的请求之间的逻辑关联。

所以,这个完整的Cookie机制其实是由一个工作流程和生命周期的。如下图所示:

这样,利用这个机制,我们就可以在用户认证成功之后,使用类似于"Set-Cookie: user=john"这样的指令,告诉客户端,在后续的请求中,自动在请求头中,添加Cookie指令并附带用户信息。服务端在接收这个请求之后,就可以从cookie内容中,获得当前请求的用户信息,从而提高相关的应用服务。

请求会话连续性的问题好像是解决了,但好像又带来了一些新的问题。主要有两个。

第一个问题是安全的问题。因为整个HTTP请求的规范和信息结构都是开放的,攻击者就很容易的模拟和构造一个类似但是非法的请求,来和一个正常的用户一样进行操作。从信息安全的角度来看,这是非常严重的。这个我们在后面还会深入探讨。

第二个问题,相对来说不是那么重要,就是效率的问题。HTTP的规范规定,当Set-Cookie之后,默认情况下,在同一个域中,后续发起的所有的请求,都会添加Cookie信息。但实际上,在很多情况下这是不必要的。例如一般的HMTL页面除了页面本身的内容之外,还有很多相关的图片、样式表文件、JS程序文件等等,都需要请求和下载,这些内容其实很多和业务没有直接关系,不需要考虑授权和控制,那么这些添加的Cookie信息就是无效内容,徒增请求的流量,并且平白占用服务器处理资源。积少成多,这个影响还是相当可观了,应该进行优化。

所以,针对第一个安全问题,人们就提出了Session这样一个解决(其实是优化)方案。

Session

Session的原意就是会话的意思。

HTTP协议中,其实并没有关于Session的内容,所以它其实是一个约定俗成的概念和技术。一般我们认为Cookie是一种客户端管理会话的机制,因为相关的认证信息是存在于Cookie当中的,由客户端就是浏览器保持和处理;而Session相对而言就是一种服务器管理的会话机制。

它的具体实现方式基本如下。服务器需要先建立一个会话信息表,这基本上是一个键-值结构,来保存当前有效的会话信息。在客户端用户认证成功之后,服务器并不是直接将认证结果如用户账号信息设置到Cookie中,而是将账号信息加入会话信息表,并使用一个随机ID(SessionID,会话ID)作为键对它进行标识;然后,服务端才将这个ID设置到Cookie中;后续,客户端在请求时,就使用这个ID发起请求;服务端会检查这个ID是否存在,并从会话信息表中,真正获取当前会话所对应的用户信息作为依据,来提供业务服务。

简单的原理大致如此。由于这个ID是随机动态并且和用户信息没有明显关联(在服务端才进行关联),所以为攻击者模仿和伪造带来了一些困难,这样就显然提供了比Cookie机制更好的安全性。而且由于整个处理过程是由服务端来控制的,所以在其中可以进一步增加一些安全机制,来提升安全性。比如在会话信息中增加认证时请求的IP地址,增加会话信息过期时间,给ID进行签名确认是由服务端颁发的,等等。而且这些机制可以进行扩展,并且对于客户端和应用而言是解耦和透明的。

下图就是Java默认的Session Manage机制生成的SessionID的样例。

Session机制基本上也是可以基于Cookie技术的,上一章节中Cookie请求过程图中,已经表明,是如何通过cookie来管理session-id的。同样,它也没有避免无效请求头信息的问题。但是,通常SessionID信息比较简单,Cookie内容也就比较小,相对对于传输的影响比较小。

除此之外,Session的问题主要有三点:

  • 性能的问题

SessionID本身虽然比较小,但它关联的信息和状态可能却比较大。特别是对于用户规模和访问量都比较大的系统,对于单一的服务器,存储和处理大规模的用户信息,可能会造成很大的性能影响。

  • 安全性有限

Session的安全性比较好,只是相对于Cookie而言,因为它使用了一个动态随机的信息,不好伪造。但一旦这个ID在有效期内泄露,就会带来一定的风险。因为没有其他的机制来验证这个ID是否是被冒用的。

  • 多服务器会话共享

大型Web应用系统一般都是可扩展的架构,通过提供多台应用服务器来扩展进行整个系统的处理能力。这个架构一般是前面有一个负载均衡装置,它可以将网络请求导向给后面的一系列运行同一程序的应用服务器中的某一个,通过负载均衡策略和算法,可以将负载比较平均的分配到实际的应用服务器上,这样的一组服务器就可以提供比单一系统更强的处理能力。

这时,如果会话信息表是在单一的服务器上处理的话,就会发送后续的请求可能会导向另外一台服务器,无法识别和解析会话信息,从而无法提供正常的应用服务的情况。

所以对于大型系统,Session管理和存储,可能就不能在单一应用程序层面来实现,而是使用如Redis这种系统,来在多个服务器之间,共享一套高性能的会话信息表。在缓解系统开销的同时,还可以解决请求在不同服务器之间迁移的问题。

当然,引入了共享会话信息表,也会引入一些系统复杂性和失效风险。为此,针对无效请求头信息和大规模会话管理的问题,业界进一步提出了Token的技术方案。

Token

基本概念和过程

Token的意思是"令牌", 也就是一个HTTP请求发起时,所提供的一个(一般由服务端颁发的)附加信息,一般就是一个编码字符串,来证明其访问是有权有效的。

虽然从网络协议的技术层面上而言,两者是一样的,但在业务和内容层面,Token和Session的设计和操作思路有一些不同。在这个方面Token反而和Cookie有点类似,就是在Token内部保存认证信息,当然,会增加一些安全机制,来更好的保证操作过程的安全性。

另外 基于对新型的前后端分离的Web应用程序的架构的更好的支持,和通用性的考虑,Token通常不使用HTTP的Set-Cookie命令,而是由应用来决定如何设置(通常也是设置Header内容)。因为Set-Cookie的支持和自动请求头内容的自动设置,通常是浏览器的一个规范,而非所有Web应用客户端的规范。而前后端分离的架构中,后端支持的不仅仅是浏览器,而可能是任何一类的Web客户端如手机APP、其他Web应用等等,就不能有这个限制,而且最好保持一致和通用。

为了更好的理解这一点,我们下面来简单分析一下它的具体实现和应用过程。

当一个正常成功的用户认证完成之后,服务端会生成一个Token,这个Token会携带当前认证的用户信息(如用户标识、角色权限标识等等),和一些附加的安全信息,如过期时间、初始认证的IP地址,以及服务器对这些信息的一个签名。 当然如果对于安全的要求比较高,还可以对这些信息进行加密。然后,在认证请求的响应中,服务器会把这个Token发送给客户端。

在后续的应用过程中,客户端可以按照业务需求,在HTTP请求的时候(通常就是AJAX异步请求服务端数据接口或API),将这个Token设置到特定或约定的Header项目当中(注意并不是标准的Cookie项目)。服务端在这些接口上,有统一的Token处理机制,它可以从请求中分离Token,对Token进行验证如解密、检查签名和时效等等,如果没有问题,就按照授权信息执行业务服务,如果出现问题,就返回认证错误信息。

从上面的阐述我们可以看到,和Session相比,Token内部就包含了认证信息,这样服务器就不需要独立的保存和处理这些信息,而是在使用时,根据规范和标准来进行计算和处理。对于分布式或者多服务器的场景,它们可以使用共享的密钥或者非对称加密机制,来一致的处理这些信息。此外,Token主要应用在前后端分离系统的HTTP API的场合当中,对于传统的Web Page应用的方式,如普通的HTML,CSS、JS和图片文件的请求,可以也不应该不使用Token。

和Session一样,Token的实现其实没没有特别明确的标准和规范,但业界提出了一种JWT的规范,让我们可以更直观的理解上述过程。

JWT

JWT原意是(JSON Web Token,JSON Web令牌)。它是一种用于在网络应用之间传递声明(claims)的开放标准(RFC 7519)。JWT作为一种紧凑的、自包含的方式来安全传输信息,通常被用作身份验证(authentication)和信息交换的方法。JWT的优点在于简洁性、可扩展性、易于传输、可靠性和安全性。它可以被用于各种场景,如用户身份验证、单点登录、安全传输信息等。

JWT的基本规范如下。一个JWT就是一个字符串,由三部分组成,每部分之间用点号(.)分隔,每个部分其实都是一个JSON对象的Base64URL编码,它们包括:

  • Header(头部):包含了令牌的元数据(metadata)和加密算法。
  • Payload(载荷):包含了声明(claims),也就是令牌所携带的信息。
  • Signature(签名):使用私钥对头部和载荷进行签名,用于验证令牌的完整性。

具体示例如下:

js 复制代码
// jwt内容 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

// HTTP请求头示例:
Authorization: Bearer <token>

// 解码后的内容
// 头部:
{
  "alg": "HS256",
  "typ": "JWT"
}

// 载荷
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

JWT的规范比较严谨,如果要考虑到和系统系统进行交互,如需要使用其他系统的API或者需要为其他系统提供服务的场景,应当遵循标准的JWT规范。

但在一般内部使用的时候,JWT的定义稍显繁琐,另外在安全性方面也有一定的限制(如认证信息加密)。可以考虑基于JWT的思想,设计自己的Token应用实现,来简化信息结构和处理,或者提供更好的扩展性和安全性。

自定义Token实现

下面是笔者在自有系统中,提出并且实现的一种Token的实现,读者有兴趣可以参考一下。

HTTP Header项目

首先,基于不影响其他应用或者避免误解的考虑,建议自定义一个HTTP Header Key: 如 X-UL-TOKEN, 作为应用级别的Token处理标识。

由于一致的规范和定义,这个Token不仅可以用在HTTP头信息中,还可以作为URL地址的一部分,而且在安全性方面并没有什么差异。在服务端可以使用同一套逻辑和方式来进行处理。

基本结构

然后,Token的基本结构如下,也是一个三段的结构:

[Prefix].[Content].[Sign]

  • Prefix

Prefix就是前缀信息,包括了Token的基本类型和版本,使用单个大型英文字母进行标识和区分,这个标识代表了当前Token所使用的编码、编码和签名方式或者组合。然后是一个过期时间的编码,并可选一个客户端初始IP地址编码。都是固定长度方便处理。

  • Content

Token的实际内容,就是JWT的载荷。简单情况下,这个内容就是业务信息JSON对象的Base64URL编码。具体内容可以完全由业务需求确定,但需要注意尽量精简,减少传输和处理的代价成本。

  • Sign

签名信息。根据Token的类型不同,可以是服务器自签名自验证的方式,也可以是服务器私钥签名,第三方用服务器公钥验证的方式。签名的内容就是Token的前两端的原始内容,和是否加密无关。

应用方式和处理流程

服务端在收到一个需要认证的HTTP请求之后,需要先从请求头或者URL区段中,分离出这个认证Token,并获得三个区段的内容。

然后基于Token的类型,和前两段的内容,对Token进行签名的验证,确定这个Token的来源没有问题的。

签名验证完成后,可以在前缀中解码过期时间,确认Token的时效性。另外还可选验证客户端IP地址。之所以IP地址验证是可选的,是考虑到现代移动互联网的动态性,一些移动类的Web应用,客户端在认证时的IP地址和使用时的可能会发生变化,所以这个验证信息只能作为参考。

信息验证阶段完成,就可以根据Token类型,解码业务载荷内容了。一般的Token就是一个Base64解码,并转换为一个JSON对象;加密的Token需要进行解密,同样得到一个JSON对象。

解码后JSON对象中,一般就包括相关的用户信息或者授权信息,用于后续的业务服务和操作。

验证阶段中,任何一个验证信息出现问题,就返回相关的错误代码和提示信息。

Token更新

从安全性的角度来看,任何操作和信息都有一个时效性,是一项重要的安全机制和设计原则。所以一般Token都会设计一个过期时间,而且使用签名来进行保护。过期检查不能通过,就认为授权失败,拒绝业务服务。

但这样可能会比较大的影响用户体验。过期后用户需要重新认证,带来了一些不便。为了改善这一点,可以采用Token隐式更新的机制,而不是重新登录来重新获取有效Token。

具体做法可以如下。 如果Token过期,在一个可以容忍的时限内(例如4个Token有效期),不是直接返回认证错误,而是仍然正常提供业务服务。同时,使用旧的Token业务信息,但按照新的过期时间,重新生成一个新的Token,封装到响应的HTTP Header之中。客户端看到这个新的Token项目,就能知晓Token已经更新了,它应当保存这个新的Token,并在后续的业务请求中使用它。

这样的操作,需要稍微改进一下前后端的实现代码。但对于用户而言,完全不会在操作层面上造成影响,改善了应用体验。

小结

本文从Web应用常见的会话保持的需求和HTTP协议无状态的特性之间的结构性冲突的问题出发,探讨了使用Cookie、Session和Token等技术的解决思路和方案。并比较了这些技术方案的原理和差异,以及相关的应用场景。最后提出了一种自定义的Token技术方案,并探讨了相关的改进思路和处理方式。

相关推荐
思忖小下几秒前
梳理你的思路(从OOP到架构设计)_设计模式Template Method模式
设计模式·模板方法模式·eit
向前看-3 小时前
验证码机制
前端·后端
燃先生._.4 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
超爱吃士力架5 小时前
邀请逻辑
java·linux·后端
高山我梦口香糖5 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
black^sugar6 小时前
纯前端实现更新检测
开发语言·前端·javascript
AskHarries7 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
2401_857600957 小时前
SSM 与 Vue 共筑电脑测评系统:精准洞察电脑世界
前端·javascript·vue.js
2401_857600957 小时前
数字时代的医疗挂号变革:SSM+Vue 系统设计与实现之道
前端·javascript·vue.js