如何在Gin框架中使用JWT实现认证机制

6,830 阅读8分钟

原创作者,公众号【程序员读书】,欢迎关注公众号,转载文章请注明出处哦。

什么是JWT

JWT是JSON Web Token的缩写,是一种跨域认证的解决方案。

使用JWT解决的问题

传统用户认证流程一般是这样的:

image.png

从上面的图中,我们可以看到,传统的登录认证的实现,依赖客户端浏览器的cookie和服务器的session,这种实现登录的方式有很大的局限性。

对于部署在单台服务器的应用来说,使用cookie+session登录认证的方案尚且可以接受。

但如果应用程序需要部署到多台服务器上呢?这里面就涉及到session的共享问题,另外,如果不同的域名想实现单点登录功能呢?显示cookie+session同样无法做到。

而要解决上面提出的问题,可以使用JWT,让应用变成无状态,避免session共享问题,而且可以很容易实现服务器的扩展。

JWT的格式

一个正确的JWT格式如下所示:

eyJhbGciOiJIUzI1NiIsInR5c.eyJ1c2VybmFtZaYjiJ9._eCVNYFYnMXwpgGX9Iu412EQSOFuEGl2c

我们看到一个JWT字符串由Header,Payload,Signature三个部分组成,中间使用逗号连接。

Header

Header是一个JSON对象,由token类型和加密算法两个部分组成的,如:

{
  "typ": "JWT",//默认为JWT
  "alg": "HS256"//支持多种加密算法
}

将上面的JSON对象使用Base64URL算法转换成字符串,即可得到JWT中的Header部分。

注意:JWT编码并不使用Base64,而Base64Url,这是因为Base64生成字符串里,可能会有+,/和=这三个URL中特殊的符号,而我们又可能将token放在URL上传递到服务器上(如test.com?token=xxx), 而Base64URL算法,则是在Base64算法生成的字符串基础上,将=省略,将+替换成-,将/替换成_。

Payload

JWT的Payload部分与Header一样,也是一个JSON对象,用来存放我们实际需要的数据,JWT标准提供了七个可选的字段,分别为:

标题描述
iss(issuer)签发者,其值为大小写敏感的字符串或Uri
sub(subject)主题,用于鉴别一个用户
exp(expiration time)过期时间
aud(audience)受众
iat(issued at)签发时间
nbf(not before)生效时间
jti(JWT ID)编号

除了标准的字段外,我们可以任意定义私有的字段以满足业务需求,如:

{
    iss:"my",//标准字段
    jti:"test",//标准字段
    username:"aaa",//自定义字段
    "gender":"男",
    "avatar":"https://1.jpg"
}

将上面的JSON对象使用Base64URL算法转换成字符串,即可得到JWT中的Payload部分。

Signature

Signature是JWT的签名,生成方式为:将Header与Payload进行Base64URL算法编码后,用逗号链接,再使用密钥(secretKey)和Header中指的加密方式进行加密,最终生成Signature。

JWT的特点

  1. 最好使用HTTPS协议,防止JWT被盗的可能。
  2. 除了JWT签发时间到期外,没有其他办法让已经生成的JWT失效,除非服务器端换算法。
  3. 在JWT不加密的情况下,JWT不应该存储敏感的信息,如果要存放敏感信息,最好再次加密。
  4. JWT最好设置较短的过期时间,防止被盗用后一直有效,降低损失。
  5. JWT的Payload也可以存储一些业务信息,这样可以减少数据库的查询。

JWT的使用

服务器签发JWT后,发送给客户端,客户端如果是浏览器的话,可以将其存放在cookie或localStorage中,如果是APP的话,则可以存放在sqlite数据库中。

然后每一次接口请求时都带上JWT,而带上来给服务端的方式,也有很多种,比如query、cookie、header或者body,总之就是一切可以带上数据给服务器的方式都可以,但比较规范的做还是通过header Authorization上传,格式如下:

Authorization: Bearer <token>

在Go项目中使用JWT

接下来我们介绍一下在Go项目中如何生成以及解析JWT,这里我们使用 github.com/golang-jwt/jwt这个库来帮我们生成或解析JWT。

生成

使用NewWithClaims()方法生成Token对象,再通过Token对象的方法来生成JWT字符串,如:

package main

import (
    "fmt"
    "time"

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

func main() {
    hmacSampleSecret := []byte("111")//密钥,不能泄露
    //生成token对象
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "foo": "bar",
	"nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(),
    })
    //生成jwt字符串
    tokenString, err := token.SignedString(hmacSampleSecret)
    fmt.Println(tokenString, err)
}

也可使用New()方法生成Token对象,再生成JWT字符串,如:

package main

import (
    "fmt"
    "time"

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

func main() {
    hmacSampleSecret := []byte("111")
    token := jwt.New(jwt.SigningMethodHS256)
    //通过New方法不能在创建的时候携带数据,因此可以通过给token.Claims赋值来定义数据
    token.Claims = jwt.MapClaims{
	"foo": "bar",
	"nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(),
    }
    tokenString, err := token.SignedString(hmacSampleSecret)
    fmt.Println(tokenString, err)
}

上面的例子中,是通过jwt.MapClaims这个数据结构定义JWT中的Payload数据的,除了使用jwt.MapClaims外,我们也可以使用自定义的结构,不过该结构必须实现下面的接口:

type Claims interface {
    Valid() error
}

下面是一个实现自定义数据结构的示例:

package main

import (
	"fmt"
	"github.com/golang-jwt/jwt"
)

type CustomerClaims struct {
    Username string `json:"username"`
    Gender   string `json:"gender"`
    Avatar   string `json:"avatar"`
    Email    string `json:"email"`
}

func (c CustomerClaims) Valid() error {
return nil
}

func main() {
    //密钥
    hmacSampleSecret := []byte("111")
    token := jwt.New(jwt.SigningMethodHS256)
    token.Claims = CustomerClaims{
        Username: "小明",
	Gender:   "男",
	Avatar:   "https://1.jpg",
	Email:    "test@163.com",
    }
    tokenString, err := token.SignedString(hmacSampleSecret)
    fmt.Println(tokenString, err)
}

如果我们想在自定义结构中使用JWT标准中定义的字段,可以这样子:

type CustomerClaims struct {
    *jwt.StandardClaims//标准字段
    Username string `json:"username"`
    Gender   string `json:"gender"`
    Avatar   string `json:"avatar"`
    Email    string `json:"email"`
}

解析

解析是生成反向操作,我们通过解析一个token来获取其中的Header,Payload,并通过Signature校验数据是否被窜改,下面是具体的实现:

package main

import (
	"fmt"

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

type CustomerClaims struct {
	Username string `json:"username"`
	Gender   string `json:"gender"`
	Avatar   string `json:"avatar"`
	Email    string `json:"email"`
	jwt.StandardClaims
}

func main() {
	var hmacSampleSecret = []byte("111")
        //前面例子生成的token
	tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IuWwj-aYjiIsImdlbmRlciI6IueUtyIsImF2YXRhciI6Imh0dHBzOi8vMS5qcGciLCJlbWFpbCI6InRlc3RAMTYzLmNvbSJ9.mJlWv5lblREwgnP6wWg-P75VC1FqQTs8iOdOzX6Efqk"

	token, err := jwt.ParseWithClaims(tokenString, &CustomerClaims{}, func(t *jwt.Token) (interface{}, error) {
		return hmacSampleSecret, nil
	})

	if err != nil {
		fmt.Println(err)
		return
	}
	claims := token.Claims.(*CustomerClaims)
	fmt.Println(claims)
}

在Gin项目中使用JWT

通过上面的例子,再结合Gin框架,其实我们完全可以自己实现在Gin使用JWT的需求,但为了不重复造轮子,我们可以直接使用别人造好的轮子。

在Gin框架中,登录认证一般通过中间件来实现,而github.com/appleboy/gin-jwt这个库中已经集成github.com/golang-jwt/jwt的实现,并帮我们定义了对应的中间件和控制器。

下面是一个具体的例子

package main

import (
    "log"
    "net/http"
    "time"

    jwt "github.com/appleboy/gin-jwt/v2"
    "github.com/gin-gonic/gin"
)

//用于接受登录的用户名与密码
type login struct {
    Username string `form:"username" json:"username" binding:"required"`
    Password string `form:"password" json:"password" binding:"required"`
}

var identityKey = "id"

//jwt中payload的数据
type User struct {
    UserName  string
    FirstName string
    LastName  string
}

func main() {

    // 定义一个Gin的中间件
    authMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{
	Realm:            "test zone",          //标识
	SigningAlgorithm: "HS256",              //加密算法
	Key:              []byte("secret key"), //密钥
	Timeout:          time.Hour,
	MaxRefresh:       time.Hour,   //刷新最大延长时间
	IdentityKey:      identityKey, //指定cookie的id
	PayloadFunc: func(data interface{}) jwt.MapClaims { //负载,这里可以定义返回jwt中的payload数据
            if v, ok := data.(*User); ok {
		return jwt.MapClaims{
                    identityKey: v.UserName,
		}
            }
            return jwt.MapClaims{}
	},
	IdentityHandler: func(c *gin.Context) interface{} {
            claims := jwt.ExtractClaims(c)
            return &User{
                UserName: claims[identityKey].(string),
            }
	},
	Authenticator: Authenticator, //在这里可以写我们的登录验证逻辑
	Authorizator: func(data interface{}, c *gin.Context) bool { //当用户通过token请求受限接口时,会经过这段逻辑
            if v, ok := data.(*User); ok && v.UserName == "admin" {
		return true
            }

            return false
	},
	Unauthorized: func(c *gin.Context, code int, message string) { //错误时响应
		c.JSON(code, gin.H{
                    "code":    code,
                    "message": message,
		})
	},
		// 指定从哪里获取token 其格式为:"<source>:<name>" 如有多个,用逗号隔开
	TokenLookup:   "header: Authorization, query: token, cookie: jwt",
	TokenHeadName: "Bearer",
	TimeFunc:      time.Now,
})

    if err != nil {
        log.Fatal("JWT Error:" + err.Error())
    }
    r := gin.Default()
    //登录接口
    r.POST("/login", authMiddleware.LoginHandler)
    auth := r.Group("/auth")
    //退出登录
    auth.POST("/logout", authMiddleware.LogoutHandler)
    // 刷新token,延长token的有效期
    auth.POST("/refresh_token", authMiddleware.RefreshHandler)
    auth.Use(authMiddleware.MiddlewareFunc()) //应用中间件
    {
        auth.GET("/hello", helloHandler)
    }

    if err := http.ListenAndServe(":8005", r); err != nil {
	log.Fatal(err)
    }
}

func Authenticator(c *gin.Context) (interface{}, error) {
	var loginVals login
	if err := c.ShouldBind(&loginVals); err != nil {
		return "", jwt.ErrMissingLoginValues
	}
	userID := loginVals.Username
	password := loginVals.Password

	if (userID == "admin" && password == "admin") || (userID == "test" && password == "test") {
		return &User{
			UserName:  userID,
			LastName:  "Bo-Yi",
			FirstName: "Wu",
		}, nil
	}

	return nil, jwt.ErrFailedAuthentication
}

//处理/hellow路由的控制器
func helloHandler(c *gin.Context) {
	claims := jwt.ExtractClaims(c)
	user, _ := c.Get(identityKey)
	c.JSON(200, gin.H{
		"userID":   claims[identityKey],
		"userName": user.(*User).UserName,
		"text":     "Hello World.",
	})
}

将服务器运行起来后,通过curl命令发起登录请求,如:

curl http://localhost:8005/login -d "username=admin&password=admin"

响应结果,返回token,如:

{"code":200,"expire":"2021-12-16T17:33:39+08:00","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Mzk2NDcyMTksImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTYzOTY0MzYxOX0.HITgUPDqli-RrO2zN_PfS4mISWc6l6eA_v8VOjlPonI"}

请求需要token才能访问的接口:

  1. 未带token访问时
curl http://localhost:8005/auth/hello

响应结果,如:

{"code":401,"message":"cookie token is empty"}
  1. 带上token访问时
# 为了方便,先将上面获取的token设置为环境变量
export TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Mzk2NDcyMTksImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTYzOTY0MzYxOX0.HITgUPDqli-RrO2zN_PfS4mISWc6l6eA_v8VOjlPonI

curl -H"Authorization: Bearer ${TOKEN}" http://localhost:8005/auth/hello

响应结果,如:

{"text":"Hello World.","userID":"admin","userName":"admin"}

总结

希望上面的讲解与例子,能帮助你学习或加深对JWT的理解以及在Go项目中的使用。

当然,除了上面介绍的两个库可以帮我们实现JWT的生成解析外,还有很多的其他的库也能帮我们做到,如果你碰到更好的实现JWT生成与解析的库,欢迎在评论中留言分享!

参考文章

在gin框架中如何使用JWT?

JSON Web Token 入门教程

认证机制:应用程序如何进行访问认证?