一文搞定用户登陆验证 JWT (含 jwt 的 Go 实现案例)

2,953 阅读7分钟

你好👋,我在写“一文搞定” 的系列文章。感谢大家的支持和阅读。
欢迎关注我的Github,了解更多 Go 相关知识。

我们在使用一个互联网服务(如网站,app,小程序)时,很多场景下都需要登陆。首先很多服务,如论坛、社交媒体,需要辨识个人身份,以便在不同设备、不同时间都能同步自己过去使用该产品的记录,其次游戏、音视频网站,需要包月、买断等方式购买会员,以获得增值服务和体验,再次,谚语云:“免费的东西最昂贵”,一些软件,如QQ、微信、百度系等,看似免费,实则企业会强制收集用户信息和机器识别码,以变相获取收益。

下面我们来看看用户登陆验证是怎么实现的。

用户认证

HTTP 是一个无状态的协议,一次请求结束后,下次在发送服务器就不知道这个请求是谁发来的了(同一个IP不代表同一个用户),在 Web 应用中,用户的认证和鉴权是非常重要的一环,实践中有多种可用方案,并且各有千秋。

Cookie - Session 认证模式

在Web应用发展的初期,大部分采用基于Cookie-Session的会话管理方式,逻辑如下。

  • 客户端使用用户名、密码进行认证
  • 服务端验证用户名、密码正确后生成并存储 Session ,将 SessionID 通过Cookie返回给客户端
  • 客户端访问需要认证的接口时在Cookie中携带 SessionID ,服务端通过SessionID 查找 Session 并进行鉴权,返回给客户端需要的数据

示意图如下: image.png

基于 Session 的方式存在多种问题。

  • 服务端需要存储 Session , 并且由于 Session 需要经常快速查找,通常存储在内存或内存数据库中,同时在线用户较多时需要占用大量的服务器资源。
  • 当需要扩展时,创建 Session 的服务器可能不是验证 Session 的服务器,所以还需要将所有 Session 单独存储并共享。
  • 由于客户端使用 Cookie 存储 SessionID,在跨域场景下需要进行兼容性处理,同时这种方式也难以防范CSRF攻击。

Token认证模式

鉴于基于 Session 的会话管理方式存在上述多个缺点,基于 Token 的无状态会话管理方式诞生了,所谓无状态,就是服务端可以不再存储信息,甚至是不再存储Session,逻辑如下:

  • 客户端使用用户名、密码进行认证
  • 服务端验证用户名、密码正确后生成Token返回给客户端
  • 客户端保存 Token ,访问需要认证的接口时在URL参数或 HTTP Header 中加入Token
  • 服务端通过解码 Token 进行鉴权,返回给客户端需要的数据

示意图如下: image.png

基于Token的会话管理方式有效解决了基于Session的会话管理方式带来的问题。

  • 服务端不需要存储和用户鉴权有关的信息,鉴权信息会被加密到Tokn中,服务端只需要读取 Token 中包含的鉴权信息即可
  • 避免了共享Session导致的不易扩展问题
  • 不需要依赖Cookie,有效避免Cookie带来的CSRF攻击问题
  • 使用CORS可以快速解决跨域问题

JWT 介绍

JWTJSON Web Token 的缩写,是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准(RFC7519)。JWT 本身没有定义任何技术实现,它只是定义了一种基于Token的会话管理的规则,涵盖Token需要包含的标准内容和Token的生成过程,特别适用于分布式站点的单点登录(SSO)场景。

一个JWT Token就像这样:

eyJhbGcioiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX21kIjoyODAxODcyNzQ40DMyMzU4NSwiZXhwIjoxNTkONTQwMjkxLCJpc3MioiJibHVIYmVsbCJ9.1k ZrAtYGCeZhK3iupHxP1kgjBJzQTVTtX0iZYFx9wU

它是由.分隔的三部分组成,这三部分依次是:

  • 头部 Header
  • 负载 Payload
  • 签名 Signature 头部和负载以 JSON 形式存在,这就是 JWT 中的 JSON,三部分的内容都分别单独经过了 Base64 编码,以 . 拼接成一个 JWT Token

image.png

Header

JWTHeader 中存储了所使用的加密算法和 Token 类型。

{
    "a1g":"HS256",
    "typ":"JwT",
}

Payload

Payload 表示负载,也是一个 JSON 对象,JWT 规定了 7 个官方字段供选用,

iss (issuer): 签发人
exp (expiration time): 过期时间
sub (subject): 主题
aud (audience): 受众
nbf (Not Before): 生效时间
iat (Issued At): 签发时间
jti (JWT ID): 编号

除了官方字段,开发者也可以自己指定字段和内容,例如下面的内容。

"sub": "一文搞定用户登陆验证 和 JWT (含基于 Go 的基本实现)",
"name": "Vooce",
"admin": true

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。这个 JSON 对象也要使用 Base64URL 算法转成字符串。

Signature

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

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

JWT 优缺点

JWT拥有基于 Token 的会话管理方式所拥有的一切优势,不依赖Cookie,使得其可以防止CSRF攻击,也能在禁用 Cookie 的浏览器环境中正常运行。 而 JWT 的最大优势是服务端不再需要存储 Session,使得服务端认证鉴权业务可以方便扩展,避免存储 Session 所需要引入的 Redis 等组件,降低了系统架构复杂度。 但这也是JWT最大的劣势,由于有效期存储在 Token 中,JWT Token 一旦签发,就会在有效期内一直可用,无法在服务端废止,当用户进行登出操作,只能依赖客户端删除掉本地存储的 JWT Token ,如果需要禁用用户,单纯使用 JWT就无法做到了。

基于 JWT 实现认证实践

前面讲的Token都是Access Token,也就是访问资源接口时所需要的Token,还有另外一种Token 是 Refresh Token。通常情况下,Refresh Token的有效期会比较长,而Access Token的有效期 比较短,当Access Token由于过期而失效时,使用Refresh Token就可以获取到新的Access Token, 如果Refresh Token也失效了,用户就只能重新登录了。

在JWT的实践中,引入Refresh Token,将会话管理流程改进如下。

  • 客户端使用用户名密码进行认证
  • 服务端生成有效时间较短的Access Token(例如10分钟),和有效时间较长的Refresh Token(例如7天)
  • 客户端访问需要认证的接口时,携带Access Token
  • 如果Access Token没有过期,服务端鉴权后返回给客户端需要的数据
  • 如果携带Access Token访问需要认证的接口时鉴权失败(例如返回401错误),则客户端使用 Refresh Token向刷新接口申请新的Access Token
  • 如果Refresh Token没有过期,服务端向客户端下发新的Access Token
  • 客户端使用新的Access Token访问需要认证的接口

image.png

JWT之限制同一账号同一时间只能登陆一个设备

image.png

附录:我用 golang-jwt/jwt 对 jwt 的 Go 实现

项目地址:mophia/Goi: A forum based on Go & Vue (github.com)

package jwt

import (
   "errors"
   "fmt"
   "time"

   "github.com/golang-jwt/jwt"
   "github.com/spf13/viper"
)

var mySecret = []byte("jwt")

func keyFunc(_ *jwt.Token) (i interface{}, err error) {
   return mySecret, nil
}

// MyClaims 自定义声明结构体并内嵌 jwt.StandardClaims
// jwt包自带的jwt.StandardClaims只包含了官方字段,若需要额外记录其他字段,就可以自定义结构体
// 如果想要保存更多信息,都可以添加到这个结构体中

type MyClaims struct {
   UserID int64 `json:"user_id"`
   jwt.StandardClaims
}

func GenToken(userID int64) (string, error) {
   // 创建一个我们自己的声明的数据
   c := MyClaims{
      userID,
      jwt.StandardClaims{
         ExpiresAt: time.Now().Add(
            time.Duration(viper.GetInt("auth.jwt_expire")) * time.Hour).Unix(), // 过期时间
         Issuer: "jwt", // 签发人
      },
   }
   // 使用指定的签名方法创建签名对象
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
   // 使用指定的secret签名并获得完整的编码后的字符串token
   return token.SignedString(mySecret)
}

// ParseToken 解析JWT
func ParseToken(tokenString string) (*MyClaims, error) {
   // 解析token
   var mc = new(MyClaims)
   token, err := jwt.ParseWithClaims(tokenString, mc, keyFunc)
   if err != nil {
      fmt.Println(err.Error())
      return nil, err
   }
   fmt.Println(token)
   // 校验token
   if token.Valid {
      return mc, nil
   }
   return nil, errors.New("invalid token")
}

// RefreshToken 刷新AccessToken
func RefreshToken(aToken, rToken string) (newAToken, newRToken string, err error) {
   // refresh token无效直接返回
   if _, err = jwt.Parse(rToken, keyFunc); err != nil {
      return
   }

   // 从旧access token中解析出claims数据
   var claims MyClaims
   _, err = jwt.ParseWithClaims(aToken, &claims, keyFunc)
   v, _ := err.(*jwt.ValidationError)

   // 当access token是过期错误 并且 refresh token没有过期时就创建一个新的access token
   if v.Errors == jwt.ValidationErrorExpired {
      token, _ := GenToken(claims.UserID)
      return token, "", nil
   }
   return
}