在这篇文章中,我们将了解基于 JWT(JSON Web Token)的身份验证是如何工作的,以及如何在 Go 中构建服务器应用程序以使用 golang-jwt/jwt 库来实现它。
JSON 网络令牌 (JWT) 允许您以无状态方式对用户进行身份验证,而无需在系统本身上实际存储有关他们的任何信息(与基于会话的身份验证相反)。
JWT 格式
考虑一个名为 user1
的用户,尝试登录应用程序或网站:一旦他们成功,他们将收到如下所示的令牌:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ.2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54
这是一个 JWT,由三部分组成(以 .
分隔):
- 第一部分是标头(
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
)。标头指定生成签名的算法(第三部分)等信息。这一部分非常标准,对于使用相同算法的任何JWT来说都是相同的。 - 第二部分是载荷(
eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ
),其中包含应用程序特定的信息(在我们的例子中,这是用户名),以及令牌的过期和有效期信息。 - 第三部分是签名(
2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54
)。它是通过将前两部分与密钥组合和哈希得到的。
请注意,标头和载荷并没有加密——它们只是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
算法的实现细节不在本文的讨论范围内,但需要注意的重要事项是,这是单向的算法,这意味着我们无法逆向算法并获得用于制作签名的组件,因此我们的密钥仍然是保存机密的。
验证 JWT
要验证JWT,服务器会再次使用来自传入JWT的标头和载荷以及其密钥生成签名。如果新生成的签名与JWT上的签名匹配,则认为JWT是有效的。
现在,如果您是试图发出假令牌的人,您可以轻松地生成标头和载荷,但是如果不知道密钥,就无法生成有效的签名。如果您尝试篡改有效JWT的现有载荷,则签名将不再匹配。
通过这种方式,JWT 作为一种以安全授权用户的方式,而无需在发布服务器上实际存储任何信息(密钥除外)。
Go 中的实现
既然我们已经了解了基于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,我们可以使用它来:
- 验证随后的用户请求
- 获取发出请求的用户相关信息
让我们编写我们的欢迎处理程序来实现这一点:
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,它接受之前的令牌(仍然有效),并返回一个新的令牌与更新的过期时间。
为了最小化 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) (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身份验证时的一个棘手问题,因为我们的应用程序应该是无状态的,这意味着我们不会在服务器上存储有关已发行的JWT令牌的任何信息。
我们唯一拥有的信息是我们的秘密密钥和用于编码和解码JWT的算法。如果一个令牌满足这些要求,那么它被我们的应用程序视为有效。
这就是为什么处理退出登录的推荐方式是提供一个短过期时间的令牌,并要求客户端不断刷新令牌。这样,我们可以确保在过期时间T内,用户在未经应用程序明示许可的情况下最长可以保持登录状态T秒。
另外一个选择是创建一个 /logout
路由,清除用户的令牌 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 或您的网络浏览器)使用适当的凭据发出登录请求:
POST http://localhost:8000/signin
{"username":"user1","password":"password1"}
您现在可以尝试从同一个客户端请求欢迎路由以获取欢迎消息:
GET http://localhost:8000/welcome
Welcome user1!
请求刷新路由,然后检查客户端 cookie 以查看令牌 cookie 的新值:
POST http://localhost:8000/refresh