在 Web 应用程序中,用户身份验证是至关重要的。本篇文章将深入探讨基于 JWT(JSON Web Token)的身份验证原理,并展示如何在 Go 语言中使用 golang-jwt/jwt 库来构建安全的 Web 服务器应用程序。
什么是 JWT?
JSON 网络令牌 (JWT) 是一种以无状态方式进行用户身份验证的机制。与传统的基于会话的身份验证不同,JWT 不需要在服务器上存储用户的信息,从而提高了系统的扩展性。
JWT 的结构与格式
JWT 令牌通常由三部分组成,以 .
分隔:
- 标头(Header):指明签名算法等信息。
- 载荷(Payload):包含用户信息(如用户名)及其过期时间。
- 签名(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 应用程序的安全性和可扩展性。