首先给出这个开源go-backend项目目录的github地址
目录结构大概是这样
ok,今天先只对其中的一个中间件,JWT身份认证鉴权进行一个梳理,之前这块大概知道是个啥,可一直没有合适的实践场景代码供参考,今天正好梳理梳理加深加深印象。
这个项目使用的http框架是
gin,RESTful风格的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
直接看一下定义的路由。
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,直接万军丛中看到这两句代码:
看一看他定义的
domain.SignRequestOK,我们使用APIfox直接去调。
ok,没问题,还拿到了一个
accessToken与一个refreshToken ,先保存起来。我们的
mongodb数据库中也有了数据,这里后端还对我们的password进行了一个不可逆加密,然后写入。看看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.AccessTokenSecret和sc.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"` } */
可以看到,作者自定使用了
Name、ID、ExpiresAt 这个3属性准备生成一个token。其中
Name和ID是存在自定义的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传入jwtv4 的NewWithClaims方法去返回一个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类型的,分别表示header,claims,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
这里就需要我们刚才的
accessToken和refreshToken 。来看一下项目中,对应代码的逻辑,看看我们怎么调用
/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里的Authorization传accessToken和refreshToken ,而且还得用空格隔开,acccesstoken在后,这里是感觉设计的不太合理的地方,不知道的人想破脑袋也不会知道要这样传参调接口。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字段,也就是
method,header,claims在解密阶段,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)tokenmethod的地址Header 中存加密方法和type:jwtclamis有我们生成阶段自定义存的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,让我们充分自由发挥自己的逻辑。
