如何在Gin框架通过RS256签名算法保护你的jwt

215 阅读5分钟

什么是JWT token

JSON Web Token (JWT) 是一个遵循 (RFC 7519) 标准定义一种紧凑且安全的,基于 JSON 的对象在各方之间安全地传输信息。详细内容可以参考这篇文章: jwt.io/introductio…

验证实体信息在数字签名后可被验证和信任。JWTs可以通过密文(通过HMAC算法) ,或者公私秘钥对来签发(通过RSA或者ECDSA算法)。通过秘钥签名,接收方可以验证token是由可信任的验证方提供。

虽然JWTs可被加密,从而为客户方和服务商提供安全的密文。但JWT token的用处更多是作为被签名的tokens,这种token通常用作验证内部信息的完整性,而不是隐藏其中的隐私信息。当tokens被公私钥签名后,私钥拥有者的签名只可能被公钥的拥有者验证,从而保证签名方是信任实体。

JWT包含下面三部分,并由(.)分隔:

  • Header
  • Payload
  • Signature

签名算法在报头。下面例子就是使用HMAC SHA256算法签名的token格式。

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

为什么不使用HS256

首先,签发JWT token并不影响header和payload。签名仅仅用于验证上述数据未被修改,而数据本身是base64编码格式,任何人均可解码并看到其中内容。所以,我们不该将任何隐私信息如密码、邮箱等放到JWT内部。签名只被用于验证JWT的完整性和签署方是可信任第三方。

RS256和HS256都是常用的签名算法。

HS256 签名算法

HS256 (HMAC with SHA-256) 是一个对称密钥的散列算法,生成签名和验证签名都用同一个密文。

RS256 签名算法

RS256 (RSA Signature with SHA-256) 是一个使用非对称密钥的算法。实体提供者使用私钥生成签名,而接受者使用收到的公钥验证JWT的签名。所以公钥的拥有着无法篡改或者生成实体,而私钥的拥有这无法验证签名。

详细内容可以参考这些文章:

既然HS256和RS256都能验证JWT的完整性,为什么我会推荐使用RS256?

  • HS256使用对称密文,所以提供方需要将密文发布给所有验证方。
  • 使用同一密文签发和验证,代表若任一客户端有暴露风险,黑客可以用暴露后的密文篡改或者自行签发JWT。
  • 即使HS256的性能更优,但RS256可以仅通过发布公钥给客户端来验证密钥完整性,获得公钥无法篡改或者签发JWT,从而避免第三方伪造签名。

怎么使用RS256签发JWT

我们刚刚讨论了RS256和HS256,那么如果你想用RS256来签发JWT,应该怎么做?

那么我们来看看demo代码如果在Gin框架通过中间件签发JWT,并且验证。

main.go

package main

import (
	ginzap "github.com/gin-contrib/zap"
	"github.com/gin-gonic/gin"
	"github.com/gin-gonic/gin/binding"

	"fmt"
	"go.uber.org/zap"
	"net/http"
	"time"
)

type LoginUserData struct {
	Username string `form:"username" json:"username" xml:"username" bson:"username" binding:"required"`
	Password string `form:"password" json:"password" xml:"password" bson:"password" binding:"required"`
}

func main() {
	logger, _ := zap.NewProduction()
	defer logger.Sync()
	router := gin.Default()

	// Add a ginzap middleware
	router.Use(ginzap.Ginzap(logger, time.RFC3339, false))
	// Logs all panic to error log
	//   - stack means whether output the stack info.
	router.Use(ginzap.RecoveryWithZap(logger, true))
	// Use middleware to generate JWTs token
	router.Use(GenerateToken())
	router.POST("/token", func(c *gin.Context) {
		var loginUserData LoginUserData
		// Parse JSON
		err := c.ShouldBindBodyWith(&loginUserData, binding.JSON)
		if err != nil {
			c.Error(err)
			return
		}
		// Remove password from auth info
		loginUserData.Password = ""
		c.Set("subject-id", loginUserData.Username)
		c.Set("auth-info", loginUserData)
		return
	})
	// Use middleware to authenticate JWTs token
	router.Use(Authentication())
	router.GET("/welcome", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": fmt.Sprintf("welcome %s", c.GetString("subject-id")),
		})
	})

	router.Run()
}

auth.go

package main

import (
	"fmt"
	"os"
	"strings"
	"time"

	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"
	"io/ioutil"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
)

type AuthClaim struct {
	AuthInfo interface{} `json:"auth_info,emitempty"`
	jwt.RegisteredClaims
}

func LoadRSAPrivateKeyFromFile(path string) (*rsa.PrivateKey, error) {
	var privateKey *rsa.PrivateKey
	var err error
	_, err = os.Stat(path)
	if err == nil {
		privateKeyFile, err := ioutil.ReadFile(path)
		if err == nil {
			privateKeyBlock, _ := pem.Decode(privateKeyFile)
			privateKey, err = x509.ParsePKCS1PrivateKey(privateKeyBlock.Bytes)
		}
	}
	return privateKey, err
}

func LoadRSAPublicKeyFromFile(path string) (*rsa.PublicKey, error) {
	var publicKey *rsa.PublicKey
	var err error
	_, err = os.Stat(path)
	if err == nil {
		publicKeyFile, err := ioutil.ReadFile(path)
		if err == nil {
			publicKeyBlock, _ := pem.Decode(publicKeyFile)
			publicKey, err = x509.ParsePKCS1PublicKey(publicKeyBlock.Bytes)
			if err != nil && err.Error() == "x509: failed to parse public key (use ParsePKCS1PublicKey instead for this key format)" {
				t, err := x509.ParsePKIXPublicKey(publicKeyBlock.Bytes)
				if err == nil {
					publicKey = t.(*rsa.PublicKey)
				}
			}
		}
	}
	return publicKey, err
}

func NewRandomUUID() (string, error) {
	var uuidv4 string
	id, err := uuid.NewRandom()
	if err == nil {
		uuidv4 = id.String()
	}
	return uuidv4, err
}

func generateToken(subId string, authInfo interface{}) (string, error) {
	issuer := "auth.randomcloud123.com"
	audience := "sts.randomcloud123.com"
	uuid, err := NewRandomUUID()
	if err != nil {
		return "", err
	}
	claims := &AuthClaim{
		AuthInfo: authInfo,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			NotBefore: jwt.NewNumericDate(time.Now()),
			Issuer:    issuer,
			Subject:   subId,
			ID:        uuid,
			Audience:  []string{audience},
		},
	}

	var signKey interface{}
	// Use RS256 algorithm
	signMethod := jwt.SigningMethodRS256
	keyPath := "/opt/fizz/private_key.pem"
	signKey, err = LoadRSAPrivateKeyFromFile(keyPath)
	if err != nil {
		return "", err
	}
	// Create a new token object, specifying signing method and the claims
	token := jwt.NewWithClaims(signMethod, claims)
	// Sign and get the complete encoded token as a string using the secret
	tokenString, err := token.SignedString(signKey)
	if err != nil {
		return "", err
	}
	return tokenString, nil
}

func parseToken(tokenString string) (*AuthClaim, error) {
	token, err := jwt.ParseWithClaims(tokenString, &AuthClaim{}, func(token *jwt.Token) (interface{}, error) {
		var parseKey interface{}
		var err error
		keyPath := "/opt/fizz/public_key.pem"
		if _, ok := token.Method.(*jwt.SigningMethodRSA); ok {
			parseKey, err = LoadRSAPublicKeyFromFile(keyPath)
		} else {
			err = fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
		}
		if err != nil {
			return nil, err
		}
		return parseKey, nil
	})

	if err == nil {
		if claims, ok := token.Claims.(*AuthClaim); ok && token.Valid {
			return claims, nil
		}
	}

	return nil, err
}

func Authentication() gin.HandlerFunc {
	return func(c *gin.Context) {
		// Retrieve the JWT token from header or cookie
		auth := c.Request.Header.Get("Authorization")
		if len(auth) == 0 {
			cookie, err := c.Cookie("auth-jwt")
			if err != nil {
				c.AbortWithError(401, err)
				return
			}
			auth = cookie
		} else {
			auth = strings.Fields(auth)[1]
		}
		claim, err := parseToken(auth)
		if err != nil {
			c.AbortWithError(401, err)
			return
		}
		// Have your validation logic here

		// Save subject info to the context
		c.Set("subject-id", claim.RegisteredClaims.Subject)
		c.Next()
	}
}

func GenerateToken() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Next()
		if len(c.Errors) == 0 {
			// If any auth info in context, generate JWT token
			if authInfo, ok := c.Get("auth-info"); ok {
				subId := c.GetString("subject-id")
				token, err := generateToken(subId, authInfo)
				if err == nil {
					// Store jwt token in cookie
					c.SetCookie("auth-jwt", token, 7200, "/", "localhost", false, true)
					return
				}
				c.AbortWithError(500, err)
			}
			c.AbortWithError(500, fmt.Errorf("empty auth info"))
		}
	}
}

上述代码会运行一个简单的Web程序,通过/token这个api接受用户信息,并且签发对应的JWT,而用户可以使用方才签发的JWT访问/welcome API并返回用户名字。我们可以通过下面步骤验证。

  1. 生成非对称的密钥对(如果已经有密钥,可以跳过)
$ openssl genrsa -out /opt/fizz/private_key.pem 2048
$ openssl rsa -in /opt/fizz/private_key.pem -pubout -out /opt/fizz/public_key.pem -RSAPublicKey_out
  1. 拷贝上述代码到本地,运行。
$ go mod init main
$ go mod tidy
$ go run main.go auth.go 
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /token                    --> main.main.func1 (6 handlers)
[GIN-debug] GET    /welcome                  --> main.main.func2 (7 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080
  1. 现在访问 /welcome API, 你会得到401的返回码。
$ curl http://127.0.0.1:8080/welcome -v
> GET http://127.0.0.1:8080/welcome HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.78.0
> Accept: */*
> Proxy-Connection: Keep-Alive
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 Unauthorized
< Connection: keep-alive
< Date: Sun, 08 Oct 2023 07:49:38 GMT
< Keep-Alive: timeout=4
< Proxy-Connection: keep-alive
< Content-Length: 0
< 
* Connection #0 to host 127.0.0.1 left intact
  1. 现在通过/token API登录,默认会返回使用密钥签名的JWT。由于代码仅为demo使用,我并未加入鉴权或者用户校验逻辑。
$ curl -X POST -d "{"username": "testuser", "password": "testpassword"}" http://127.0.0.1:8080/token
{"token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoX2luZm8iOnsidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInBhc3N3b3JkIjoidGVzdHBhc3N3b3JkIn0sImlzcyI6ImF1dGgucmFuZG9tY2xvdWQxMjMuY29tIiwic3ViIjoidGVzdHVzZXIiLCJhdWQiOlsic3RzLnJhbmRvbWNsb3VkMTIzLmNvbSJdLCJleHAiOjE2OTY3NTg4NDYsIm5iZiI6MTY5Njc1MTY0NiwiaWF0IjoxNjk2NzUxNjQ2LCJqdGkiOiJkMmY2MGEwMS00NjY1LTQzOTktYTQxNi1iYzBmZTg5YTg3YmQifQ.RT5GnrnckJT0EGLD_eJv7GQs6_otRPKg3llsQSTmifcOgNW0ZoFktKV-u1224A0sOaoD9JXu5gbSXVq7eVSIZCcu1hXy4ZpmaTefATDUOIlUwk0UWbSAvZoSf8_DFpziBBvrD5ehIzFWfZ_gq89gG8GMG9K_5Lw0cGyw_sFQ3VsY9sImlhMVKmuiqjDTwuoQ3JGRXTSY5UOaU4K7nXSFzUG-kfMjROge09yGuqKPlBnoI2rWgXwXHAShgpXKXWjlDHFP9-V1F2ueCqQTTiRm7zy4dLNsreTgRADFCn_JHLncY9Ye8kC1WQHggVrBu_T1L_7hbrC-M-z4DCH3J6XwdQ"}

你可以在jwt.io 这个网站去查看JWT的结构。

5.你现在可以使用JWT访问 /welcome API.

 $ curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoX2luZm8iOnsidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInBhc3N3b3JkIjoidGVzdHBhc3N3b3JkIn0sImlzcyI6ImF1dGgucmFuZG9tY2xvdWQxMjMuY29tIiwic3ViIjoidGVzdHVzZXIiLCJhdWQiOlsic3RzLnJhbmRvbWNsb3VkMTIzLmNvbSJdLCJleHAiOjE2OTY3NTg4NDYsIm5iZiI6MTY5Njc1MTY0NiwiaWF0IjoxNjk2NzUxNjQ2LCJqdGkiOiJkMmY2MGEwMS00NjY1LTQzOTktYTQxNi1iYzBmZTg5YTg3YmQifQ.RT5GnrnckJT0EGLD_eJv7GQs6_otRPKg3llsQSTmifcOgNW0ZoFktKV-u1224A0sOaoD9JXu5gbSXVq7eVSIZCcu1hXy4ZpmaTefATDUOIlUwk0UWbSAvZoSf8_DFpziBBvrD5ehIzFWfZ_gq89gG8GMG9K_5Lw0cGyw_sFQ3VsY9sImlhMVKmuiqjDTwuoQ3JGRXTSY5UOaU4K7nXSFzUG-kfMjROge09yGuqKPlBnoI2rWgXwXHAShgpXKXWjlDHFP9-V1F2ueCqQTTiRm7zy4dLNsreTgRADFCn_JHLncY9Ye8kC1WQHggVrBu_T1L_7hbrC-M-z4DCH3J6XwdQ" http://127.0.0.1:8080/welcome
{"message":"welcome testuser"}