JSON Web Token(JWT)入门详解及实例分析 | 青训营笔记

520 阅读13分钟

这是我参与「第五届青训营」伴学笔记创作活动的第13天

本文较为详细地介绍了JWT的相关知识;并在Go语言中进行简单实践;最后收集了三个开源项目的源码,对其中相关代码的优缺点进行对比学习。本文全篇共4K字左右,请根据需要进行阅读。 image.png

一、所需场景和跨域认证问题

在Web通信过程中,绝大部分服务端和客户端的交互都是要建立在服务端对客户端身份有效识别的基础之上。试想某宝的购物流程,你登陆自己的账户后,首先将一件衣服加入购物车,过了十分钟又将一件零食放入购物车,最后再进行支付。在一整个购物的流程中,某宝的服务器端是怎么识别和认定商品加入到你账户的购物车中?这就需要解决用户认证问题,怎么证明你是你自己。

HTTP协议是无状态的,也就是说如果服务器端这一次认证了一个用户,那么下一次相同用户请求的时候服务器并不知道是谁,就必须再次认证。Web服务的用户认证流程一般是以下的步骤:

  1. 用户向服务器发送用户名和密码;
  2. 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等;
  3. 服务器向用户返回一个 session_id,写入用户的 Cookie;
  4. 用户随后的每一次请求,都会通过 Cookie,将session_id 传回服务器;
  5. 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份,从而实现用户身份认证。

这方式在单机当然没有问题,单台服务器存储所有用户信息拿到所有session数据,然而如果是服务器集群,或者需要跨域的服务导向架构,就要求session数据共享,每台服务器都能读取session来获取用户信息。

举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

一种方法是将session写入数据库等,将其持久化,不同服务器在有需求时都向持久层请求数据。但缺点很明显,存储的数据量大所需工程量大,而且有单点失效的风险。另一种方法,则是下面要详细讲解的JWT了。

二、JWT原理详解

2.1 什么是JWT

JSON Web Token (JWT) 是一个开放标准 ( RFC 7519 ),它定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。该信息可以被验证和信任,因为它是经过数字签名的。

这概念有点抽象,具体解释一下:服务器在对客户端进行认证以后,生成一个JSON对象并发给客户端。以后每当客户端需要与服务器进行通信时,都会发回JSON对象,服务器端便可以通过JSON来判断这是哪一个用户。而且之所以JSON是“可以被验证和信任”,是因为服务器端在生成JSON时为防止用户篡改,会经过数字签名。这样就能保证服务器端知道,这个JSON确实是我自己发出去的,这个客户端确实我认证过。

还是有点抽象?那就再看一个网站JSON Web 令牌 - jwt.io 1.png 这是一个自动生成JWT的网站,我们在右边方框里修改信息,左边也会进行相应的变动,最终生成的JWT其实就是左边框中的样子。想必不难看出,左右两边三种颜色是相互对应的,也就是说JWT是分为三个部分,分别是HEADER、PAYLOAD、VERIFY SIGNATURE。它们经过一定规则排列起来写成一行,中间用(.)分隔开来,如下图所示。下文依次介绍这三部分的内容。 2.png

2.2 HEADER

Header部分是一个JSON对象,描述JWT的元数据,通常是图中的样子 31.png

这部分中alg属性表示签名的算法,例如HS256表示采用HMAC SHA256的算法;typ属性表示令牌(token)的类型(type),JWT令牌统一写为JWT

2.3 PAYLOAD

Payload部分是有关客户端信息的具体数据和附加声明。共有三种类型的声明:注册声明、公共声明和私人声明。

注册声明:这些是一组预定义的声明,不是强制性的但是推荐使用的一些声明。提供了例如 iss(发行者)、 exp(到期时间)、 sub(主题)、 aud(受众)、iat(签发时间)、nbf(生效时间)等一组使用频率比较高的声明;

公共声明:这些可以由使用 JWT 的人随意定义。但是为了避免冲突,它们应该在IANA JSON Web Token Registry中定义或定义为包含抗冲突命名空间的 URI;

私有声明:这一类声明是JWT的产生者和使用者都同意才可以使用的声明,要避免与上面两种类型的声明冲突;

如上面网站中默认生成的一个有效的payload如下图解释。有一点需要注意的是JWT 默认是不加密的(即便后面的签名也只是为了防止数据篡改),任何人都可以读到,所以不要把秘密信息放在这个部分。 4.png

2.4 VERIFY SIGNATURE

Signature 部分是对前两部分的签名,防止数据篡改。 首先,需要指定一个密钥。这个密钥只有服务器才知道,不能泄露给客户端。然后,使用Header里面指定的签名算法,按照下面的公式产生签名(拿默认的HMAC SHA256为例):

HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

说叫签名其实按照密码学严格定义来讲,其实就是按规则做了个哈希MAC消息验证码。密钥在服务器端手里,JWT需要客户端发回服务器端,服务器收到后只要拿前面的明文消息再做一次哈希,对比消息中的签名是否相同就可以判断JWT有没有被修改,从而达到对客户端身份认证的目的。

2.5 Base64URL编码

JWT作为一个令牌(token),有些场合可能会放到URL(比如 api.example.com/?token=xxx。Base64 有三个字符+/=,在URL里面有特殊含义,所以要被替换掉:=被省略、+替换成-/替换成_ 。这就是Base64URL算法。其中Header和Payload两部分用Base64URL的算法进行串行化。

三、实例分析

很多语言都已经有了可以直接调用的JWT库,例如C++11中创建和验证JWT的Header only库,只需要include头文件就可以使用,源码链接在这里(Thalhammer/jwt-cpp: A header only library for creating and validating json web tokens in c++ (github.com))。 对Go语言来说同样也有方便的JWT库可以使用,源码在此,直接go get便可以使用:

go get github.com/dgrijalva/jwt-go

下文先从Go语言的角度解释JWT三部分的生成方法,再通过三个项目的源码分析各JWT部分优劣所在。

3.1 Go语言的JWT生成和解析

首先是Header部分,由上文我们得知其中只有两个字段,如果不改变默认签名算法的情况下,Header的生成是不用我们去填写的,在后续有签名算法函数会自动生成。

接着是Payload部分,我们可以查看 jwt.StandardClaims 类型的字段,可以发现它包含如下的类型:

type StandardClaims struct {
	Audience  string `json:"aud,omitempty"`
	ExpiresAt int64  `json:"exp,omitempty"`
	Id        string `json:"jti,omitempty"`
	IssuedAt  int64  `json:"iat,omitempty"`
	Issuer    string `json:"iss,omitempty"`
	NotBefore int64  `json:"nbf,omitempty"`
	Subject   string `json:"sub,omitempty"`
}

发现这些字段其实就是上文提到的注册声明中的那些字段,也就是Payload部分默认填写的字段。这里我们直接填充每一部分,来用它得到token:

claims := &Claims{
    UserId: user.UserInfoId,          
    StandardClaims: jwt.StandardClaims{
        ExpiresAt: time.Now().Add(2 * time.Hour).Unix(),
        IssuedAt:  time.Now().Unix(),
        Issuer:    "ziejietiaodong1",
        Subject:   "test",
    }}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

函数jwt.NewWithClaims()将返回一个Token对象。

最后是Signature部分。这一部分只需要调用库中的一个函数SignedString 方法并传入产生签名需要的密钥(自定义byte[]类型的数据),将会自动完成token的整个编码过程:包括对 Signature 字段的填写,以及将上述三个部分json数据通过base64url进行最后的编码返回最终的token字符串。

tokenString, err := token.SignedString(jwtKey)

解析JWT。JWT的解析过程就是一个反序列化的过程,类似于JSON的解析过程。库内提供了函数ParseWithClaims,需要传入对象的指针,然后在内部帮你完整反序列化操作绑定到这个对象,唯一的不一样就是我们还需要传入一个回调,这个回调返回密钥以便最终的签名判断防止数据篡改。

token, _ := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
   return jwtKey, nil
}

3.2 JWT相关项目分析

上一节简单阐述了Go语言中JWT的生成和解析步骤,但只有这些肯定还是不能够在项目中进行用户认证的实现,还有一些流程例如哪些环节中发送JWT何时创建JWTJWT过期或者解析不对如何处理等问题。下面我通过字节跳动青训营的极简版抖音项目的三个不同队伍的源码来分析这些问题。

源码一

这一项目是我学习JWT的参考项目,前文的一些内容是我参考这一源码作者的博文得来的。所以这一源码在JWT的原理使用上我觉得是很正确清晰的。

其中的middleware/jwt.go文件中定义了Claims结构体、ReleaseToken函数功能是生成JWT、ParseToken负责解析JWT、最后写了个鉴权中间件用来读取token信息,并把解析函数调用进来,做一个正确与否的判断。

值得一提的是,在中间件判断的情况中,本项目给出了一个token超时的判断:

//token超时
if time.Now().Unix() > tokenStruck.ExpiresAt {
   c.JSON(http.StatusOK, models.CommonResponse{
      StatusCode: 402,
      StatusMsg:  "token过期",
   })
   c.Abort() //阻止执行
   return
}

实际上JWT解析过程中,它是会判断token是否过期了,如果过期,那么就会返回err。意思就是其实不用写也可以如果出现过期的情况,在解析函数中就会返回err。但是我觉得,对于初学者和项目相关人员来说,自行写一个过期判断更有利于对JWT工作流程的理解,使得更加清楚明了,好评。

另外,本项目将生成分发token的函数放在了service/user_login/post_user_login.goservice/user_login/qurey_user_login.go文件中,也就是放在了service层,再由handler层的API函数调用,并没有很明确的标注这一部分,个人觉得逻辑可以进行优化;在路由文件中,本项目没有进行明确的划分,哪些接口需要JWT的验证,只有在部分功能中进行用户鉴权;还有就是本项目设置的JWT过期时间太长了,可能是为了方便测试,但是无法测试JWT过期的情况,可以进行修改。

源码二

这一项目是之前字节青训营的获奖项目,做的很优秀,代码也开源在了github上。这里同样重点关注用户鉴权JWT相关代码。

首先看项目根目录下的路由文件router.go,可以看到每个接口中基本都调用了jwt.XXX中间件,其中还分为了三种不同的功能。 image.png

其中AuthAuthBody的功能基本类似,若用户携带的token正确,解析token,将userId放入上下文context中并放行;否则,返回错误信息。两者的不同就在于起始处一个是从context中获取token,另一个是POST的数据体里获取。这一处个人觉得有些冗余(两个函数还分为了两个go文件),可以合在一个函数中做一个判断。

AuthWithoutLogin是我觉得写的很好的点,未登录情况下,若携带token,则解析出用户id并放入context;若未携带,则放入用户id默认值0。单独把这一功能分出来,是为了迎合一种情况:抖音APP存在用户不登陆也可以刷视频feed流的情况。只要把这一中间件放在对应的接口处,就能优化APP,更贴近现实。

这一项目中,解析JWT的函数放在了middleware/jwt/auth.go文件下,却一时间找不到创建jwt的函数。后来我找到用户注册接口,找到token分发函数调用处,才发现 在service/userServiceImpl.go中定义的JWT创建函数,同一文件下还有密码加密函数,个人觉得可以改进。这一项目的过期时间设置的24H比第一个项目合理一些。

源码三

这一项目是本次队友之前参加青训营的未完成项目,也来分析一下其中相关代码实现。

打开router.go,发现这里的结构跟前两个项目不同,这里的中间件都是从controller目录下调用的,并没有像前两个项目一样将最上层与中间件层分开。逻辑稍微有些混乱。进入到user.go文件中,它的Auth竟然只有一行,转而调用service目录下的Auth;再点进去,发现这里的Auth还是只有一行:

authMiddleware.MiddlewareFunc()(c)

这才发现本项目是直接使用的一个github库github.com/appleboy/gin-jwt/v2,这个库应该是写好了关于Go语言的JWT中间件,可以直接拿来使用,还是比较方便的,同样的不出所料在service层的user.go中,看到了注册接口中同样方式生成JWT的操作。

个人觉得这样有点过于省略,不如自己去完成JWT的相关定义,而且结构不够清晰,修改Payload中的字段也不够方便。但利用了第三方的middleware在功能上肯定会更严谨更先进一些吧,也有可取之处。

3.3 JWT注意点

以下引用自阮一峰的博客:

(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。

(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。

(3)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。

(4)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

(5)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。

(6)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

四、参考引用

JSON Web Token 入门教程 - 阮一峰的网络日志 (ruanyifeng.com)

【项目必会】用户鉴权是什么?jwt有什么用? | 青训营笔记 - 掘金 (juejin.cn)