JWT 简介

2018-02-13 golang network security

在程序开发中,用户认证授权是一个绕不过的重难点,以前的开发模式下,cookie 和 session 认证是主流,随着前后端分离的趋势,基于 Token 的认证方式成为主流。

而 JWT(RFC 7519) 是基于 Token 认证方式的一种机制,是实现单点登录认证的一种有效方法。

这里详细介绍其设计和使用方式。

JSON Web Token, JWT

JWT 定义了一个紧凑且自包含的方式,通过 JSON 对象安全地传输信息,这些信息可以通过数字签名进行验证和信任,可以使用 HMAC 算法或使用 RSA 的公私钥对来对 JWT 进行签名。

体积足够小,可以通过 URL、POST 参数或者使用 HTTP 头发送,而且其有效载荷包含有关用户的所有必需信息,从而可以避免多次查询数据库。

详细可以参考 jwt.io 中的相关入门介绍,如下仅仅介绍相关的概念。

如何工作

当用户使用自己的凭证 (Credentials) 成功登录时,将返回一个 JSON Web Token,并且必须保存在本地 (可以是本地存储中,也可以使用 Cookie),当然为了安全考虑,需要确认其有效时间。

无论何时用户想要访问受保护的路由或资源时,需要同时带上 JWT,一般在 Authorization 头部的 Bearer 模式中,类似如下:

Authorization: Bearer <token>

这是一种无状态身份验证机制,因为用户状态永远不会保存在服务器内存中,服务器通过检查是否为有效的 JWT 判断其权限,由于 JWT 是独立的,所有必要的信息都在那里,减少了多次查询数据库的需求。

结构

JWT 包含三个由点 . 分隔的部分:头部、有效载荷、签名,所以看起来基本上类似 xxx.yyy.zzz 的格式。

头部

头部通常由两部分组成:令牌的类型 (JWT) 和正在使用的散列算法 (例如HMAC SHA256 RSA)。

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

然后,这个 JSON 被 Base64Url 编码,形成 JWT 的第一部分。

有效载荷

第二部分是包含声明的有效载荷,有三种类型的声明 (Claims) :保留,公开和私有声明。

  • 保留的声明 (Reserved Claims),一组预先定义的声明,非强制性但推荐使用,例如 iss(发行人),exp(到期时间),sub(主题),aud(听众)等等。
  • 公开声明 (Public Claims),这些可以由使用JWT的人员任意定义。 但为避免冲突,应在IANA JSON Web令牌注册表中定义它们,或者将其定义为包含防冲突命名空间的URI。
  • 私有声明(Private Claims),:这是为了同意使用它们的各方之间共享信息而创建的自定义claims。
{
    "sub": "1234567890",
    "name": "John Doe",
    "admin": true
}

其中保留声明主要包含了如下几种:

  • sub 该JWT所面向的用户
  • iss 该JWT的签发者
  • iat Issued AT 在什么时候签发的token
  • exp Expires token什么时候过期
  • nbf Not Before token在此时间之前不能被接收处理
  • jti JWT ID为web token提供唯一标识

如上的声明在 GoLang 实现中通过 type StandardClaims struct 定义。

签名

签名部分会计算 编码头部 编码有效载荷 中的内容,例如,如果想使用 HMAC SHA256 算法,签名将按以下方式创建:

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

签名用于验证 JWT 是否被修改。

JWT 实现方案

JWT-GO

详见 jwt-go 中的内容,如下是 HS256 的示例。

package main

import (
    "fmt"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

func main() {
    key := "It's your secret key"

    token := jwt.New(jwt.SigningMethodHS256)

    claims := make(jwt.MapClaims)
    claims["username"] = "Your Name"
    claims["iss"] = "Cargo JWT Builder"
    claims["aud"] = "www.cargo.com"
    claims["exp"] = time.Now().Add(time.Hour * time.Duration(1)).Unix()
    claims["iat"] = time.Now().Unix()
    token.Claims = claims

    // 对上述的Claim信息生成Token信息
    tokenString, err := token.SignedString([]byte(key))
    if err != nil {
        fmt.Printf("[ERROR] Sign the token failed, %v\n", err)
        return
    }
    fmt.Printf("[ INFO] Got signed token '%v'\n", tokenString)

    // 第二个入参为keyFunc,也就是通过函数来获取签名用的私钥Key信息
    newtoken, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
        }
        return []byte(key), nil
    })

    // 同时会检查是否有效
    if claims, ok := newtoken.Claims.(jwt.MapClaims); ok && newtoken.Valid {
        fmt.Printf("[ INFO] Got claims '%v'\n", claims)
    } else {
        fmt.Printf("[ERROR] Invalid claims '%v'\n", err)
    }
}

通过 Claims 保存需要传递的数据,上述的 jwt.MapClaims 是提供的默认实现,用户也可以自定义,只需要实现 Valid() error 方法即可。

package main

import (
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type SimpleClaims struct {
	Name string `json:"name"`
	// 如果部分字段为空,会直接忽略
	jwt.RegisteredClaims
}

func main() {
	key := "It's your secret key"

	claims := SimpleClaims{
		Name: "andy",
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    "Auth_Server",                                 // 签发者
			Subject:   "AndyUserID",                                  // 签发对象
			Audience:  jwt.ClaimStrings{"Android_APP", "IOS_APP"},    // 签发受众
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), // 过期时间
			NotBefore: jwt.NewNumericDate(time.Now()),                // 最早使用时间
			IssuedAt:  jwt.NewNumericDate(time.Now()),                // 签发时间
			ID:        "SimpleRandomID",                              // JWT ID 类似于盐值
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	// 对上述的Claim信息生成Token信息
	tokenString, err := token.SignedString([]byte(key))
	if err != nil {
		fmt.Printf("[ERROR] Sign the token failed, %v\n", err)
		return
	}
	fmt.Printf("[ INFO] Got signed token '%v'\n", tokenString)

	// 也可以通过 Parse() 函数解析
	newtoken, err := jwt.ParseWithClaims(tokenString, &SimpleClaims{},
		func(token *jwt.Token) (interface{}, error) {
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
			}
			return []byte(key), nil
		})

	// 同时会检查是否有效
	if claims, ok := newtoken.Claims.(*SimpleClaims); ok && newtoken.Valid {
		fmt.Printf("[ INFO] Got claims '%v'\n", claims)
	} else {
		fmt.Printf("[ERROR] Invalid claims '%v'\n", err)
	}
}

PyJWT

PyJWT 是一个用来编码和解码 JSON Web Tokens, JWT 的 Python 库,可以通过 pip install pyjwt 命令安装。

生成 Token

需要使用 PyJWT 的 encode() 方法,需要传入三个参数:

jwt.encode(payload, config.SECRET_KEY, algorithm='HS256')

上面代码的方法中传入了三个参数:A)payload 认证依据的主要信息;B) 密钥,这里是读取配置文件中的SECRET_KEY配置变量;C) 生成 Token 的算法。

注意,payload 是认证的依据,也是后续解析 token 后定位用户的依据,需要包含特定用户的特定信息,例如可以记录用户 ID 和登陆时间,其中 pyjwt 内置了几个声明:

exp: 过期时间
nbf: 表示当前时间在nbf里的时间之前,则Token不被接受
iss: token签发者
aud: 接收者
iat: 发行时间