这是我参与「第三届青训营 -后端场」笔记创作活动的的第5篇笔记。
问题引入
本次抖音项目中,我们小组通过调研发现,在Go中通常使用 github.com/appleboy/gin-jwt/v2 来实现jwt认证.而且,在之前的课程中,老师也使用该框架实现了easy_note(简单的笔记服务).基于以上两点,我们小组决定使用该库来实现抖音项目中的用户鉴权.
关于gin-jwt的工作流程,这里不再赘述,可以看我的前一条笔记.
然而,在项目开发中,我们遇到了以下几个问题,而且该jwt框架没有开放接口给我们修改:
- 不知道是不是字节这边的老师们给我们挖的坑,想看看我们解决问题的能力,在上传视频这个接口中,前端app发送的请求竟然将token放在了postform中.而我们查看gin-jwt的源码,发现其中间件在解析token时不支持从postform中解析,如下图所示.其实这也正常,因为常规的做法是不会将token放在post的表单中的.
- gin-jwt的中间件,对于同一条路由请求,要么开启认证;要么不开启认证,此时如果用户传的token不对或者不传token就会直接返回错误.而我们拉取视频的路由需要同时支持登录用户和非登录用户的.如果我们将这条路由开启jwt认证,那么如果非登录用户拉取视频,前端app就不传token,此时就过不了jwt中间件对token的解析,直接就返回错误.
对于以上两个问题,我们小组也尝试过使用路由重定向的方法.比如问题1,我们先不开启认证,将postform中的token字段写到gin.context的header中,然后内部重定向到另一条开启认证的路由,从而进行后面的流程;比如问题2,也是采用类似的方法,先不开启认证,根据有无token决定是否重定向到开启认证的路由.然而,这个方案在实践时遇到了莫名奇妙的错误,由于时间有限我们就放弃了路由重定向这个技术路线.
现在我们只剩一个办法了,就是在gin-jwt的基础上,重写我们自己的jwt库.该库需要支持两个功能:
-
中间件支持从postform中获取并解析token
-
对于同一条路由,同时支持登录用户和非登录用户
开始重写gin-jwt
1. 将 gin-jwt 引入我们的项目
找到GOPATH下的gin-jwt库,发现其源文件结构如下:
发现其实真正有用的只有一个文件: auth_jwt.go.
因此,只需要将该文件添加到我们的项目中,并且修改项目中相关的import路径和go.mod即可.
2. 支持从postform中获取并解析token
在GinJWTMiddleware结构体中,有一个TokenLookup字段,如下图所示:
通过gin日志输出可以看到,在app上传视频时,在postform中有一个名为"token"的键,其value放了token的内容.
因此,我们可以在TokenLookup中加入这样一种策略: postform: token.
然后,修改ParseToken函数,增加该解析方法:
jwtFromPostForm函数主要就是调用c.PostForm方法,拿到token的值.如果token不存在,就返回token为空的错误,交由ParseToken函数处理:
最后,只需要在调用jwt.New函数定义中间件时,声明TokenLookup的值即可:
TokenLookup: "header: Authorization, query: token, cookie: jwt, postform: token", // 最后一个是我们新增的解析字段
3. 通过过滤URL实现同一条路由支持登录和非登录用户
首先,我们在GinJWTMiddleware结构体中增加一个字段,设置我们想要过滤的URL:
// 设置鉴权时可以跳过的URL
FilteredURL string
然后,我们修改middlewareImpl方法.主要思想是:
-
如果不是被过滤的URL,我们就按照原来默认的方式处理.
-
如果是被过滤的URL
-
如果有token,我们就按照原来的方式处理(非法token直接返回,合法token正常解析);
-
如果token字段为空,那么我们就不做token的检查,让他能够正常通过jwt的认证,只是由于token字段为空,我们的项目代码在后续处理时无法从token中获取用户信息罢了.
-
func (mw *GinJWTMiddleware) middlewareImpl(c *gin.Context) {
claims, err := mw.GetClaimsFromJWT(c)
if err != nil {
// 非空的token,可以认为攻击者在尝试攻击,或者是过期了,我们就直接返回错误
if err.Error() != "form post token is empty" {
mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c))
return
}
// 空的token,过滤掉不需要验证token的url
if c.Request.URL.Path != mw.FilteredURL {
mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(err, c))
return
}
}
if claims["exp"] == nil {
// 过滤掉不需要验证token的url
if c.Request.URL.Path != mw.FilteredURL {
mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrMissingExpField, c))
return
}
}
if _, ok := claims["exp"].(float64); !ok {
// 过滤掉不需要验证token的url
if c.Request.URL.Path != mw.FilteredURL {
mw.unauthorized(c, http.StatusBadRequest, mw.HTTPStatusMessageFunc(ErrWrongFormatOfExp, c))
return
}
}
if claims["exp"] != nil {
if int64(claims["exp"].(float64)) < mw.TimeFunc().Unix() {
fmt.Println(",,,,,,,,,claims[exp] < mw.TimeFunc().Unix()!")
// 如果在最上面解析时没过期,处理到这里过期了,这样就能正常返回token过期的错误
mw.unauthorized(c, http.StatusUnauthorized, mw.HTTPStatusMessageFunc(ErrExpiredToken, c))
return
}
}
c.Set("JWT_PAYLOAD", claims)
identity := mw.IdentityHandler(c)
if identity != nil {
c.Set(mw.IdentityKey, identity)
}
if !mw.Authorizator(identity, c) {
// 过滤掉不需要验证token的url
if c.Request.URL.Path != mw.FilteredURL {
mw.unauthorized(c, http.StatusForbidden, mw.HTTPStatusMessageFunc(ErrForbidden, c))
return
}
}
c.Next()
}
最后,只需要在调用jwt.New函数定义中间件时,声明TokenLookup的值即可:
FilteredURL: "/douyin/feed", // 设置你需要跳过认证的url,目前比较粗糙,只支持一条URL
目前FilteredURL的处理比较简单,只支持一条URL.后续可以按照框架处理TokenLookup的方式进行改进,用逗号间隔的方式写入多条路径,那样就可以支持多条URL.