JWT详解及Golang实现

JWT详解及Golang实现
强烈推介IDEA2020.2破解激活,IntelliJ IDEA 注册码,2020.2 IDEA 激活码

1.背景

HTTP协议是一种无状态的协议,这意味着用户提供账号和密码进行登录认证后,下次再请求的时候,仍然需要认证,因为服务器并不知道是谁发送的请求,并不知道该用户已经认证过一次。
 
所以为了解决这一问题,保持客户端与服务端的会话状态,在服务器的缓存中需要为每位用户分配一份存储空间,用于存储用户的个人登录信息等,且每份存储空间有个唯一标识ID作为自己的身份证;
 
这样在作响应的时候,将该ID返回给浏览器,浏览器存储到本地,以便后续再次请求时都可以携带着这个ID,服务器就能根据这个ID去对应缓存空间中去查找是否存在对应的存储区,能找到则表示该用户之前已经访问过了,存储区存储的登录信息等也可以直接使用,就不用再次登录了。

这就是传统的基于session+cookie的会话保持技术。session是存在于服务端的一个缓存区,相当于一个存储数据的map数据结构,每个session对应着自己的sessionId;而cookie则是存在于客户端浏览器的一种数据存储区。

上述描述的流程实际就是浏览器在第一次访问服务器时分配session空间和生成sessionID,并将sessionID存储在响应头的Set-Cookie属性中;浏览器会专门处理Cookie数据并进行存储,然后在下次发起请求的时候,会在请求头的Cookie字段中携带上之前存储的Cookie数据,也就是sessionID;服务器在收到请求后先在请求头的Cookie中拿到sessionID,这样后续就能找到和操作专属于这个用户的数据存储区了。

所以常见的登录认证流程就是用户登录后将用户基本信息放到session存储区中,下次再次请求的时候用户就不需要再次进行登录了,服务器会直接从seesion中拿到你的id等数据,然后根据具体业务逻辑到数据库中查询和处理数据等。

但是这种基于Session的会话保持技术存在很多弊端,如:

  • 随着用户增加服务器的开销也会显然增大;
  • 在处理分布式应用的情境下会相应的限制负载均衡器的能力;
  • Cookie存储在客户端,如果被拦截窃取,会很容易受到CSRF跨域伪造请求攻击;
  • 除浏览器之外的其他设备对Cookie的支持并不友好,对跨平台支持不好;

2.概述

JWT的出现就是为了解决传统Session+Cookie技术存在的各种问题,实际上随着前后端分离的发展,以及数据中心的建立,越来越多的公司会创建一个中心服务器,同时服务于各种产品线,如:统一身份认证平台;这些产品线上的产品,它们可能有着各种终端设备,包括但不仅限于浏览器、桌面应用、移动端应用、平板应用、甚至智能家居。
在这里插入图片描述
JWT全称Json Web Token,翻译为JSON格式的网络令牌,很多时候又简称为Token,它要解决的问题,就是为多种终端设备,提供统一的、安全的令牌格式。因此,JWT只是一个令牌格式而已,你可以把它存储到Cookie,也可以存储到localstorage,没有任何限制!同样的,对于传输,你可以使用任何传输方式来传输JWT,一般来说,我们会使用HTTP消息头来传输它。比如,当登录成功后,服务器可以给客户端响应一个JWT:
在这里插入图片描述

HTTP/1.1 200 OK
...
Set-Cookie:token=JWT令牌
Authorization:JWT令牌
Token:JWT令牌
...

{
   ..., token:JWT令牌}

可以看到,JWT令牌可以出现在响应的任何一个地方,客户端和服务器自行约定即可。

当客户端拿到令牌后,它要做的只有一件事:存储它。你可以存储到任何位置,比如手机文件、PC文件、localstorage、Cookie等。当后续请求发生时,你只需要将它作为请求的一部分发送到服务器即可。

这样一来,服务器就能够收到这个令牌了,通过对令牌的验证,即可知道该令牌是否有效。它们的完整交互流程是非常简单清晰的:
在这里插入图片描述

3.组成

为了保证令牌的安全性,JWT令牌由三个部分组成,分别是:

  1. header:令牌头部,记录了整个令牌的类型和签名算法
  2. payload:令牌负荷,记录了保存的主体信息,比如你要保存的用户信息就可以放到这里
  3. signature:令牌签名,按照头部固定的签名算法对整个令牌进行签名,该签名的作用是:保证令牌不被伪造和篡改

它们组合而成的完整格式是:header.payload.signature,三部分通过.连接。比如,一个完整的jwt令牌如下:

eyJhbGdvcml0aG0iOiJIU0EyNTYiLCJKV1QiOiJKV1QifQ==.eyJpc3N1ZXIiOiJ3aGVyZWFib3V0cy5pY3UiLCJvd25lciI6ImtvcmJpbiIsInB1cnBvc2UiOiJBdXRoZW50aWNhdGlvbiIsInJlY2lwaWVudCI6IkJyb3dzZXIiLCJ0aW1lIjoxNjE0MDc0Nzc2LCJleHBpcmUiOjE2MTQwNzgzNzYsImR1cmF0aW9uIjoxODAwMDAwMDAwMDAwLCJleHRlcm5hbCI6bnVsbH0=.xJLdLSUZvSRkCNw4q5I-OS1yaUvbIRhKzgD92FOc470=

它各个部分的值分别是:

  • eyJhbGdvcml0aG0iOiJIU0EyNTYiLCJKV1QiOiJKV1QifQ==
  • eyJpc3N1ZXIiOiJ3aGVyZWFib3V0cy5pY3UiLCJvd25lciI6ImtvcmJpbiIsInB1cnBvc2UiOiJBdXRoZW50aWNhdGlvbiIsInJlY2lwaWVudCI6IkJyb3dzZXIiLCJ0aW1lIjoxNjE0MDc0Nzc2LCJleHBpcmUiOjE2MTQwNzgzNzYsImR1cmF0aW9uIjoxODAwMDAwMDAwMDAwLCJleHRlcm5hbCI6bnVsbH0=
  • xJLdLSUZvSRkCNw4q5I-OS1yaUvbIRhKzgD92FOc470=

3.1 Header

header是令牌头部,记录了整个令牌的类型和签名算法,它的格式是一个JSON对象,如下:

{
   
  "alg":"HSA256",
  "typ":"JWT"
}
  • alg:signature部分使用的签名算法,通常可以取两个值
    • HS256:一种对称加密算法,使用同一个秘钥对signature加密解密
    • RS256:一种非对称加密算法,使用私钥加密,公钥解密
  • typ:整个令牌的类型,固定写JWT即可

设置好了header的结构之后,还需要对header的JSON对象进行Base64 URL编码,最后编码后生成的字符串才是最终上述的header部分:

eyJhbGdvcml0aG0iOiJIU0EyNTYiLCJ0eXBlIjoiSldUIn0=

Base64 URL不是一个加密算法,而是一种编码方式,它是在Base64算法的基础上对+=/三个字符做出特殊处理的一种变种算法。而Base64是使用64个可打印字符来表示一个二进制数据,具体参考:百度百科

3.2 Payload

这部分是JWT的主体信息,它仍然是一个JSON对象,它可以包含以下内容:

{
   
  	"ss""发行者",
    "iat""发布时间",
    "exp""到期时间",
    "sub""主题",
    "aud""听众",
    "nbf""在此之前不可用",
  	"jti""JWT ID"
}

以上属性可以全写,也可以一个都不写,它只是一个规范,就算写了,也需要你在将来验证这个JWT令牌时手动处理才能发挥作用。上述属性表达的含义分别是:

  • ss:发行该jwt的是谁,可以写公司名字,也可以写服务名称
  • iat:该jwt的发放时间,通常写当前时间的时间戳
  • exp:该jwt的到期时间,通常写时间戳
  • sub:该jwt是用于干嘛的
  • aud:该jwt是发放给哪个终端的,可以是终端类型,也可以是用户名称,随意一点
  • nbf:一个时间点,在该时间点到达之前,这个令牌是不可用的
  • jti:jwt的唯一编号,设置此项的目的,主要是为了防止重放攻击(重放攻击是在某些场景下,用户使用之前的令牌发送到服务器,被服务器正确的识别,从而导致不可预期的行为发生)

实际上很多时候我们都用自定义的结构,说白了payload只是一个JSON对象而已,比如我常用的如下结构也是一个有效的payload:

{
   
	// 签发者
	"issuer":"whereabouts.icu",
	// 令牌所有者,存放ID等标识
	"owner":"korbin",
	// 用途,默认值authentication表示用于登录认证
	"purpose":"Authentication",
	// 接受方,表示申请该令牌的设备来源,如浏览器、Android等
	"recipient":"Browser",
	// 令牌签发时间
	"time":1614074776,
	// 过期时间
	"expire":1614078376,
	// 令牌持续时间,即生命周期
	"duration":1800000000000,
	// 其他扩展的自定义参数
	"external":{
   }
}

然后,payload部分和header一样,需要通过Base64 URL编码得到:

eyJpc3N1ZXIiOiJ3aGVyZWFib3V0cy5pY3UiLCJvd25lciI6ImtvcmJpbiIsInB1cnBvc2UiOiJBdXRoZW50aWNhdGlvbiIsInJlY2lwaWVudCI6IkJyb3dzZXIiLCJ0aW1lIjoxNjE0MDkwNDY2LCJleHBpcmUiOjE2MTQwOTIyNjYsImR1cmF0aW9uIjoxODAwMDAwMDAwMDAwLCJleHRlcm5hbCI6bnVsbH0=

3.3 Signature

这一部分是JWT的签名,正是它的存在,保证了整个jwt不被篡改。这部分的生成方式与上述两部分直接编码JSON对象不同,它需要将前两部分的编码结果通过.连接起来,然后按照头部指定的方式进行加密。如:头部指定的加密方法是HSA256,前面两部分的编码结果是:

eyJhbGdvcml0aG0iOiJIU0EyNTYiLCJ0eXBlIjoiSldUIn0=.eyJpc3N1ZXIiOiJ3aGVyZWFib3V0cy5pY3UiLCJvd25lciI6ImtvcmJpbiIsInB1cnBvc2UiOiJBdXRoZW50aWNhdGlvbiIsInJlY2lwaWVudCI6IkJyb3dzZXIiLCJ0aW1lIjoxNjE0MDkwNDY2LCJleHBpcmUiOjE2MTQwOTIyNjYsImR1cmF0aW9uIjoxODAwMDAwMDAwMDAwLCJleHRlcm5hbCI6bnVsbH0=

则第三部分就是用对称加密算法HS256对该字符串进行加密,并指定一个密钥,如:HeZebin

HS256(`header.payload`, "HeZebin")
// 得到:N3Fxlh1Nh-FBT9uL59axUhXoUv87FpFoJnZgBtSLyqg=

最终,将三部分通过.组合在一起,就得到了完整的JWT。并且由于签名使用的秘钥保存在服务器,客户端就无法伪造出签名,因为它拿不到秘钥。换句话说,之所以说无法伪造JWT,就是因为第三部分signature的存在。而前面两部分并没有加密,只是一个编码结果而已,可以认为几乎是明文传输,粘贴到在线解码网站里面解码出来就是个JSON对象。

这不会造成太大的问题,因为既然用户登陆成功了,它当然有权力查看自己的用户信息;甚至在某些网站,用户的基本信息可以被任何人查看。你要保证的,是不要把敏感的信息存放到JWT中,比如密码。

4.令牌的校验

JWT的signature可以保证令牌不被伪造,那如何保证令牌不被篡改呢?

比如,某个用户登陆成功了,获得了JWT,但他人为的篡改了payload,比如把自己的账户余额修改为原来的两倍,然后重新编码出payload发送到服务器,服务器该如何得知这些信息被篡改过了呢?这就要说到令牌的验证了!!!

服务器要验证这个令牌是否被篡改过,其实验证方式非常简单:

  1. header+payload用同样的秘钥和加密算法进行重新加密;
  2. 然后把加密的结果和传入JWT中的signature进行对比,如果完全相同,则表示前面两部分没有动过,就是自己颁发的,如果不同,肯定是被篡改过了。

当令牌验证为没有被篡改后,服务器可以进行其他验证:比如是否过期、听众是否满足要求等等,这些就视情况而定了。

注意:这些验证都需要服务器手动完成,没有哪个服务器会给你进行自动验证,当然,你可以借助第三方库来完成这些操作。

5.续期方案

签发生成的JWT有一个过期时间,比如令牌有效时间有半个小时,而如果用户在网站使用时间超过30分钟后,会被过滤器拦截然后突然提示登录过期失效,要重新登录,用户心里肯定觉得MDZZ…

所以在用户在一次新的请求之后,或者在令牌过期之前,应当无感知的给用户续期,避免上述极差的用户体验尴尬场景。

说实话网上看了一些双token的续期方案没太看懂,目前自己觉得比较简单的有两种方案:

5.1 方案1

将token放到Redis缓存中,设置过期时间,在令牌有效期内访问过则更新过期时间;若过期了则在Redis中查不到。

5.2 方案2

利用好payload中的expireduration等字段,若某次请求过滤器中计算出当前时间-过期时间小于5分钟了,那么就将过期时间在当前时间的基础上再加30分钟,然后重新编码和加密生成新的token,放在响应头中返回。但这种方案需要前端配合,前端axios响应拦截器中检查header中的token字段,如果有值则表示签发了新的令牌,则更新localstorage中的token,更新后下次则带着新的token了。

6.Token总结

最后,总结一下JWT的特点:

  • JWT本质上是一种令牌格式。它和终端设备无关,同样和服务器无关,甚至与如何传输无关,它只是规范了令牌的格式而已;
  • JWT由三部分组成:headerpayloadsignature,主体信息在payload
  • JWT难以被篡改和伪造,这是因为有第三部分的签名存在。

另外,其实Token也可以不按照JWT规范来生成,只要是你自己通过一个别人不容易破解的算法生成的字符串,都可以作为验证令牌,开发者完全可以根据业务逻辑自定义Token,只是说使用官方的JWT规范更友好。

7.Golang代码实现

我自己实现了一个工具库:github.com/whereabouts/utils,该库下的jwt包中提供了对JWT的一些基本功能。

go get github.com/whereabouts/utils

下载库后使用示例:

package main

import (
	"fmt"
	"github.com/whereabouts/utils/jwt"
)

func main() {
   
	// example of jwt
	fmt.Println("#######################################################")
	token := jwt.NewToken().SetOwner("korbin").String()
	fmt.Println(token)
	fmt.Println(jwt.Check(token))
	fmt.Println(jwt.Check(token + "1"))
	fmt.Println(jwt.IsExpire(token))
	fmt.Printf("%+v\n", jwt.GetPayload(token))
	token = jwt.Refresh(token)
	fmt.Println(token)
	fmt.Printf("%+v\n", jwt.GetPayload(token))
}

另外该库下还提供了一个slice包,用于对切片元素的快捷删除,感兴趣的小伙伴也可以看看。

本文来源MrKorbin,由架构君转载发布,观点不代表Java架构师必看的立场,转载请标明来源出处:https://javajgs.com/archives/25241

发表评论