Golang 中实现基于 JWT 的身份验证

在这篇文章中,我们将了解基于JWT(JSON Web Token)的身份验证如何工作,以及如何使用golang-jwt/jwt库在 Go 中构建服务器应用程序来实现它。

JSON Web 令牌 (JWT) 允许您以无状态方式对用户进行身份验证,而无需在系统本身上实际存储有关用户的任何信息(与基于会话的身份验证相反)。

JWT 格式
考虑一个名为 的用户user1,尝试登录应用程序或网站:一旦成功,他们将收到如下所示的令牌:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ.2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54


这是一个 JWT,它由三部分组成(用 分隔.):

  1. 第一部分是标题 ( eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9)。标头指定了用于生成签名的算法等信息(第三部分)。这部分非常标准,对于使用相同算法的任何 JWT 都是相同的。
  2. 第二部分是有效负载 ( eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ),其中包含应用程序特定信息(在我们的例子中,这是用户名),以及有关令牌的到期和有效性的信息。
  3. 第三部分是签名(2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54)。它是通过将前两部分与密钥组合并散列生成的。

请注意,标头和有效负载未加密 – 它们只是进行了 Base64 编码。这意味着任何人都可以使用Base64 解码器对其进行解码

例如,如果我们将标头解码为纯文本,我们将看到以下内容:

{ "alg": "HS256", "typ": "JWT" }
 

如果您使用的是linux或Mac操作系统,还可以在终端执行以下语句:

echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d
同样,payload的内容为:

{ "username": "user1", "exp": 1547974082 }

JWT 签名的工作原理
那么,如果任何人都可以访问 JWT 的标头和签名,那么究竟是什么让 JWT 安全呢?答案在于第三部分(签名)是如何生成的。
user1考虑一个想要向已成功登录的用户(例如 )颁发 JWT 的应用程序。

制作标头和有效负载非常简单:标头针对我们的用例是固定的,有效负载 JSON 对象是通过设置用户 ID 和以 unix 毫秒为单位的到期时间来形成的。

发行令牌的应用程序还将有一个密钥,它是一个秘密值,只有应用程序本身知道。

然后,将报头和有效载荷的 base64 表示法与秘钥结合起来,再通过散列算法(在本例中为 HS256,如报头所述)进行散列.

关于如何实现该算法的细节不在本文讨论范围之内,但需要注意的是,该算法是单向的,这意味着我们无法逆转该算法并获取制作签名的组件,因此我们的秘钥仍然是保密的。


验证 JWT
要验证 JWT,服务器会使用传入 JWT 的标头和有效负载及其秘钥再次生成签名。如果新生成的签名与 JWT 上的签名一致,则认为 JWT 有效。

现在,如果有人想伪造令牌,你可以很容易地生成报头和有效负载,但如果不知道密钥,就无法生成有效的签名。如果你试图篡改有效 JWT 的现有有效负载,签名就不再匹配。

这样,JWT 就成了一种以安全方式授权用户的方法,而无需在签发服务器上实际存储任何信息(除了密钥)。

现在我们已经了解了基于 JWT 的身份验证的工作原理,让我们使用 Go 来实现它。

创建 HTTP 服务器
让我们首先使用所需的路由初始化 HTTP 服务器:

package main

import (
    "log"
    
"net/http"
)

func main() {
    
// we will implement these handlers in the next sections
    http.HandleFunc(
"/signin", Signin)
    http.HandleFunc(
"/welcome", Welcome)
    http.HandleFunc(
"/refresh", Refresh)
    http.HandleFunc(
"/logout", Logout)

    
// start the server on port 8000
    log.Fatal(http.ListenAndServe(
":8000", nil))
}

我们现在可以定义Signin和Welcome路线。

处理用户登录
/signin路由将获取用户凭据并登录。让我们首先定义用户数据以及一些表示凭据和 JWT 声明的类型:

import (
    //...
    
// import the jwt-go library
    
"github.com/golang-jwt/jwt/v5"
    
//...
)

// Create the JWT key used to create the signature
var jwtKey = []byte(
"my_secret_key")

// 为简化起见,我们在代码中将用户信息存储为内存map
var users = map[string]string{
    
"user1": "password1",
    
"user2": "password2",
}

// 创建一个结构体,从请求正文中读取用户名和密码
type Credentials struct {
    Password string `json:
"password"`
    Username string `json:
"username"`
}

//创建一个将编码为 JWT 的结构。
// 我们添加 jwt.RegisteredClaims 作为嵌入式类型,以提供到期时间等字段
type Claims struct {
    Username string `json:
"username"`
    jwt.RegisteredClaims
}

因此,目前我们的应用程序中只有两个有效用户:user1、 和user2。在实际应用程序中,用户信息将存储在数据库中,密码将被散列并存储在单独的列中。为了简单起见,我们在这里使用硬编码map。

接下来,我们可以编写SigninHTTP 处理程序。在本示例中,我们使用golang-jwt/jwt库来帮助我们创建和验证 JWT 令牌。

// Create the Signin handler
func Signin(w http.ResponseWriter, r *http.Request) {
    var creds Credentials
    
// Get the JSON body and decode into credentials
    err := json.NewDecoder(r.Body).Decode(&creds)
    if err != nil {
        
// If the structure of the body is wrong, return an HTTP error
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    
//从内存映射中获取预期密码
    expectedPassword, ok := users[creds.Username]

    
// If a password exists for the given user
    
// AND, if it is the same as the password we received, the we can move ahead
    
// if NOT, then we return an "Unauthorized" status
    if !ok || expectedPassword != creds.Password {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    
//声明令牌的失效时间
    
// 在这里,我们将其保留为 5 分钟
    expirationTime := time.Now().Add(5 * time.Minute)
    
// 创建 JWT 声明,其中包括用户名和过期时间
    claims := &Claims{
        Username: creds.Username,
        RegisteredClaims: jwt.RegisteredClaims{
            
// In JWT, the expiry time is expressed as unix milliseconds
            ExpiresAt: jwt.NewNumericDate(expirationTime),
        },
    }

    
// 声明令牌和用于签名的算法,以及权利要求
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    
// Create the JWT string
    tokenString, err := token.SignedString(jwtKey)
    if err != nil {
        
// If there is an error in creating the JWT return an internal server error
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    
// 最后,我们将 "token "的客户端 cookie 设置为刚刚生成的 JWT
    
// 我们还设置了与令牌本身相同的到期时间
    http.SetCookie(w, &http.Cookie{
        Name:    
"token",
        Value:   tokenString,
        Expires: expirationTime,
    })
}

在此示例中,该jwtKey变量用作 JWT 签名的密钥。该密钥应安全地保存在服务器上,并且不应与服务器外部的任何人共享。通常,它存储在配置文件中,而不是源代码中。为了简单起见,我们在这里使用硬编码值。

如果用户使用正确的凭据登录,该处理程序将在客户端使用 JWT 值设置 cookie。一旦在客户端上设置了 cookie,它就会与此后的每个请求一起发送。现在我们可以编写欢迎处理程序来处理用户特定信息。

处理身份验证后路由
现在所有登录的客户端都将会话信息作为 cookie 存储在其端,我们可以使用它来:

  • 验证后续用户请求
  • 获取有关发出请求的用户的信息

让我们编写Welcome处理程序来执行此操作:

func Welcome(w http.ResponseWriter, r *http.Request) {
    //我们可以从请求 Cookie 中获取会话标记,每次请求都会附带 Cookie
    c, err := r.Cookie(
"token")
    if err != nil {
        if err == http.ErrNoCookie {
            
// If the cookie is not set, return an unauthorized status
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        
// For any other type of error, return a bad request status
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    
// Get the JWT string from the cookie
    tknStr := c.Value

    
// Initialize a new instance of `Claims`
    claims := &Claims{}

   
// 解析 JWT 字符串并将结果存储在`claims`.
   
//注意,我们在此方法中也传递了密钥。如果令牌无效(根据我们在登录时设置的有效期过期),
    
//或者签名不匹配,本方法将返回错误信息
    tkn, err := jwt.ParseWithClaims(tknStr, claims, func(token *jwt.Token) (any, error) {
        return jwtKey, nil
    })
    if err != nil {
        if err == jwt.ErrSignatureInvalid {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    if !tkn.Valid {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    
// 最后,向用户返回欢迎信息,以及用户在令牌中给出的
    
// 用户名。
    w.Write([]byte(fmt.Sprintf(
"Welcome %s!", claims.Username)))
}

更新您的令牌
在此示例中,我们设置了五分钟的较短到期时间。如果用户的令牌过期,我们不应该期望用户每五分钟登录一次。
为了解决这个问题,我们将创建另一个/refresh路由,该路由采用先前的令牌(仍然有效),并返回具有更新的到期时间的新令牌。
为了最大限度地减少 JWT 的滥用,过期时间通常保持在几分钟左右。通常,客户端应用程序会在后台刷新令牌。


func Refresh(w http.ResponseWriter, r *http.Request) {
    // (BEGIN) 在此之前的代码与 "Welcome "路由的第一部分相同
    c, err := r.Cookie(
"token")
    if err != nil {
        if err == http.ErrNoCookie {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    tknStr := c.Value
    claims := &Claims{}
    tkn, err := jwt.ParseWithClaims(tknStr, claims, func(token *jwt.Token) (any, error) {
        return jwtKey, nil
    })
    if err != nil {
        if err == jwt.ErrSignatureInvalid {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    if !tkn.Valid {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    
// (END) The code until this point is the same as the first part of the `Welcome` route

   
// 我们确保在足够长的时间过去后才会签发新令牌
   
//在这种情况下,只有当旧令牌在 30 秒内到期时,才会签发新令牌。否则,将返回不良请求状态
    if time.Until(claims.ExpiresAt.Time) > 30*time.Second {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

   
// 现在,为当前使用创建一个新的令牌,并延长有效期
    expirationTime := time.Now().Add(5 * time.Minute)
    claims.ExpiresAt = jwt.NewNumericDate(expirationTime)
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, err := token.SignedString(jwtKey)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

   
// 将新标记设置为用户的 "token "cookie
    http.SetCookie(w, &http.Cookie{
        Name:    
"token",
        Value:   tokenString,
        Expires: expirationTime,
    })
}


处理注销
当涉及基于 JWT 的身份验证时,注销可能会很棘手,因为我们的应用程序是无状态的 - 这意味着我们不会在服务器上存储有关已发行的 JWT 令牌的任何信息。

我们拥有的唯一信息是用于编码和解码 JWT 的密钥和算法。如果令牌满足这些要求,我们的应用程序就会认为它是有效的。

这就是为什么处理注销的推荐方法是提供过期时间较短的令牌,并要求客户端不断刷新令牌。这样,我们可以确保在有效期内T,用户在没有应用程序明确许可的情况下可以保持登录状态的最长时间为T秒。

我们的另一个选择是创建一个/logout清除用户令牌 cookie 的路由,以便后续请求将未经身份验证:

func Logout(w http.ResponseWriter, r *http.Request) {
    // immediately clear the token cookie
    http.SetCookie(w, &http.Cookie{
        Name:    
"token",
        Expires: time.Now(),
    })
}

但是,这是客户端实现,如果客户端决定不遵循说明并删除 cookie,则可以绕过它。

我们还可以将想要在服务器上失效的 JWT 存储起来,但这将使我们的应用程序有状态。

运行我们的应用程序
要运行此应用程序,请构建并运行 Go 二进制文件:

go build
./jwt-go-example


现在,使用任何支持 cookie 的 HTTP 客户端(例如Postman或您的 Web 浏览器)使用适当的凭据发出登录请求:

POST http://localhost:8000/signin

{
"username":"user1","password":"password1"}

您现在可以尝试从同一客户端点击欢迎路线来获取欢迎消息:

GET http://localhost:8000/welcome
结果:
Welcome user1!

点击刷新路由,然后检查客户端 cookie 以查看tokencookie 的新值:

POST http://localhost:8000/refresh

您可以在此处找到此示例的工作源代码。