摘抄: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中的过期时间可以灵活地管理会话有效期,允许用户在活跃时保持登录状态。

在项目code-comment-analyzer中,我的做法:

  • jwt中的内容,content部分含有user_id,但是不做其他设置
  • redis中存储一个格式为prefix_userid的key(方便后续其他操作指定的token),以及jwt生成的token作为value,并且会设置过期时间
gpt评价

整体来说,你目前的方案在可行性和简单性上是没有问题的,但如果要长期维护并在更复杂的场景下使用,还需要留意以下几点:

你这样做的优点

  1. 实现简单

    • JWT 中只存 user_id,在验证时解析出 user_id 即可去 Redis 查找对应记录,不需要把过多的信息放进 JWT,减少了令牌体过长的问题。
    • Redis 中只存一对 prefix_userid -> token,查找和更新都比较直接。
  2. 可强制下线/过期

    • 当你需要让某个用户的 Token 立即失效时,只需删除 Redis 里对应的 Key(prefix_userid),即可让原先的 JWT 形同无效(因为你在校验时会比对 Redis 中存储的 token 是否一致)。
  3. 续期可控

    • 只要每次用户调用接口时,你都能够刷新 Redis 中 prefix_userid 这个 Key 的过期时间,就能实现“滑动过期”或“自动续期”的效果。

可能需要进一步考虑的点

一、多端登录或多 Token 管理

目前你的 Redis Key 是以 prefix_userid 作为唯一标识,这意味着 同一个用户只能存储一个 Token。这在有些应用场景中是可以的,比如你的系统只允许单端登录,或者用户必须在新设备登录时将旧设备的登录顶下去。

但如果你需要实现多设备同时登录多 Token 并存(譬如手机端、PC端同时使用),就需要考虑以下两种做法之一:

  1. 在 Redis 中以 Token 自身为 Key,比如 prefix_token -> user_id 形式。这样可以同时存在多条记录,每条记录的过期时间独立管理。
  2. 在 Key 中带上更细分的信息,比如 prefix_{user_id}_{device_id},从而允许每个用户在多个设备上各自持有 Token 并独立管理过期时间。

二、Token 本身的唯一性 (jti)

在更严格的安全策略下,JWT 中可能需要包含一个 唯一 ID(jti),用来标识该 Token 实例,这样可以实现更灵活的令牌撤销机制令牌防重放等等。

  • 生成 Token 时在其 Claims 中加入 jti(uuid 或随机字符串)。
  • 在 Redis 中不仅仅存 {prefix_userid -> token},而是存 {jti -> 用户或会话信息} 或者 {jti -> token}
  • 用户每次请求带上 Token 时,你可以解析出 jti,然后去 Redis 确认 jti 是否存在/过期,从而确定有效性。

这样做好处:

  1. 即便你的 JWT 在外面被复制了一份,如果你主动在 Redis 中删除对应 jti,也可以让所有复制的令牌都失效。
  2. 如果要实现一次性 Token(某些场景才需要),配合 jti 可以更好地做防重放。

三、Redis 存储的内容与过期策略

“jwt中的内容,content部分含有user_id,但是不做其他设置;Redis 中存储一个格式为 prefix_userid 的 key,以及 jwt 生成的 token 作为 value,并且会设置过期时间。”

如果主要是为了强制 Token 失效,那么把 token 本身放 Redis 里是可以的;如果要进一步做附加信息缓存(比如用户权限、角色信息),就可以在 Redis 里存一个更丰富的对象(Hash / JSON),这样在授权校验时可以少做一次数据库查询。

同时,你也要决定JWT 的过期时间Redis Key 的过期时间怎么配合:

  • 如果 JWT 的过期时间较短,而 Redis Key 过期时间较长,Token 有可能在 Redis 中还存在,但 JWT 已经失效;
  • 如果 JWT 的过期时间比 Redis Key 过期时间更长,那么就会出现 Token 的签名和时间都还有效,但 Redis 里 Key 已经删除导致令牌无效。

通常的策略是:

  1. JWT 本身设置一个相对较短的过期时间(如 2 小时或 1 天);
  2. Redis Key 设置与 JWT 同步或更短的过期时间,并在每次用户访问时“滑动更新过期时间”,从而实现“只要活跃,就保持登录”。
  3. 可以通过 Refresh Token 机制去续发一个新的 Access Token(取决于你的系统安全需求,是否要区分 Access Token 和 Refresh Token)。

四、安全性权衡

JWT 的好处是自带签名,不易被篡改,但不能随意撤销(除非你实现了 Redis 或数据库层面的对照)。
既然你已经采用了 Redis 校验,那基本就把 JWT 当作一种无状态 Token + 有状态的验证结合使用了。

  • 好处:可实现更灵活的会话管理,比如强制登出、滑动过期、权限变更即时生效等。
  • 代价:每次鉴权需要访问 Redis,再做一次校验,牺牲了 JWT “可脱离服务端缓存”的天然特性,但在分布式下依然比存数据库效率好很多,而且安全性更高。

结论

你目前的做法在很多实际项目中是常见且可行的组合拳:

  1. JWT 只放最小必要信息 (如 user_id),减少令牌大小;
  2. Redis 存储并映射用户与 Token,实现强制失效续期控制
  3. 每次请求刷新 Redis 的过期时间,在用户活跃期间保持 Token 有效;
  4. 利用 JWT 的签名防篡改和 Redis 校验,既保留了某些无状态特性,又能灵活管理会话。

需要结合自己项目的需求决定是否要:

  • 支持多设备多 Token;
  • 在 JWT 加入 jti 并以此为 Key;
  • 区分 Access Token 和 Refresh Token;
  • 在 Redis 里存更多的用户/权限信息;
  • 决定 JWT 有效期与 Redis 过期时间的策略。

如果你的场景只需要单端登录、对 Token 的撤销/续期有明确需求,并且不会出现太多“多设备共存”的场景,那么这种方案是简洁且有效的。

若以后需求扩张,需要多端登录或者更复杂的细粒度权限管理,你就可以做相应的“Key 结构”或“Claims 结构”的调整,依然可以复用这个思路,无需推翻重来。