Contents

如何使用gin-jwt实现用户登录模块

Jwt简介

Jwt就是Json Web Token,它是RFC 7519定义的一种令牌结构,一般用于服务端在客户端认证成功后返回给客户端保存,假如客户端后续在http请求头带上这个令牌,那么就可以证明它的身份,实现免登录。

一个例子:

Jwt本质上是一个由三段数据用.拼接而成的字符串,其中前二段是json字符串用base64编码后得到的字符串,而第三段则是前两段base64编码并拼接后用第一段声明的加密方法(如hs256),用密钥加密后的签名。接下来讲一下前两段的具体内容:

第一段Header主要用来声明token是jwt以及加密的算法:

1
2
3
4
{
	"alg": "HS256",
	"typ": "JWT"
}

第二段Payload则是具体传递的令牌内容,如用户id,到期时间等:

1
2
3
4
{
  "loggedInAs": "admin",
  "iat": 1422779638
}

在客户端登录的时候,客户端校验用户名和密码,然后生成对应的jwt返回给客户端,客户端存起来,在后续的请求中都在请求头带上这个token来证明自己的身份。

而服务端在收到jwt后,用自己的密钥重新计算jwt前两段,看得出的是否与第三段相等,假如是,则说明对方的jwt确实是自己签发的,于是信任请求的来源,这便是jwt的具体验证过程。

接下来我们来看看如何在gin框架中实现jwt用户登录。

创建并加载gin-jwt中间件

在开发基于gin框架的web app时,我们可以使用gin-jwt来实现登录认证,下面记录一下如何使用这个中间件。

在gin框架中加载jwt中间件之前,我们需要创建一个GinJWTMiddleware 的结构体,并定义好jwt认证相关选项,这是结构体中比较重要的字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type GinJWTMiddleware struct {
	Realm string // jwt标识
	Key []byte  // 加密密钥 []byte("my secret key")
	Timeout time.Duration // 有效时间
	MaxRefresh time.Duration // 可以刷新token的最大时间区间
	IdentityKey string // payload中的用户标识
	Authenticator func(c *gin.Context) (interface{}, error)
	Authorizator func(data interface{}, c *gin.Context) bool
	PayloadFunc func(data interface{}) MapClaims
	IdentityHandler func(c *gin.Context) interface{}
	...  
}

剩余未注释的字段我们结合下面流程部分讲,先看看这个结构体怎么和gin一起使用。实际上很简单,先用New函数创建结构体,传入也是这个结构体,不过New函数会帮忙进行校验,我们自己包装一个自己的New函数,使用闭包来把用户数据层动态传入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import jwt "github.com/appleboy/gin-jwt/v2"

...

func New(userRepo repo.UserRepo) *jwt.GinJWTMiddleware{
	var authMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{
		Realm:           "my project",
		Key:             []byte("my secret"),
		Timeout:         time.Hour,
		MaxRefresh:      time.Hour,
		IdentityKey:     IdentityKey,
		Authenticator:   userAuth(us),
		IdentityHandler: identityHandler,
		PayloadFunc:     payloadFunc,
	})
	if err != nil {
		return authMiddleware
	}
}

然后在gin加载路由的地方进行初始化并在需要用户先登录的接口启用即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func installController(g *gin.Engine) {
	...
	authMiddleware := New(userRepo)
	g.Post("/login", authMiddleware.LoginHandler) //启用结构体自带的login handler
	bookRoutes := g.Group("/books")
	{
		bookRoutes.Use(authMiddleware.MiddlewareFunc()) //调用MiddlewareFunc来生成gin中间件
		// ...后续其他接口
	}
}

登录流程

结构体中有两个字段在登录时会被调用:GinJWTMiddleware.AuthenticatorGinJWTMiddleware.PayloadFunc

登录的请求首先会进入Authenticator函数,这个函数负责从gin.Context中提取登录信息,并进行校验,最后返回用map或者struct封装的用户信息,这个数据会自动被作为参数传入PayloadFunc

这是一个Authenticator的例子,同样使用闭包来传入用户服务层,我们在创建结构体时调用userAuth(us)来生成具体的Authenticator函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type UserInfo struct {
	Username string
	Userid   int
	Role     string
}

func userAuth(us service.UserService) func(*gin.Context) (interface{}, error) {
	return func(c *gin.Context) (interface{}, error) {
		var l Login
		if err := c.ShouldBindJSON(&l); err != nil {
			return nil, jwt.ErrMissingLoginValues
		}
		u, err := us.Login(c, l.Username, l.Password)
		if err != nil {
			return nil, jwt.ErrFailedAuthentication
		}
		return UserInfo{
			u.Username,
			int(u.ID),
			u.Role
		}, nil
	}
}

用户信息登录成功后,在默认情况下,gin-jwt不会往jwt的payload中添加额外的信息,如userid等,而只会有token签发时间和过期时间:

如想要在payload中加入用户的唯一信息,让后续的请求可以携带,则需要定义PayloadFunc 函数,假如结构体中有这个函数,那么gin-jwt会在调用完Authenticator之后,把它的输出作为参数传入此函数,而此函数则负责创建想要附带进payload所需要的对象mapClaims,它是一个map[string]interface{},在生成token时会被自动填入payload中。

这是一个简单的PayloadFunc的例子:

1
2
3
4
5
6
7
8
func payloadFunc(data interface{}) jwt.MapClaims {
	if v, ok := data.(UserInfo); ok {
		return jwt.MapClaims{
			IdentityKey: v.Userid,
		}
	}
	return jwt.MapClaims{}
}

值得注意的是,mapClaims中应有一个与结构体的IdentityKey值一样的字段,里面存放用户的标识,这在后续的token验证中的默认情况下,也就是没有声明IdentityHandler,会被gin-jwt提取并存放到gin.Context中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// gin-jwt/auth_jwt.go

func (mw *GinJWTMiddleware) MiddlewareInit() error {
	...
	if mw.IdentityHandler == nil {
			mw.IdentityHandler = func(c *gin.Context) interface{} {
				claims := ExtractClaims(c)
				return claims[mw.IdentityKey]
			}
	}
	...
}

func (mw *GinJWTMiddleware) middlewareImpl(c *gin.Context) {
	...
	c.Set("JWT_PAYLOAD", claims)
	identity := mw.IdentityHandler(c)

	if identity != nil {
		c.Set(mw.IdentityKey, identity)
	}
	...
}

通过实现这两个函数,我们便可以验证用户的登录信息,并把对应的用户id嵌入返回的jwt中。接下来便是如何验证token,并提取用户id的部分。

token验证流程

在登录后的后续请求中,客户端应当在请求头附上jwt:

1
Authorization: Bearer ${jwt}

在gin-jwt中间件处理带有jwt的请求时,gin-jwt便会把jwt的信息写入gin.Context中,此时我们可以自定义IdentityHandler,从context中抽取之前写入jwt的完整信息,从上面的源码可知,默认情况下gin-jwt只会抽取IdentityKey字段进行返回。

在此函数返回后,gin-jwt会把返回值,也就是用户信息,作为参数传入Authorizor函数进行用户鉴权。在这个函数中,我们同样应使用闭包来动态传入权限数据层的对象并进行调用,道理同上,然后假如鉴权通过,则gin-jwt的中间件处理完成,请求发给下一个中间件或者接口函数接着处理。

由于Authorizor 是gin-jwt的最后一个函数,我们在这里最好把整个用户信息写入gin.Context,让后续需要用到用户信息的模块或函数不依赖gin-jwt,同时暴露一个get函数来获取这些信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func userAuthz(authRepo interface{}) func(interface{}, *gin.Context) bool {

	return func(data interface{}, ctx *gin.Context) bool {
		// do authorization here
		if !authRepo.Authz(data) {
			return false
		}
		// extract user info into context
		if v, ok := data.(map[string]interface{}); ok {
			ctx.Set(IdentityKey, v)
			return true
		}
		return false
	}
}

func GetUserInfo(ctx *gin.Context) *UserInfo {
	v, ok := ctx.Get(IdentityKey)
	if ok {
		return &UserInfo{
			Username: v["Username"],
			Userid:   v["Userid"],
			Role:     v["Role"],
		} 
	}
	return &UserInfo{}
}

总结

认证用户登录并签发jwt时:

  • Authenticator从ctx中获取账号密码认证,然后把用户信息传给PayloadFunc
  • PayloadFunc处理用户信息,生成mapClaims以嵌入签发的jwt

校验jwt时:

  • gin-jwt负责主要的校验过程
  • 可以自定义IdentityHandler 来获取jwt中嵌入的用户信息,gin-jwt会把返回值传给Authorizor
  • Authorizor 中进行用户的鉴权,在此函数中还可以进行对gin-jwt的解耦,把用户信息写入gin.Context