【译文】原文地址
大家好,我将尝试把使用Go结合JWT构建用户认证系统的整个过程记录下来,作为一个参考希望对你有所帮助。
什么是JWT
对于不是很熟悉JWT的人来说,JWT是JSON Web Token的缩写。JWT是在服务器端对用户身份进行认证的一种方法。
在传统方法中,我们有用于验证用户身份的会话,在成功登录时,我们将使用该用户的详细信息创建一个新会话,并将其存储在服务器存储中。然后将创建的session ID发送回客户机。随后客户端发送后续请求都会携带这个session ID,因此服务端可以通过查询存储的session ID来对客户端请求进行认证。当不同的服务对应独立的存储时(例如微服务架构)这种认证方法就复杂了。
JWT是构建微服务架构应用的救星。在JWT使用中,信息只存储在客户端。JWT只是一些包含用户声明信息的JSON数据。JWT的关键属性是令牌(Token)本身包含验证所需的所有细节,因为它们携带消息身份验证代码(MAC)。JWT由三部分组成,头部、有效负载和签名,分别用点号分开的。
JWT例子
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
在上面的例子中,第一个点号签名的部分是头部,第二个点号和第一个点号之间的是有效负载,后面的是签名部分。上面的token是使用Base64Url编码版本。我们去jwt.io将上面的token解码为JSON数据来查看。
让我们来看看JWT的每个部分都包含哪些内容
头部:包含一些关于token的元数据,如所使用的的签名类型等。下面是上述token解码后的JSON数据:
{
“alg”: “HS256”,
“typ”: “JWT”
}
有效负载:包含用户的识别信息,以上token解码后如下所示:
{
“sub”: “1234567890”,
“name”: “John Doe”,
“iat”: 1516239022
}
签名:是我们上面讨论过的消息认证码(MAC message authentication code),它有助于token的验证。签名是通过结合头部和有效负载的base64Url编码格式并在服务器中使用密钥对其签名来创建的。例如,在HS256签名类型的JWT中,签名的创建如下:
signature = hmacsha256(encoded_header + “.” + encoded_payload, “server_secret”)
hmacsha256是一种哈希算法。签名只能由头部、有效负载和密匙的所有者才能生成。因此,在身份验证期间,在接收到JWT后,服务器将使用已编码的头和所获得的JWT中的有效负载创建一个新签名,并使用服务器中提供的密钥对其进行签名。然后将这个新创建的签名与获得的JWT中存在的签名进行比较。如果匹配,服务器就认为该令牌有效,并通过获取token中的用户标识来处理请求。
JWT认证系统的基本流程如下:
1、客户端发送带有用户名和密码的登录请求
2、服务器通过查询数据库来验证用户名和密码的组合,并验证请求。
3、如果有效,将创建一个新的JWT token,其中包含用户的技术标识和一个过期时间(稍后将讨论这个过期时间)。
4、然后服务器基于base64Url对头部和有效负载进行编码,并使用密匙对他们进行签名。这样就创建了完整的JWT。
5、服务器将JWT作为响应发送回客户机。客户机将在随后的请求中携带这个JWT发送给服务器以获得身份验证。
6、服务器在接收到带有JWT的请求后,尝试通过检查token中的过期时间并创建一个新的签名,然后token中的签名进行对比。
到目前为止,,在讨论JWT时,我们用一个通用的token来表示。但是在实现中,您将发现JWT是使用两个术语AccessToken和RefreshToken来实现。这两个都是JSON web Token,为什么需要两个?我们知道JWT本身包含验证所需的所有信息,假设一个场景,黑客得到了请求中发送的token。黑客可以冒充用户向服务器发送请求。现在黑客可以连续有效地访问服务器了。为了解决这个问题,我们使用两个不同的token。AccessToken是在发送请求用于用户身份验证,但是有过期时间,过期后将权限失效。因此黑客获得AccessToken只能获得短暂的权限。那么现在当AccessToken过期时,可信用户不会也失去访问权限吗?是的,这就是使用RefreshToken的原因。RefreshToken没有任何过期时间,可以使用它从服务器获取新的AccessToken。
总之,在基于用户名和密码成功验证用户身份之后,服务器现在创建两个名为AccessToken和RefreshToken的令牌,并将它们返回给客户机。在后续的请求中客户端将AccessToken发送给服务器进行身份验证。这个AccessToken将在一段时间后过期,在这段时间内客户端可以使用RefreshToken获得一个新的AccessToken。
希望以上理论分析给出了对JWT的理解。但是我建议阅读文章后面的文献来对JWT进行深入的了解。现在我们来看看Go的JWT实现。
Go实现
现在让我们看看在golang中实现JWT身份验证代码。我们将讨论JWT身份验证的主要代码片段,完整代码实现。我们将为用户对象和相应的sql schema使用如下数据模型:
// User is the data type for user object
type User struct {
ID string `json:"id" sql:"id"`
Email string `json:"email" validate:"required" sql:"email"`
Password string `json:"password" validate:"required" sql:"password"`
Username string `json:"username" sql:"username"`
TokenHash string `json:"tokenhash" sql:"tokenhash"`
CreatedAt time.Time `json:"createdat" sql:"createdat"`
UpdatedAt time.Time `json:"updatedat" sql:"updatedat"`
}
// schema for user table
const schema = `
create table if not exists users (
id varchar(36) not null,
email varchar(225) not null unique,
username varchar(225),
password varchar(225) not null,
tokenhash varchar(15) not null,
createdat timestamp not null,
updatedat timestamp not null,
primary key (id)
);
`
我们在用户结构上添加了json、validate和sql标签,这有助于我们对用户对象进行编码/解码和验证。让我们从实现认证系统的路由逻辑开始:
// create a serve mux
sm := mux.NewRouter()
// register handlers
postR := sm.Methods(http.MethodPost).Subrouter()
postR.HandleFunc("/signup", uh.Signup)
postR.HandleFunc("/login", uh.Login)
postR.Use(uh.MiddlewareValidateUser)
// used the PathPrefix as workaround for scenarios where all the
// get requests must use the ValidateAccessToken middleware except
// the /refresh-token request which has to use ValidateRefreshToken middleware
refToken := sm.PathPrefix("/refresh-token").Subrouter()
refToken.HandleFunc("", uh.RefreshToken)
refToken.Use(uh.MiddlewareValidateRefreshToken)
getR := sm.Methods(http.MethodGet).Subrouter()
getR.HandleFunc("/greet", uh.Greet)
getR.Use(uh.MiddlewareValidateAccessToken)
这里我们使用gorilla mux包进行路由。我们创建一个新的服务mux,然后为我们将要处理的每个http方法创建子路由器。然后我们在子路由器上注册路由和相应的处理函数。还要注意,用Use()在子路由器上注册了中间件函数。对于子路由器的所有路由上的请求,将首先执行一个中间件函数。
接下来,我们将深入登录和注册处理数的细节。但在此之前,我们先来看看这两个handle函数所使用的中间件函数。
// MiddlewareValidateUser validates the user in the request
func (uh *UserHandler) MiddlewareValidateUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uh.logger.Debug("user json", r.Body)
user := &data.User{}
err := data.FromJSON(user, r.Body)
if err != nil {
uh.logger.Error("deserialization of user json failed", "error", err)
w.WriteHeader(http.StatusBadRequest)
data.ToJSON(&GenericError{Error: err.Error()}, w)
return
}
// validate the user
errs := uh.validator.Validate(user)
if len(errs) != 0 {
uh.logger.Error("validation of user json failed", "error", errs)
w.WriteHeader(http.StatusBadRequest)
data.ToJSON(&ValidationError{Errors: errs.Errors()}, w)
return
}
// add the user to the context
ctx := context.WithValue(r.Context(), UserKey{}, *user)
r = r.WithContext(ctx)
// call the next handler
next.ServeHTTP(w, r)
})
}
MiddlewareValidateUser函数解析请求体中给出的用户对象,并将数据编码为用户结构。然后我们验证用户结构,这是使用我们提供的验证标记完成的。如果User结构具有所有必需的字段,则将对象添加到请求上下文并调用下一个处理函数(在本例中可能是signup或login)。
现在来看看处理函数:
// Signup handles signup request
func (uh *UserHandler) Signup(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(UserKey{}).(data.User)
hashedPass, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
w.Header().Set("Content-Type", "application/json")
if err != nil {
uh.logger.Error("unable to hash password", "error", err)
w.WriteHeader(http.StatusInternalServerError)
data.ToJSON(&GenericError{Error: err.Error()}, w)
return
}
user.Password = string(hashedPass)
user.TokenHash = utils.GenerateRandomString(15)
err = uh.repo.Create(context.Background(), &user)
if err != nil {
uh.logger.Error("unable to insert user to database", "error", err)
errMsg := err.Error()
if strings.Contains(errMsg, PgDuplicateKeyMsg) {
w.WriteHeader(http.StatusBadRequest)
data.ToJSON(&GenericError{Error: ErrUserAlreadyExists}, w)
} else {
w.WriteHeader(http.StatusInternalServerError)
data.ToJSON(&GenericError{Error: errMsg}, w)
}
return
}
uh.logger.Debug("User created successfully")
w.WriteHeader(http.StatusCreated)
data.ToJSON(&GenericMessage{Message: "user created successfully"}, w)
}
上面的是注册处理函数。在请求上下文当中有一个经过验证的user对象。我们对密码进行hash然后存储到数据库中。如果你已经注意到它了想知道TokenHash属性的用途,就是一段长度固定随机字符串,稍后会讨论。因此,这里我们存储给定的用户并返回一个成功响应。
// Login handles login request
func (uh *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
reqUser := r.Context().Value(UserKey{}).(data.User)
user, err := uh.repo.GetUserByEmail(context.Background(), reqUser.Email)
if err != nil {
uh.logger.Error("error fetching the user", "error", err)
errMsg := err.Error()
if strings.Contains(errMsg, PgNoRowsMsg) {
w.WriteHeader(http.StatusBadRequest)
data.ToJSON(&GenericError{Error: ErrUserNotFound}, w)
} else {
w.WriteHeader(http.StatusInternalServerError)
data.ToJSON(&GenericError{Error: err.Error()}, w)
}
return
}
if valid := uh.authService.Authenticate(&reqUser, user); !valid {
uh.logger.Debug("Authetication of user failed")
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "application/json")
data.ToJSON(&GenericError{Error: "incorrect password"}, w)
return
}
accessToken, err := uh.authService.GenerateAccessToken(user)
if err != nil {
uh.logger.Error("unable to generate access token", "error", err)
w.WriteHeader(http.StatusInternalServerError)
w.Header().Set("Content-Type", "application/json")
data.ToJSON(&GenericError{Error: err.Error()}, w)
return
}
refreshToken, err := uh.authService.GenerateRefreshToken(user)
if err != nil {
uh.logger.Error("unable to generate refresh token", "error", err)
w.WriteHeader(http.StatusInternalServerError)
w.Header().Set("Content-Type", "application/json")
data.ToJSON(&GenericError{Error: err.Error()}, w)
return
}
uh.logger.Debug("successfully generated token", "accesstoken", accessToken, "refreshtoken", refreshToken)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
data.ToJSON(&AuthResponse{AccessToken: accessToken, RefreshToken: refreshToken, Username: user.Username}, w)
}
这是登录处理函数。我们知道,在请求上下文中应该有一个有效的user对象,因为用户验证中间件函数应该在这个函数之前执行。我们首先检查数据库中是否存在带有请求中给定电子邮件的用户,并检索它。然后,请求中的用户(requestUser)和从DB检索的用户(user)被传递到Authenticate函数。让我们看看它在做什么:
func (auth *AuthService) Authenticate(reqUser *data.User, user *data.User) bool {
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(reqUser.Password)); err != nil {
auth.logger.Debug("password hashes are not same")
return false
}
return true
}
这里我们只比较请求中提供的密码和DB中的密码。我们使用一个特殊的哈希比较函数,因为数据库中的密码是以哈希格式存储的。如果密码散列匹配,我们就认为给定的请求凭据是可信的。
一旦登录请求凭证经过验证,我们就可以开始生成AccessToken和RefreshToken了。希望我们记得为什么有两个令牌,下面的代码片段解释了它们是如何生成的:
// GenerateRefreshToken generate a new refresh token for the given user
func (auth *AuthService) GenerateRefreshToken(user *data.User) (string, error) {
cusKey := auth.GenerateCustomKey(user.ID, user.TokenHash)
tokenType := "refresh"
claims := RefreshTokenCustomClaims{
user.ID,
cusKey,
tokenType,
jwt.StandardClaims{
Issuer: "bookite.auth.service",
},
}
signBytes, err := ioutil.ReadFile(auth.configs.RefreshTokenPrivateKeyPath)
if err != nil {
auth.logger.Error("unable to read private key", "error", err)
return "", errors.New("could not generate refresh token. please try again later")
}
signKey, err := jwt.ParseRSAPrivateKeyFromPEM(signBytes)
if err != nil {
auth.logger.Error("unable to parse private key", "error", err)
return "", errors.New("could not generate refresh token. please try again later")
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(signKey)
}
我们暂且放慢速度,逐行解释下上面的代码。首先,我们创建一个“cusKey”值,它是用用户ID和用户HashToken(存储每个用户时生成的随机字符串)的散列组合。我们将把这个customkey添加到JWT有效负载中,但是为什么呢?因此,正如我们上面所讨论的,为了处理AccessToken可能被黑客攻击的情况,我们添加了一个过期时间戳,使它在特定时间后失效。但是,如果刷新token被黑了怎么办?黑客现在可以使用它获得新的AccessToken 's,并在服务器上执行错误操作。处理这个问题的一种方法是周期性的key旋转,我们改变密钥,这将使旧的标记失效。尽管它是JWT必须实现的东西,但它是周期性的活动,用户不能执行它。我们作为用户,我们知道我们的数据传输被破坏了,我们该如何采取行动呢?不幸的是,JWT让用户无法做到这一点,作为用户,我们只能更改密码,但这对JWT令牌没有影响,刷新令牌将保持有效,直到在服务器中执行key旋转。
为了处理这个特殊的场景,我们将这个customkey值添加到RefreshToken的有效负载中。在验证RefreshToken期间,我们将再次使用用户ID和HashToken创建这个customKey,并将其与RefreshToken有效负载中的customKey进行比较。所以现在如果这个HashToken改变了,那么RefreshToken就会失效。每次用户更改密码时,我们都会用一个新的随机字符串来更改这个HashToken。因此,我们为用户提供了一种通过修改密码来使RequestToken无效的方法。希望我对引入HashToken的原因进行冗长的解释是有意义的,我们将继续讨论下一部分,即创建JWT有效负载。
RefreshTokenCustomClaims构成了我们的有效负载。它由诸如用户id、customkey、tokenttype、颁发者(属于JWT标准声明)等信息组成。注意,我们没有向有效负载添加任何过期时间戳信息。现在我们有了有效负载,头文件将由JWT库生成,接下来我们需要的是签名。我们这里使用的签名方法是RS256。
简而言之,RS256方法遵循公钥密码学,其中我们有两个密钥,公钥和私钥。私钥将用于对令牌进行签名,公钥将用于验证令牌,这与HSA方法不同,在HSA方法中,一个密钥将用于签名和验证。用私钥加密的数据只能用相应的公钥解密,反之亦然。
(注意:在解释JWT时,我们使用HSA方法进行签名。不同的是HSA是基于哈希的方法,RSA是基于加密的方法。为了进一步了解每种方法的优缺点,请阅读下面的参考资料。
linux系统中生成rsa密钥的命令:
- 生成rsa私钥
openssl genrsa -out auth-private.pem 2048
- 导出rsa公钥
openssl rsa -in auth-private.pem -outform PEM -pubout -out auth-public.pem
因此,我们从服务器文件系统读取privatekey。现在我们有了创建token所需的所有组件——有效负载、头(指定的签名方法)和签名秘密。我们只需要创建我们的令牌并使用秘密对其进行签名。是的! !我们有了刷新token。
接下来,我们使用类似的方法来创建AccessToken,只需很少的修改,比如使用不同的私钥(不包括customKey的需要)。这里需要注意的重要一点是,我们正在为Accesstoken添加一个过期时间戳。
// GenerateAccessToken generates a new access token for the given user
func (auth *AuthService) GenerateAccessToken(user *data.User) (string, error) {
userID := user.ID
tokenType := "access"
claims := AccessTokenCustomClaims{
userID,
tokenType,
jwt.StandardClaims{
ExpiresAt: time.Now().Add(time.Minute * time.Duration(auth.configs.JwtExpiration)).Unix(),
Issuer: "bookite.auth.service",
},
}
signBytes, err := ioutil.ReadFile(auth.configs.AccessTokenPrivateKeyPath)
if err != nil {
auth.logger.Error("unable to read private key", "error", err)
return "", errors.New("could not generate access token. please try again later")
}
signKey, err := jwt.ParseRSAPrivateKeyFromPEM(signBytes)
if err != nil {
auth.logger.Error("unable to parse private key", "error", err)
return "", errors.New("could not generate access token. please try again later")
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(signKey)
}
至此,我们已经介绍了与注册和登录处理函数有关的所有内容。
现在让我们看看如何处理刷新令牌请求。如前所述,我们使用refresh-token在AccessToken过期时获得一个新的AccessToken。因此,我们需要在这里验证给定的RefreshToken并提供一个新的AccessToken。验证部分在在refresh-token子外部注册的MiddlewareValidateRefreshToken中间件函数中执行。
// MiddlewareValidateRefreshToken validates whether the request contains a bearer token
// it also decodes and authenticates the given token
func (uh *UserHandler) MiddlewareValidateRefreshToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uh.logger.Debug("validating refresh token")
uh.logger.Debug("auth header", r.Header.Get("Authorization"))
token, err := extractToken(r)
if err != nil {
uh.logger.Error("token not provided or malformed")
w.WriteHeader(http.StatusBadRequest)
data.ToJSON(&GenericError{Error: err.Error()}, w)
return
}
uh.logger.Debug("token present in header", token)
userID, customKey, err := uh.authService.ValidateRefreshToken(token)
if err != nil {
uh.logger.Error("token validation failed", "error", err)
w.WriteHeader(http.StatusBadRequest)
data.ToJSON(&GenericError{Error: err.Error()}, w)
return
}
uh.logger.Debug("refresh token validated")
user, err := uh.repo.GetUserByID(context.Background(), userID)
if err != nil {
uh.logger.Error("invalid token: wrong userID while parsing", err)
w.WriteHeader(http.StatusBadRequest)
data.ToJSON(&GenericError{Error: "invalid token: authentication failed"}, w)
return
}
actualCustomKey := uh.authService.GenerateCustomKey(user.ID, user.TokenHash)
if customKey != actualCustomKey {
uh.logger.Debug("wrong token: authetincation failed")
w.WriteHeader(http.StatusBadRequest)
data.ToJSON(&GenericError{Error: "invalid token: authentication failed"}, w)
return
}
ctx := context.WithValue(r.Context(), UserKey{}, *user)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
func extractToken(r *http.Request) (string, error) {
authHeader := r.Header.Get("Authorization")
authHeaderContent := strings.Split(authHeader, " ")
if len(authHeaderContent) != 2 {
return "", errors.New("Token not provided or malformed")
}
return authHeaderContent[1], nil
}