什么是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并返回用户名字。我们可以通过下面步骤验证。
- 生成非对称的密钥对(如果已经有密钥,可以跳过)
$ 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
- 拷贝上述代码到本地,运行。
$ 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
- 现在访问
/welcomeAPI, 你会得到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
- 现在通过
/tokenAPI登录,默认会返回使用密钥签名的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"}