警告
本文最后更新于 2019-10-13,文中内容可能已过时。
本文翻译自《Implementing JWT based authentication in Golang》。全文如下:
认证是让您的应用程序知道向您的应用程序发送的请求
一定是请求人
发送的。JSON网络令牌(JWT)是一种允许认证的方法,它不需要在系统本身存储任何关于用户的信息(与基于session based authentication相反)。
在这篇文章中,我们将演示基于JWT的身份验证是如何工作的,以及如何在Go中构建一个示例应用程序来实现它。
如果你已经知道JWT是如何工作的,只是想看看它的实现,你可以跳过前面的内容,或者在Github上看看源代码。
假设我们有一个叫user1的用户,他们试图登录一个应用程序或网站。一旦成功,他们会收到一个类似这样的令牌。
1
|
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ.2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54
|
这是一个JWT,由三部分组成(用.分隔)。
-
第一部分是header
(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9)。头部指定了用于生成签名的算法等信息(第三部分)。这一部分是非常标准的,对于任何使用相同算法的JWT都是一样的。
-
第二部分是payload
(eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ),其中包含应用的特定信息(在我们的例子中,这是用户名),以及有关令牌的到期时间和有效性的信息。
-
第三部分是signature
(2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54)。它是由前两部分和一个秘密密钥组合和散列生成的。
现在有趣的是,header
和payload
没有加密。它们只是base64编码。这意味着,任何人都可以通过解码来查看它们的内容。
例如,我们可以使用这个在线工具,并解码header
或payload
。
header
的内容:
1
|
{ "alg": "HS256", "typ": "JWT" }
|
如果您使用的是Linux或Mac OS,则还可以在终端上执行以下语句:
echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d
类似地,payload
的内容为:
1
|
{ "username": "user1", "exp": 1547974082 }
|
因此,如果JWT的头和签名可以被任何人读取和写入,那么实际上是什么使JWT安全?答案在于最后一部分(签名)是如何生成的。
假设你是一个应用程序,想要向一个已经成功登录的用户(例如,user1)发出JWT。
制作header
和payload
是非常简单的。header
或多或少是固定的,而payload
是JSON对象是通过设置用户ID和到期时间(以unix毫秒为单位)形成的。
签署令牌的应用还会有一个密钥,这是一个秘密值,只有应用自己知道。然后,header
和payload
的base64表示与秘钥相结合,然后通过散列算法(在本例中是HS256
,正如头中提到的那样
关于算法如何实现的细节不在本篇文章的讨论范围内,但要注意的是,它是单向的,这意味着我们无法逆转算法并获得用于制作签名的组件……所以我们的秘钥仍然是秘密的。
为了核实接收的JWT,将再次使用接收的JWT的header
和payload
以及秘钥生成的signature
。如果签名与JWT上的签名相匹配,那么JWT被认为是有效的。
现在让我们假设你是一个黑客,试图发行一个假令牌。您可以轻松地生成header
和payload
,但如果不知道密钥,就无法生成有效的签名。如果你试图篡改有效JWT的现有payload
,signature
将不再匹配。
这样,JWT可以以一种安全的方式授权用户,而无需在应用程序服务器上实际存储任何信息(除了密钥)。
现在,我们已经了解了基于JWT的身份验证的工作原理,让我们使用Go来实现它。
首先,使用所需的路由初始化HTTP服务器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
package main
import (
"log"
"net/http"
)
func main() {
// "Signin "和 "Welcome "是我们要实现的处理程序。
http.HandleFunc("/signin", Signin)
http.HandleFunc("/welcome", Welcome)
http.HandleFunc("/refresh", Refresh)
// 启动8000端口的服务器
log.Fatal(http.ListenAndServe(":8000", nil))
}
|
现在,我们可以定义Signin
和Welcome
routes。
/signin
路由将获取用户凭据并登录。为简化起见,我们将用户信息存储在内存:
1
2
3
4
|
var users = map[string]string{
"user1": "password1",
"user2": "password2",
}
|
因此,目前,我们的应用程序中只有两个有效用户:user1
和user2
。接下来,我们可以编写Signin
HTTP处理程序。对于此示例,我们使用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
|
import (
//...
// 导入 jwt-go
"github.com/dgrijalva/jwt-go"
//...
)
// 用于signature的JWT密钥
var jwtKey = []byte("my_secret_key")
var users = map[string]string{
"user1": "password1",
"user2": "password2",
}
// 创建一个结构来读取请求body中的用户名和密码。
type Credentials struct {
Password string `json:"password"`
Username string `json:"username"`
}
// 创建一个将被编码为JWT的结构(payload)。我们添加jwt.StandardClaims作为嵌入式类型,以提供诸如到期时间等字段。
type Claims struct {
Username string `json:"username"`
jwt.StandardClaims
}
// 创建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 {
// 如果body错误返回http错误
w.WriteHeader(http.StatusBadRequest)
return
}
// 从内存中获取期望的密码
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 := &Claims{
Username: creds.Username,
StandardClaims: jwt.StandardClaims{
// 在JWT中,到期时间用unix毫秒表示。
ExpiresAt: expirationTime.Unix(),
},
}
// 通过签名算法和claims创建token(未经过hash的JWT,可以理解成header+payload)。
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 生成的JWT
tokenString, err := token.SignedString(jwtKey)
if err != nil {
// 如果在创建JWT时出现错误,则返回一个内部服务器错误。
w.WriteHeader(http.StatusInternalServerError)
return
}
// 最后,我们将 "token "的客户端cookie设置为我们刚刚生成的JWT。
// 我们还设置了一个到期时间,这个到期时间和令牌本身是一样的
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: tokenString,
Expires: expirationTime,
})
}
|
如果用户用正确的凭证登录,该处理程序将在客户端设置一个带有JWT值的cookie。一旦在客户端上设置了cookie,它就会随着每一个请求一起发送。现在我们可以编写我们的欢迎处理程序来处理用户的特定信息。
现在,所有已登录的客户端都将会话信息以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
|
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`.注意,我们在这个方法中也传递了密钥。如果token无效(如果根据我们在登录时设置的到期时间,它已经过期了),或者如果签名不匹配,这个方法将返回一个错误。
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
}
// 最后,向用户返回欢迎信息,以及用户在token中给出的用户名。
w.Write([]byte(fmt.Sprintf("Welcome %s!", claims.Username)))
}
|
在这个例子中,我们设置了一个很短的过期时间,即5分钟。如果用户的token过期,用户就需要每五分钟登录一次。为了解决这个问题,我们将创建另一个/refresh
路由,该路由使用之前的令牌(仍然有效),并返回一个更新过期时间的新令牌。
为了最大限度地减少对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
|
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 "路径的第一部分相同。
// 我们确保在足够的时间内才会发出新的令牌。否则,返回一个坏的请求状态
if time.Unix(claims.ExpiresAt, 0).Sub(time.Now()) > 30*time.Second {
w.WriteHeader(http.StatusBadRequest)
return
}
// 现在,为当前用途创建一个新的令牌,并更新到期时间。
expirationTime := time.Now().Add(5 * time.Minute)
claims.ExpiresAt = expirationTime.Unix()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
// 将新的token设置为用户的`token`cookie。
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: tokenString,
Expires: expirationTime,
})
}
|
要运行此应用程序,请构建并运行Go二进制文件。
1
2
|
go build
./jwt-go-example。
|
现在,使用任何支持cookie的HTTP客户端(如Postman,或你的网络浏览器),用适当的凭证发出登录请求。
1
2
3
4
5
6
|
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"username":"user1","password":"password1"}' http://127.0.0.1:8000/signin
HTTP/1.1 200 OK
Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNjE4MzI2NjY0fQ.T2w0b2HLtHyytEmGxvbWKk4grjoyIEsDvKAlNhKOP4o; Expires=Sun, 13 Oct 2019 13:03:31 GMT
Date: Sun, 13 Oct 2019 12:58:31 GMT
Content-Length: 12
Content-Type: text/plain; charset=utf-8
|
现在你可以尝试从同一个客户端打出欢迎路径,来获取欢迎信息。
1
2
3
4
5
6
7
|
$ curl -i -X GET -b "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNjE4MzI2NjY0fQ.T2w0b2HLtHyytEmGxvbWKk4grjoyIEsDvKAlNhKOP4o; Expires=Sun, 13 Oct 2019 15:11:04 GMT" http://127.0.0.1:8000/welcome
HTTP/1.1 200 OK
Date: Tue, 13 Apr 2021 15:08:01 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
Welcome user1!
|
点击刷新路径,然后检查客户端cookie,查看token cookie的新值。
1
2
3
4
5
|
$ curl -i -X POST -b "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNjE4MzI2NjY0fQ.T2w0b2HLtHyytEmGxvbWKk4grjoyIEsDvKAlNhKOP4o; Expires=Sun, 13 Oct 2019 15:11:04 GMT" http://127.0.0.1:8000/refresh
HTTP/1.1 200 OK
Set-Cookie: session_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNjE4MzI2OTUzfQ.Nya_cQMX4KsKk7J39F0Cv1KvK4AGgvpfKTSd8DyqUaY; Expires=Tue, 13 Apr 2021 15:15:53 GMT
Date: Tue, 13 Apr 2021 15:10:53 GMT
Content-Length: 0
|