如何在 Go 中实现 JWT 身份验证

使用 Golang 构建基于 JWT 的身份验证系统

文章目录

在 Web 应用程序中,用户身份验证是至关重要的。本篇文章将深入探讨基于 JWT(JSON Web Token)的身份验证原理,并展示如何在 Go 语言中使用 golang-jwt/jwt 库来构建安全的 Web 服务器应用程序。

什么是 JWT?

JSON 网络令牌 (JWT) 是一种以无状态方式进行用户身份验证的机制。与传统的基于会话的身份验证不同,JWT 不需要在服务器上存储用户的信息,从而提高了系统的扩展性。

JWT 的结构与格式

JWT 令牌通常由三部分组成,以 . 分隔:

  1. 标头(Header):指明签名算法等信息。
  2. 载荷(Payload):包含用户信息(如用户名)及其过期时间。
  3. 签名(Signature):通过将标头和载荷与一个密钥组合并进行哈希生成的,确保令牌的完整性和安全性。

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

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ.2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54

JWT 安全性解析

JWT 的安全性来源于签名部分的生成。签名是通过将标头和载荷的 Base64 表达与密钥结合并进行哈希处理生成的。若有人试图篡改 JWT,签名将不再匹配,从而无法通过验证。这使得 JWT 成为一个安全的身份验证方案,无需在服务器上存储用户数据(除密钥外)。

请注意,JWT 标头和载荷并没有加密——它们只是 Base64 编码。这意味着任何人都可以使用 Base64 解码器对它们进行解码。

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

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

如果你使用的是 linux 或者 Mac OS,也可以在终端执行如下语句:

echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d

同样,payload 的内容是:

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

JWT 签名的工作原理

如果任何人都可以访问 JWT 的标头和签名,那么究竟是什么让 JWT 安全呢?答案在于第三部分(签名)是如何生成的。

考虑一个应用程序要向成功登录的用户(例如 user1)发放 JWT 的情况。

制作标头和载荷都相当简单:对于我们的用例,标头是固定不变的,负载 JSON 对象通过将用户 ID 和 Unix 毫秒级别的到期时间设置在一起形成。

发放令牌的应用程序还需要具有一个密钥,这是一个机密值,仅应用程序自身知道。

然后,标头和载荷的 Base64 表示形式会与密钥结合,然后通过哈希算法进行处理(在本例中是 HS256,如头中所述)。

Header                      Payload
  |                            |
  v                            v
base64                       base64
  |                            |
  |____________________________|
                 |
                 |----------- SecretKey
                 |
                 v
               HS256
                 |
                 v
             signature

算法的实现细节不在本文的讨论范围内,但需要注意的重要事项是,这是单向的算法,这意味着我们无法逆向算法并获得用于制作签名的组件,因此我们的密钥仍然是保存机密的。

使用 Go 实现 JWT 身份验证

要验证 JWT,服务器会再次使用来自传入 JWT 的标头和载荷以及其密钥生成签名。如果新生成的签名与 JWT 上的签名匹配,则认为 JWT 是有效的。

现在,如果您是试图发出假令牌的人,您可以轻松地生成标头和载荷,但是如果不知道密钥,就无法生成有效的签名。如果您尝试篡改有效 JWT 的现有载荷,则签名将不再匹配。

通过这种方式,JWT 作为一种以安全授权用户的方式,而无需在发布服务器上实际存储任何信息(密钥除外)。

既然我们已经了解了基于 JWT 的身份验证是如何工作的,让我们使用 Go 语言来实现它。

创建 HTTP 服务器

首先,我们需要设置一个基本的 HTTP 服务器并定义所需的路由:

package main

import (
	"log"
	"net/http"
)

func main() {
    // 我们将在下一节中实现这些处理程序
	http.HandleFunc("/signin", Signin)
	http.HandleFunc("/welcome", Welcome)
	http.HandleFunc("/refresh", Refresh)
	http.HandleFunc("/logout", Logout)

    // 在端口 8000 上启动服务器
	log.Fatal(http.ListenAndServe(":8000", nil))
}

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

处理用户登录

/signin 路由中,我们将接收用户凭据并进行验证。用户信息将存储在一个简单的 map 中:

var users = map[string]string{
	"user1": "password1",
	"user2": "password2",
}

所以现在,我们的应用程序中只有两个有效用户:user1 和 user2。接下来,我们可以编写 Signin HTTP 处理程序。对于此示例,我们使用 golang-jwt/jwt 库来帮助我们创建和验证 JWT 令牌。

import (
	"github.com/golang-jwt/jwt/v4"
)

// 创建用于创建签名的 JWT 密钥
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
}

// 创建 Signin 处理程序
func Signin(w http.ResponseWriter, r *http.Request) {
	var creds Credentials
    // 获取 JSON body 并解码为 credentials
	err := json.NewDecoder(r.Body).Decode(&creds)
	if err != nil {
        // 如果请求体结构错误,则返回 HTTP 错误
		w.WriteHeader(http.StatusBadRequest)
		return
	}

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

    // 如果给定用户存在密码,
    // 并且如果与我们收到的密码相同,则可以继续
    // 如果不是,则返回 “未授权” 状态。
	if !ok || expectedPassword != creds.Password {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

    // 声明 token 的过期时间
    // 在此我们将其保持为 5 分钟
	expirationTime := time.Now().Add(5 * time.Minute)
    // 创建 JWT claims ,其中包括用户名和过期时间
	claims := &Claims{
		Username: creds.Username,
		RegisteredClaims: jwt.RegisteredClaims{
            // 在 JWT 中,过期时间表示为 Unix 毫秒时间戳
			ExpiresAt: jwt.NewNumericDate(expirationTime),
		},
	}

    // 声明使用的算法和 claims 来创建 token
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    // 创建 JWT 字符串
	tokenString, err := token.SignedString(jwtKey)
	if err != nil {
        // 如果创建 JWT 出错,则返回内部服务器错误
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

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

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

处理身份验证后的欢迎路由

既然所有已登录的客户端都在其端存储了会话信息作为 cookie,我们可以使用它来:

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

接下来,创建 /welcome 路由,用户可以在登录后访问该路由以获取个性化欢迎信息:

func Welcome(w http.ResponseWriter, r *http.Request) {
    // 我们可以从每个请求都附带的请求 cookie 中获取会话令牌
    c, err := r.Cookie("token")
    if err != nil {
        if err == http.ErrNoCookie {
            // 如果未设置 cookie,则返回未授权状态
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        // 对于任何其他类型的错误,返回错误的请求状态
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    // 从 cookie 中获取 JWT 字符串
    tknStr := c.Value

    // 初始化一个 Claims 实例
    claims := &Claims{}

    // 解析 JWT 字符串并将结果存储在 Claims 中。
    // 请注意,我们在此方法中传递了密钥。如果令牌无效(根据我们在登录时设置的过期时间已过期),
    // 或者签名不匹配,则此方法将返回错误
    tkn, err := jwt.ParseWithClaims(tknStr, claims, func(token *jwt.Token) (interface{}, 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("欢迎 %s!", claims.Username)))
}

刷新令牌

为了防止用户频繁登录,我们可以创建 /refresh 路由,允许用户在令牌即将过期时获得新的令牌:

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) (interface{}, 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) 本段代码与 `Welcome` 路由的前半部分相同

	// 我们确保在足够的时间过去之前不会重新发放新令牌
	// 在这种情况下,只有当旧令牌距离过期还有 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(JSON Web Token)身份验证,退出登录操作通常会比较棘手,因为 JWT 机制的设计目的是为了支持无状态的应用程序。这意味着服务器不会存储已颁发的 JWT 令牌的任何信息,而仅依赖私钥和用于生成和验证 JWT 的算法来决定令牌的有效性。因此,只要令牌符合签发时的标准,它就会被视为有效,直到到期。

为了解决退出登录的问题,推荐的做法是使用短期有效的 JWT,并让客户端定期刷新令牌。这样一来,即便不主动清除令牌,用户也只能在 JWT 的有效期 T 内保持登录,过了 T 秒后就必须重新验证登录状态。

另一种方案是在后端提供一个 /logout 路由,通过清除用户的 JWT cookie 来实现“退出”效果,使后续请求失去认证。例如:

func Logout(w http.ResponseWriter, r *http.Request) {
	// 立即清除 token cookie
	http.SetCookie(w, &http.Cookie{
		Name:    "token",
		Expires: time.Now(),
	})
}

这种方法本质上是通过客户端的配合来实现的。如果客户端未按要求删除 cookie,就有可能规避此退出机制。

也可以考虑在服务器端维护一个“黑名单”列表,将需失效的 JWT 存储起来,但这就违背了无状态设计的初衷,应用将变成有状态。

运行我们的应用程序

要运行该应用程序,构建并执行 Go 代码。使用支持 cookie 的 HTTP 客户端(如 Postman 或 Web 浏览器)发送登录请求:

POST https://localhost:8000/signin

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

成功登录后,用户可以请求欢迎路由以获取欢迎消息:

GET https://localhost:8000/welcome

Welcome user1!

请求刷新路由,然后检查客户端 cookie 以查看令牌 cookie 的新值:

POST https://localhost:8000/refresh

总结

本文介绍了如何在 Go 中实现基于 JWT 的身份验证,涵盖了 JWT 的基本结构、安全机制及其在用户身份验证中的应用。通过使用 JWT,您可以实现无状态的安全身份验证,提升 Web 应用程序的安全性和可扩展性。


也可以看看