基于一个Go-backend开源项目目录梳理一下JWT认证
👽

基于一个Go-backend开源项目目录梳理一下JWT认证

Tags
Golang
Gin
JWT
Published
April 9, 2024
Author
zhichao
Language
Go
Source

目录结构大概是这样

ok,今天先只对其中的一个中间件,JWT身份认证鉴权进行一个梳理,之前这块大概知道是个啥,可一直没有合适的实践场景代码供参考,今天正好梳理梳理加深加深印象。
notion image
这个项目使用的http框架是ginRESTful风格的API请求,也是比较熟悉了。数据库使用的是mongoDB,这个不是很熟悉,之前没使用过,裤裤一顿搞。

.env配置

他这里用了viper来更好的管理和引入配置,我的配置如下,一会jwt生成和解密会用到最后两个:
APP_ENV=development SERVER_ADDRESS=:8080 PORT=8080 CONTEXT_TIMEOUT=2 DB_HOST=127.0.0.1 DB_PORT=27017 DB_USER= DB_PASS= DB_NAME=go-backend-clean-architecture-db ACCESS_TOKEN_EXPIRY_HOUR = 2 REFRESH_TOKEN_EXPIRY_HOUR = 168 ACCESS_TOKEN_SECRET=access_token_secret REFRESH_TOKEN_SECRET=refresh_token_secret

直接看一下定义的路由。

notion image
func Setup(env *bootstrap.Env, timeout time.Duration, db mongo.Database, gin *gin.Engine) { publicRouter := gin.Group("") // All Public APIs NewSignupRouter(env, timeout, db, publicRouter) NewLoginRouter(env, timeout, db, publicRouter) NewRefreshTokenRouter(env, timeout, db, publicRouter) protectedRouter := gin.Group("") // Middleware to verify AccessToken protectedRouter.Use(middleware.JwtAuthMiddleware(env.AccessTokenSecret)) // All Private APIs NewProfileRouter(env, timeout, db, protectedRouter) NewTaskRouter(env, timeout, db, protectedRouter) }
各种路由详情在各自的_route.go中。
NewSignupRouter是注册路由,POST方式,”/signup
NewLoginRouter是登录路由,POST方式,”/login
NewRefreshTokenRouter是刷新token路由,POST方式,”/refresh
与上面3层不同,下面的两个属于是一个共同的新的RouterGroup,RouterGroup的路径可以一样,这样就方便添加JWT了,可以看到protectedRouter.Use(middleware.JwtAuthMiddleware(env.AccessTokenSecret))就是使用JWT中间件。
注册或登录或刷新会得到token,Profile和task需要token进行jwt校验。
NewProfileRouter是一个大概的用户信息路由,GET方式,”/profile
NewTaskRouter是任务路由,模拟某些业务,POST方式GET方式,对应create任务和fetch任务,”/task

看一下调接口需要传哪些参数,直接测试一下

调用signup

ok,直接万军丛中看到这两句代码:
notion image
看一看他定义的domain.SignRequest
notion image
OK,我们使用APIfox直接去调。
notion image
ok,没问题,还拿到了一个accessToken与一个refreshToken ,先保存起来。
我们的mongodb数据库中也有了数据,这里后端还对我们的password进行了一个不可逆加密,然后写入。
notion image

看看jwt的token怎么生成的

controller/signup_controller.go中有这么两句话:
accessToken, err := sc.SignupUsecase.CreateAccessToken(&user, sc.Env.AccessTokenSecret, sc.Env.AccessTokenExpiryHour) if err != nil { c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()}) return } refreshToken, err := sc.SignupUsecase.CreateRefreshToken(&user, sc.Env.RefreshTokenSecret, sc.Env.RefreshTokenExpiryHour) if err != nil { c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()}) return }
 
参数中首先看到sc.Env.AccessTokenSecretsc.Env.AccessTokenExpiryHour ,是在我们.env中定义的,也就是server端要加密糅合的一个这边的字符串,和一个过期时间。
ACCESS_TOKEN_EXPIRY_HOUR = 2 ACCESS_TOKEN_SECRET=access_token_secret
ok,接着我们到具体方法中看看,去其中一个就行,比如CreateAccessToken
type SignupUsecase interface { Create(c context.Context, user *User) error GetUserByEmail(c context.Context, email string) (User, error) CreateAccessToken(user *User, secret string, expiry int) (accessToken string, err error) CreateRefreshToken(user *User, secret string, expiry int) (refreshToken string, err error) }
func (su *signupUsecase) CreateAccessToken(user *domain.User, secret string, expiry int) (accessToken string, err error) { return tokenutil.CreateAccessToken(user, secret, expiry) }
好家伙,接着刨。最终在internal/tokenutil/tokenutil.go中找到。并且他这里使用了jwtv4 "github.com/golang-jwt/jwt/v4" ,然后是逻辑:
func CreateAccessToken(user *domain.User, secret string, expiry int) (accessToken string, err error) { exp := time.Now().Add(time.Hour * time.Duration(expiry)).Unix() claims := &domain.JwtCustomClaims{ Name: user.Name, ID: user.ID.Hex(), StandardClaims: jwt.StandardClaims{ ExpiresAt: exp, }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) t, err := token.SignedString([]byte(secret)) if err != nil { return "", err } return t, err }
1、exp := time.Now().Add(time.Hour * time.Duration(expiry)).Unix() :过期时间设置,得到一个UNIX时间戳,表示从1970年1月1日0点0分0秒开始的秒数。 2、自定义的JwtCustomClaims是这样的:
type JwtCustomClaims struct { Name string `json:"name"` ID string `json:"id"` jwt.StandardClaims //这个是jwtv4包中的 } /*jwtv4包中的StandardClaims type StandardClaims struct { Audience string `json:"aud,omitempty"` ExpiresAt int64 `json:"exp,omitempty"` Id string `json:"jti,omitempty"` IssuedAt int64 `json:"iat,omitempty"` Issuer string `json:"iss,omitempty"` NotBefore int64 `json:"nbf,omitempty"` Subject string `json:"sub,omitempty"` } */
可以看到,作者自定使用了NameIDExpiresAt 这个3属性准备生成一个token。
其中NameID是存在自定义的domain.User中的,在signup成功后就已经确定下来的:
user := domain.User{ ID: primitive.NewObjectID(), //mongodb自动创建的一个ID Name: request.Name, Email: request.Email, Password: request.Password, }
 
3、
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) ,将加密方法,和自定义的claims传入jwtv4NewWithClaims方法去返回一个token类型。
// NewWithClaims creates a new Token with the specified signing method and claims. func NewWithClaims(method SigningMethod, claims Claims) *Token { return &Token{ Header: map[string]interface{}{ "typ": "JWT", "alg": method.Alg(), }, Claims: claims, Method: method,//这个method其实实现了SigningMethod的三个方法 } }
//jwtv4token定义的字段属性 type Token struct { Raw string // The raw token. Populated when you Parse a token Method SigningMethod // The signing method used or to be used Header map[string]interface{} // The first segment of the token Claims Claims // The second segment of the token Signature string // The third segment of the token. Populated when you Parse a token Valid bool // Is the token valid? Populated when you Parse/Verify a token }
4、 t,err := token.SignedString([]byte(secret))进一步使用server端的一个字符串进行签名,并返回一个我们最终看到的字符串。 是一个xxxx.xxxx.xxxx类型的,分别表示headerclaims,server的signature加密字符串形式。
需要注意的的是,传入的method是属于一个固定类型的SigningMethod ,它是有三个方法的。
type SigningMethod interface { Verify(signingString, signature string, key interface{}) error // Returns nil if signature is valid Sign(signingString string, key interface{}) (string, error) // Returns encoded signature or error Alg() string // returns the alg identifier for this method (example: 'HS256') }
欧克,至此就是生成过程了。

调用profile

这里就需要我们刚才的accessTokenrefreshToken
来看一下项目中,对应代码的逻辑,看看我们怎么调用
/middleware/jwt_auth_middleware.go中间件大致代码如下
func JwtAuthMiddleware(secret string) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.Request.Header.Get("Authorization") t := strings.Split(authHeader, " ") if len(t) == 2 { authToken := t[1] authorized, err := tokenutil.IsAuthorized(authToken, secret) if authorized { //true or false userID, err := tokenutil.ExtractIDFromToken(authToken, secret) if err != nil { c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Message: err.Error()}) c.Abort() return } c.Set("x-user-id", userID) c.Next() return } c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Message: err.Error()}) c.Abort() return } c.JSON(http.StatusUnauthorized, domain.ErrorResponse{Message: "Not authorized"}) c.Abort() } }
ok,就是Header里的AuthorizationaccessTokenrefreshToken ,而且还得用空格隔开,acccesstoken在后,这里是感觉设计的不太合理的地方,不知道的人想破脑袋也不会知道要这样传参调接口。
notion image
ok,我们通过jwt的token校验后,成功从这个接口中获取到了我们刚才的用户信息。

看看jwt怎么解密的

func IsAuthorized(requestToken string, secret string) (bool, error) { _, err := jwt.Parse(requestToken, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) } //做回调函数返回值,[]byte(secret)=[97 99 99 101 115 115 95 116 111 107 101 110 95 115 101 99 114 101 116]=[ACCESS_TOKEN_SECRET] return []byte(secret), nil //但是,_可是正儿八经的token,有6个属性 }) if err != nil { return false, err } return true, nil } func ExtractIDFromToken(requestToken string, secret string) (string, error) { token, err := jwt.Parse(requestToken, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) } //做回调函数返回值,[]byte(secret)=[97 99 99 101 115 115 95 116 111 107 101 110 95 115 101 99 114 101 116]=[ACCESS_TOKEN_SECRET] return []byte(secret), nil }) //但是,token可是正儿八经的token,有6个属性 fmt.Println(token) if err != nil { return "", err } claims, ok := token.Claims.(jwt.MapClaims) if !ok && !token.Valid { return "", fmt.Errorf("Invalid Token") } return claims["id"].(string), nil }
token一共有六个属性,像前文中提到的:
//jwtv4token定义的字段属性 type Token struct { Raw string // The raw token. Populated when you Parse a token Method SigningMethod // The signing method used or to be used Header map[string]interface{} // The first segment of the token Claims Claims // The second segment of the token Signature string // The third segment of the token. Populated when you Parse a token Valid bool // Is the token valid? Populated when you Parse/Verify a token }
在生成阶段,会使用3个token字段,也就是methodheaderclaims
在解密阶段,token的六个属性,会返回一个有6个属性的token,多了Raw,signature,Valid,正如jwtv4注释中说的,当parse一个token时,才会populated(生成)。
ok,解密时,我们打印出来看看,这个token长啥样:
&{eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiemhpY2hhbyIsImlkIjoiNjYxNGE5NjY2N2IyOTJhMTNmYTc1M2ZkIiwiZXhwIjoxNzEyNjM3MzE4fQ.90O33xl2GJ2183WB2Apv8RUR_hOF35sWf-4g8ihRbJ8 0xc0000086c0 map[alg:HS256 t yp:JWT] map[exp:1.712637318e+09 id:6614a96667b292a13fa753fd name:zhichao] 90O33xl2GJ2183WB2Apv8RUR_hOF35sWf-4g8ihRbJ8 true}
可以看到:
原始(Raw)token
method的地址
Header 中存加密方法和type:jwt
clamis有我们生成阶段自定义存的name,id,过期时间
signature,.env中我们自定义的,通过加密之后展示的server端签名字符串,可以看到,与原始token的第三段是一样的。
Valid是否是有效token

之后的事

之后就是返回个id,然后c.Set("x-user-id", userID)
profile_controller中:userID := c.GetString("x-user-id")
然后根据取到的id去数据库中查,找到我们的相应的用户信息,并最终返回给我们了。为什么不直接把所有信息都存token里呢,还要再查一次?我觉得是更加安全吧,总不能把所有信息,甚至敏感信息都村里面吧,存个除了查询别无他用的id更加安全(当然这个作者还存了name),另一方面可能是作者想展示一下demo,让我们充分自由发挥自己的逻辑。