摘抄:https://learnku.com/go/t/52399

身份验证使应用程序知道向应用程序发送请求的人是谁。JSON Web 令牌(JWT)是一种允许身份验证的方法,而无需在系统本身实际存储任何有关用户的任何信息(与基于会话的身份验证相反 )。

在本文中,我们将演示基于 JWT 的身份验证的工作原理,以及如何在 Go 中构建示例应用程序以实现该示例。

如果你已经知道 JWT 的工作原理,并且只想看一下实现,则可以 跳过,或者在 Github 上查看源代码 。

JWT 格式

假设我们有一个名为的用户 user1,他们尝试登录到应用程序或网站。一旦成功,他们将收到一个看起来像这样的令牌:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ.2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54

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

  1. 第一部分是标题 header(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9)。标头指定信息,例如用于生成签名的算法(第三部分)。这部分是标准的,并且对于使用相同算法的任何 JWT 都是相同的。
  2. 第二部分是有效负载 payload (eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ),其中包含特定于应用程序的信息(在我们的示例中,这是用户名),以及有关令牌的到期和有效性的信息。
  3. 第三部分是签名(2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54)。它是通过组合和散列前两个部分以及一个秘密密钥来生成的。
    现在有趣的是,标题 header 和有效负载 payload 未加密。它们只是 base64 编码的。这意味着任何人都可以通过解码来查看其内容。

例如,我们可以使用此 在线工具 对标题或有效负载进行解码。

eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ将显示为以下内容:

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




JWT 签名如何工作

因此,如果任何人都可以读写 JWT 的标头和签名,那么实际上如何保证 JWT 是安全的?答案在于如何生成最后一部分(签名)。

假设你的应用程序想要向成功登录的用户 user1 签发 JWT。

使标头和有效负载非常简单:标头或多或少是固定的,有效负载 JSON 对象是通过设置用户 ID 和有效时间(以 Unix 毫秒为单位)来形成的。

发行令牌的应用程序还拥有一个密钥,该密钥是一个私有值,并且仅对应用程序本身是已知的。然后将标头和有效负载的 base64 表示形式与密钥组合,然后通过哈希算法计算签名值(在本例中为 HS256,如标头中所述)

如何实现算法的细节超出了本文的讨论范围,但是要注意的重要一点是,这是一种 hash 方法,这意味着我们无法破解算法并获得进行签名的密钥,因此我们秘密密钥仍然是私有的。





验证 JWT

为了验证传入的 JWT,将使用传入的 JWT 的标头和有效负载以及密钥再次生成签名。如果签名与 JWT 上的签名匹配,则认为 JWT 有效。

现在,让我们假设你是一个试图发行假令牌的黑客。你可以轻松地生成标头和有效负载,但是在不知道密钥的情况下,无法生成有效的签名。如果你尝试篡改有效 JWT 的有效负载 payload,则签名将不再匹配。

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





GO 的实现

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

创建 HTTP 服务器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"log"
"net/http"
)

func main() {
// "Signin"和"Welcome"方法是我们将要实现的处理程序
http.HandleFunc("/signin", Signin)
http.HandleFunc("/welcome", Welcome)

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

现在,我们可以定义 SigninWelcome 路由。





处理用户登录

/signin 路由将获取用户凭据并登录。为简化起见,我们在代码中将用户信息存储在 map:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import (
//...
// 导入jwt-go库
"github.com/dgrijalva/jwt-go"
//...
)

// 创建一个jwt使用的密钥
var jwtKey = []byte("my_secret_key")

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

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

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

// 创建Signin处理函数。
func Signin(w http.ResponseWriter, r *http.Request) {
var creds Credentials
// 获取JSON正文并解码为凭据
err := json.NewDecoder(r.Body).Decode(&creds)
if err != nil {
// 如果主体结构错误,则返回HTTP错误
w.WriteHeader(http.StatusBadRequest)
return
}

// 从我们的map中获取用户的密码
expectedPassword, ok := users[creds.Username]

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

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

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

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

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





处理认证后的路由

现在,所有已登录的客户端都使用 cookie 存储用户信息,我们可以将其用于:

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

让我们编写 Welcome 处理方法来做到这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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("Welcome %s!", claims.Username)))
}




双令牌续签

双令牌:刷新令牌 + 访问令牌

1、刷新令牌的低使用频率和安全性

在双令牌系统中,访问令牌和刷新令牌的角色和使用频率有明显差异:

  • 访问令牌:用于访问系统资源,因此会频繁使用,每次API调用都可能需要验证访问令牌。这意味着访问令牌更频繁地在客户端和服务器之间传输,从而增加了被截获的风险。
  • 刷新令牌:主要用于在访问令牌过期后获取新的访问令牌。因此,其使用频率远低于访问令牌,通常只在访问令牌失效时才使用。

安全优势

刷新令牌的这种使用模式带来了几个安全优势:

  1. 降低暴露风险:由于刷新令牌不需要频繁发送,其被截获的机会相对较小。这减少了被盗用的风险。
  2. 控制和监控:刷新令牌的使用可以更容易地被监控和控制,因为其使用情况较少且具有特定目的(仅用于令牌续签)。这使得任何异常使用模式更容易被检测到。
  3. 更长的生命周期管理:虽然刷新令牌具有更长的有效期,但由于其使用频率低,可以在系统检测到安全问题时及时撤销,而不像访问令牌那样经常处于活跃状态中。

为了最大程度地减少对 JWT 的滥用,通常将到期时间保持在几分钟左右。通常,客户端应用程序将在后台刷新令牌。





jwt + redis续签

JWT 实现登录认证 + Token 自动续期方案,这才是正确的使用姿势!

安全性:使用JWT作为session_id,其自带的签名验证确保了令牌的安全性,防止篡改。

状态管理:通过在Redis中存储prefix + session_id对应的用户信息,您可以轻松管理用户状态,如权限、偏好设置等。

可扩展性:Redis具有出色的读写性能,适用于大规模应用,且易于水平扩展。

灵活性:每次API访问时刷新Redis中的过期时间可以灵活地管理会话有效期,允许用户在活跃时保持登录状态。