jwt认证 | 青训营笔记

117 阅读12分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第三篇笔记

什么是JWT

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

起源

说起JWT,我们应该来谈一谈基于token的认证和传统的session认证的区别。

传统的session认证

我们知道,http协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据http协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于session认证。

但是这种基于session的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于session认证应用的问题就会暴露出来.

基于session认证所显露的问题

Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

CSRF: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。

基于token的鉴权机制

基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

流程上是这样的:

  • 用户使用用户名密码来请求服务器
  • 服务器进行验证用户的信息
  • 服务器通过验证发送给用户一个token
  • 客户端存储token,并在每次请求时附送上这个token值
  • 服务端验证token值,并返回数据

这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *

那么我们现在回到JWT的主题上。

JWT长什么样?

JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了Jwt字符串。就像这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

JWT的构成

第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature).

header

jwt的头部承载两部分信息:

  • 声明类型,这里是jwt
  • 声明加密的算法 通常直接使用 HMAC SHA256

完整的头部就像下面这样的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

playload

载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分

  • 标准中注册的声明
  • 公共的声明
  • 私有的声明

标准中注册的声明 (建议但不强制使用) :

  • iss(Issuer) : jwt签发者
  • sub(Subject) : jwt所面向的用户
  • aud(Audience) : 接收jwt的一方
  • exp(ExpiresAt) : jwt的过期时间,这个过期时间必须要大于签发时间
  • nbf(NotBefore) : 定义在什么时间之前,该jwt都是不可用的.
  • iat(IssuedAt) : jwt的签发时间
  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

公共的声明 : 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.

私有的声明 : 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

定义一个payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

然后将其进行base64加密,得到Jwt的第二部分。

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

signature

jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

  • header (base64后的)
  • payload (base64后的)
  • secret

这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。

// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
​
var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

将这三部分用.连接成一个完整的字符串,构成了最终的jwt:

  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

如何应用

一般是在请求头里加入Authorization,并加上Bearer标注:

fetch('api/user/1', {
  headers: {
    'Authorization': 'Bearer ' + token
  }
})

服务端会验证token,如果验证通过就会返回相应的资源。整个流程就是这样的:

img

jwt-diagram

总结

优点

  • 因为json的通用性,所以JWT是可以进行跨语言支持的,像JAVA,JavaScript,NodeJS,PHP等很多语言都可以使用。
  • 因为有了payload部分,所以JWT可以在自身存储一些其他业务逻辑所必要的非敏感信息。
  • 便于传输,jwt的构成非常简单,字节占用很小,所以它是非常便于传输的。
  • 它不需要在服务端保存会话信息, 所以它易于应用的扩展

安全相关

  • 不应该在jwt的payload部分存放敏感信息,因为该部分是客户端可解密的部分。
  • 保护好secret私钥,该私钥非常重要。
  • 如果可以,请使用https协议

gin-jwt

介绍

JSON Web Token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该 Token 被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该 Token 也可直接被用于认证,也可被加密。

使用

安装

go get github.com/appleboy/gin-jwt

引入

import "github.com/appleboy/gin-jwt"

我目前使用的版本是 v2.5.0.

创建中间件

设计 API 对象

type API struct {
    App    *apps.App  // 业务对象
    Router *gin.Engine // 路由
    JWT    *jwt.GinJWTMiddleware // jwt 对象
}

中间件对象:

api.JWT = &jwt.GinJWTMiddleware{
        Realm:      "gin jwt",
        Key:        []byte("secret key"),
        Timeout:    time.Hour,
        MaxRefresh: time.Hour,
        PayloadFunc: func(data interface{}) jwt.MapClaims {},
        Authenticator: func(c *gin.Context) (interface{}, error) {},
        Authorizator: func(data interface{}, c *gin.Context) bool {},
        Unauthorized: func(c *gin.Context, code int, message string) {},
        TokenLookup: "header: Authorization, query: token, cookie: jwt",
        // TokenLookup: "query:token",
        // TokenLookup: "cookie:token",
        TokenHeadName: "Bearer",
        TimeFunc: time.Now,
    }
  • Realm JWT标识
  • Key 服务端密钥
  • Timeout token 过期时间
  • MaxRefresh token 更新时间
  • PayloadFunc 添加额外业务相关的信息
  • Authenticator 在登录接口中使用的验证方法,并返回验证成功后的用户对象。
  • Authorizator 登录后其他接口验证传入的 token 方法
  • Unauthorized 验证失败后设置错误信息
  • TokenLookup 设置 token 获取位置,一般默认在头部的 Authorization 中,或者 query的 token 字段,cookie 中的 jwt 字段。
  • TokenHeadName Header中 token 的头部字段,默认常用名称 Bearer
  • TimeFunc 设置时间函数

注册阶段

在注册时如果要直接返回 token,那么可以调用 TokenGenerator 来生成 token。

token, expire, err := c.JWT.TokenGenerator(strconv.Itoa(user.ID), *user)

TokenGenerator 的具体实现

func (mw *GinJWTMiddleware) TokenGenerator(userID string, data interface{}) (string, time.Time, error) {
   // 根据签名算法创建 token 对象
    token := jwt.New(jwt.GetSigningMethod(mw.SigningAlgorithm))
    // 获取 claims
    claims := token.Claims.(jwt.MapClaims)
​
   // 设置业务中需要的额外信息
    if mw.PayloadFunc != nil {
        for key, value := range mw.PayloadFunc(data) {
            claims[key] = value
        }
    }
​
   // 过期时间
    expire := mw.TimeFunc().UTC().Add(mw.Timeout)
    claims["id"] = userID
    claims["exp"] = expire.Unix()
    claims["orig_iat"] = mw.TimeFunc().Unix()
    // 生成 token 
    tokenString, err := mw.signedString(token) 
    if err != nil {
        return "", time.Time{}, err
    }
​
    return tokenString, expire, nil
}

登录阶段

登录时会调用 Authenticator 注册的方法。

func (api *API) LoginAuthenticator(ctx *gin.Context) (interface{}, error) {
    var params model.UserParams
    if err := ctx.Bind(&params); err != nil {
        return "", jwt.ErrMissingLoginValues
    }
​
   // 根据用户名获取用户
    user, err := api.App.GetUserByName(params.Username)
    if err != nil {
        return nil, err
    }
​
   // 验证密码
    if user.AuthPassword(params.Password) {
        return *user, nil
    }
​
    return nil, jwt.ErrFailedAuthentication
}

验证 Token

其他接口在设置了中间件 Router.Use(api.JWT.MiddlewareFunc()) 后,通过调用 Authorizator 方法来验证。

func (api *API) LoginedAuthorizator(data interface{}, c *gin.Context) bool {
    if id, ok := data.(string); ok {
        return api.App.IsExistUser(id)
    }
    return false
}

在业务 Hander 中可以通过方法 jwt.ExtractClaims(ctx) 来获取 payload 的信息。

深入

gin-jwt 依赖的 jwt 库叫做 jwt-go。下面来介绍一下这个库。

核心的 Token 结构:

// A JWT Token.  Different fields will be used depending on whether you're
// creating or parsing/verifying a token.
type Token struct {
    Raw       string                 // The raw token.  Populated when you Parse a token
    Method    SigningMethod          // The signing method used or to be used
    Header    map[string]interface{} // The first segment of the token
    Claims    Claims                 // The second segment of the token
    Signature string                 // The third segment of the token.  Populated when you Parse a token
    Valid     bool                   // Is the token valid?  Populated when you Parse/Verify a token
}

这个Token结构体是用来生成 jwt 的 token。其中 Method 是用来表示签名使用的算法。Header 是头部jwt的信息,还有 Claims 记录额外的信息。

然后是生成签名的方法,key 是服务端的密钥。

func (t *Token) SignedString(key interface{}) (string, error) {
    var sig, sstr string
    var err error
    // 将 Header 和 Claims 转换成字符串然后 base64 之后拼接在一起。
    if sstr, err = t.SigningString(); err != nil {
        return "", err
    }
    // 使用签名算法加密
    if sig, err = t.Method.Sign(sstr, key); err != nil {
        return "", err
    }
    return strings.Join([]string{sstr, sig}, "."), nil
}

解密 token 的对象叫做 Parser

type Parser struct {}
​
// 主要解析方法
func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {}

Parser 除了验证 Token 外,还包括解码 Header 和 Claims 的内容。

JWT和token的区别及优缺点

TOKEN

概念: 令牌, 是访问资源的凭证。

1. Token的认证流程:

  1. 用户输入用户名和密码,发送给服务器。
  2. 服务器验证用户名和密码,正确的话就返回给客户端一个签名过的token。
  3. 浏览器客户端拿到这个token,存储到cookie或者localStorage中。
  4. 客户端后续每次请求中,会在 header中携带token发送给服务器。
  5. 服务器器验证token并解析出用户ID等相关信息,通过调取数据库信息比对,如果有效那么认证就成功,可以返回客户端需要的数据,无效则返回错误信息引导客户端跳转到登陆页面。

2. Token的优点:

  • 可以隐藏真实数据
  • 适用于分布式或微服务
  • 安全系数高

3. Token缺点:

  • 需要解析后,调用数据库比对来验证,浪费服务器资源
  • 内存级别重启全部失效
  • 时效性,无法失效,被非法获取后可以一直使用
  • 集群部署,多台服务器无法共享,负载会导致用户状态不同步

JWT

1. JWT的优点:

  • 无需服务器端存放数据,减轻服务器端的压力
  • 占用带宽比较小、跨语言
  • token自身包含用户信息且无法篡改,在服务(网关)中可以自行解析校验出用户信息,对认证服务器(account-svc)压力小

2. JWT的缺点:

  • 建议不要放铭感数据 userid、手机号码(如果非要放userId,deptId等信息,可采用rsa256算法加密)RSA256生成Jwt
  • Jwt生成之后无法修改(发生变化)
  • 后端无法统计 生成jwt
  • 无法吊销令牌,只能等待令牌自身过期
  • 令牌长度与其包含用户信息多少正相关,传输开销较大
  • Jwt是无状态的,如果别人获取到了,别人也能用

对比例子

现在有一个接口/viptest只能是vip用户访问,我们看看服务端如何根据Token判断用户是否有效。

普通token版:

查库判断是否过期

查库判断时候是VIP

JWT 版本:

假如payload部分如下:

{
"exp": 1518624000,
"isVip": true,
"uid":1
}

解析JWT

判断签名是否正确,根据生成签名时使用的密钥和加密算法,只要这一步过了就说明是payload是可信的

判断JWT token是否过期,根据exp,判断是否是VIP,根据isVip

从以上不难看出:JWT版是没有查库的,他所需要的基础信息可以直接放到JWT里,服务端只要判断签名是否正确就可以判断出该用户是否可以访问该接口,当然JWT里的内容也不是无限多的,其他更多的信息我们就可以通过id去查数据库