原文(有删改):
效果预览
登录
请求:
curl -X POST http://localhost:8080/login \
-d '{"username":"admin","password":"123","id":3}'
密码校验成功后,返回Token:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NfdXVpZCI6IjMzMWI3YTJjLTY2NWEtNDFmOS05YjUwLWQzYzAwNjE2ZTdlMiIsImF1dGhvcml6ZWQiOnRydWUsImV4cCI6MTY2MTE1NjkwNiwidXNlcl9pZCI6MX0.ddwa_Bbx-ueGqYQcv4NWgt0R_k5JCvyMQmfcBIUN7xM",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjE3NjA4MDYsInJlZnJlc2hfdXVpZCI6IjhkMDhlMmU1LTIxMzMtNDM2NS1hOGE0LWQ1ZTNhMDk4OWMwOSIsInVzZXJfaWQiOjF9.MS2slR6IoF2FX7gtG69k-It31dVGtB5rZSArbAq_C0E"
}
认证拦截演示
请求(不带token):
curl -X POST http://localhost:8080/todo \
-d '{"user_id":1,"title":"this is todo"}' \
此时,会返回需要认证的提示:
unauthorized
带上token后,请求正常:
curl -X POST http://localhost:8080/todo \
-d '{"user_id":1,"title":"this is todo"}' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NfdXVpZCI6ImE5MGEwN2Y5LTE0OTgtNDc3YS1hNDdhLWMyY2FiNWQ3NzE3NiIsImF1dGhvcml6ZWQiOnRydWUsImV4cCI6MTY2MTMxNDgwMSwidXNlcl9pZCI6MX0.6aImjI_ATsuZ9ASdoDetFCBMQpL3wIpnjxeQcJhyexU'
响应结果:
{"user_id":1,"title":"this is todo"}
正文开始
简介
JSON Web Token (JWT)
是一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息,开发人员通常在其 API 中使用它们。
JWT 很受欢迎,因为:
-
JWT 是无状态的
。也就是说,与不透明令牌不同,它不需要存储在数据库(持久层)中。 -
JWT 的签名一旦形成就永远不会被解码
,从而确保令牌是安全可靠的。 -
可以将 JWT 设置为在一段时间后失效
。如果令牌被劫持,这有助于最大限度地减少或完全消除黑客可能造成的任何损害。
在本教程中,我将使用 Golang 和 Gin 通过简单的 RESTful API 演示 JWT 的创建、使用和失效。
JWT 的组成部分
JWT 由三部分组成:
-
Header
(头部):包含令牌的类型和使用的签名算法。令牌的类型可以是“JWT”,而签名算法可以是 HMAC 或 SHA256。 -
Payload
(有效负载):令牌的第二部分,包含Claims(声明)。这些声明包括应用程序特定数据(例如,用户 id、用户名)、令牌到期时间(exp)、颁发者(iss)、主题(sub)等。 -
Signature
(签名):编码的标头、编码的有效负载和您提供的秘密用于创建签名。
让我们用一个简单的 Token 来理解上面的概念。
Token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoX3V1aWQiOiIxZGQ5MDEwYy00MzI4LTRmZjMtYjllNi05NDRkODQ4ZTkzNzUiLCJhdXRob3JpemVkIjp0cnVlLCJ1c2VyX2lkIjo3fQ.Qy8l-9GUFsXQm4jqgswAYTAX9F4cngrl28WJVYNDwtM
别担心,令牌是无效的,所以它不会在任何生产应用程序上工作。
您可以导航到 jwt.to 并测试令牌签名是否经过验证。使用“HS512”作为算法。您将收到“签名已验证”消息:
要进行签名,您的应用程序需要提供密钥。此密钥使签名能够保持安全——即使 JWT 被解码,签名仍然是加密的。强烈建议在创建 JWT 时始终使用密钥。
Token 类型
由于可以将 JWT 设置为在特定时间段后过期(失效),因此在此应用程序中将考虑两个令牌:
-
AccessToken
:用于需要身份验证的请求。它通常添加在请求的标头中。建议AccessToken生命周期尽可能短,例如15 分钟
。如果用户的令牌被篡改,在令牌被劫持的情况下,给予访问令牌较短的时间跨度可以防止任何严重的损害。在令牌失效之前,黑客只有 15 分钟或更短的时间来执行他的操作。 -
RefreshToken
:刷新令牌的寿命更长,通常为 7 天
。此令牌用于生成新的访问和刷新令牌。如果访问令牌过期,则在刷新令牌路由(来自我们的应用程序)时创建新的访问和刷新令牌集。
在哪里存储 JWT(前端)
对于生产级应用程序,强烈建议将 JWT 存储在 HttpOnly
cookie 中。为了实现这一点,在将后端生成的 cookie 发送到前端(客户端)时,HttpOnly
会随 cookie 一起发送一个标志,指示浏览器不要通过客户端脚本显示 cookie。这样做可以防止 XSS(跨站点脚本)攻击。JWT 也可以存储在浏览器本地存储或会话存储中。以这种方式存储 JWT 可能会使其遭受多种攻击,例如上述 XSS,因此与使用 HttpOnly
cookie 技术相比,它通常不太安全。
应用程序示例
初始化
我们将考虑一个简单的todo RESTful API来一步一步如何实现jwt认证,它具有3个API:
- 登录
- 登出
- 创建待办事项
首先,创建一个名为 jwt-todo
的目录,然后初始化 go mod
:
go mod init jwt-todo
然后,在根目录 /jwt-todo 下创建一个 main.go
的文件,写入如下内容:
package main
func main() {}
我们将使用 Gin
来路由和处理 HTTP 请求。Gin 框架有助于减少样板代码,并且在构建可扩展的 API 方面非常有效。
如果您还没有安装 gin,您可以使用:
go get github.com/gin-gonic
然后更新 main.go
文件:
package main
import (
"github.com/gin-gonic/gin"
"log"
)
var (
router = gin.Default()
)
func main() {
router.POST("/login", Login)
log.Fatal(router.Run(":8080"))
}
在实际场景中,/login
获取用户输入的账户和密码,然后通过查询数据库完成整个登录过程。
但是在这个 API 中,为了保持简单,我们从内存中检查用户信息,并且内置一个用户,他的用户名是 username
,密码是 password
。
在 main.go
文件中增加如下结构体:
type User struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
}
//A sample use
var user = User{
ID: 1,
Username: "username",
Password: “password",
}
实现登录请求
我们定义一个 Login
函数来实现登录功能,它的主要逻辑如下:
- 解析 POST 请求,得到用户名和密码
- 从内存中校验用户名和密码是否正确以及匹配,如果我们使用数据库,我们会将其与数据库中的记录进行比较。
- 创建并返回一个Token
代码实现:
func Login(c *gin.Context) {
var u User
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(http.StatusUnprocessableEntity, "Invalid json provided")
return
}
//compare the user from the request, with the one we defined:
if user.Username != u.Username || user.Password != u.Password {
c.JSON(http.StatusUnauthorized, "Please provide valid login details")
return
}
token, err := CreateToken(user.ID)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, err.Error())
return
}
c.JSON(http.StatusOK, token)
}
为了不让 Login
函数臃肿,生成 JWT 的逻辑放在 CreateToken
函数中, 用户 id 被传递给这个函数,并且保存进JWT 的Claims中。
该 CreateToken
函数使用 dgrijalva/jwt-go
包,我们可以使用以下命令安装它:
go get github.com/dgrijalva/jwt-go
让我们定义 CreateToken
函数:
func CreateToken(userid uint64) (string, error) {
var err error
//Creating Access Token
os.Setenv("ACCESS_SECRET", "jdnfksdmfksd") //this should be in an env file
atClaims := jwt.MapClaims{}
atClaims["authorized"] = true
atClaims["user_id"] = userid
atClaims["exp"] = time.Now().Add(time.Minute * 15).Unix()
at := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims)
token, err := at.SignedString([]byte(os.Getenv("ACCESS_SECRET")))
if err != nil {
return "", err
}
return token, nil
}
我们将令牌设置为仅在 15 分钟
内有效,之后,它就无效并且不能用于任何经过身份验证的请求。此外,请注意我们使用从环境变量中获得的秘密( ) 对 JWT 进行了签名。ACCESS_SECRET
强烈建议不要在你的代码库中公开这个秘密,而是像我们上面所做的那样从环境变量中获取。您可以将其保存在 .env
,.yml
或任何适合您的文件中。
到目前为止,我们的 main.go
文件如下所示:
package main
import (
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"log"
"net/http"
"os"
"time"
)
var (
router = gin.Default()
)
func main() {
router.POST("/login", Login)
log.Fatal(router.Run(":8080"))
}
type User struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
}
//A sample use
var user = User{
ID: 1,
Username: "admin",
Password: "123",
}
func Login(c *gin.Context) {
var u User
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(http.StatusUnprocessableEntity, "Invalid json provided")
return
}
//compare the user from the request, with the one we defined:
if user.Username != u.Username || user.Password != u.Password {
c.JSON(http.StatusUnauthorized, "Please provide valid login details")
return
}
token, err := CreateToken(user.ID)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, err.Error())
return
}
c.JSON(http.StatusOK, token)
}
func CreateToken(userid uint64) (string, error) {
var err error
//Creating Access Token
os.Setenv("ACCESS_SECRET", "jdnfksdmfksd") //this should be in an env file
atClaims := jwt.MapClaims{}
atClaims["authorized"] = true
atClaims["user_id"] = userid
atClaims["exp"] = time.Now().Add(time.Minute * 15).Unix()
at := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims)
token, err := at.SignedString([]byte(os.Getenv("ACCESS_SECRET")))
if err != nil {
return "", err
}
return token, nil
}
我们现在可以运行应用程序:
go run main.go
现在我们可以尝试一下,看看我们得到了什么!启动您最喜欢的 API 工具并点击Send:
如上所示,我们生成了一个持续 15 分钟的 JWT Token。
缺陷
是的,我们可以登录一个用户并生成一个 JWT,但是上面的实现有很多错误:
-
JWT 只能在过期时失效
。如果用户登录后立即登出,但用户的 JWT 仍然有效,直到达到过期时间。 -
JWT 可能会被黑客劫持和使用
。却无需用户对其进行任何操作,直到令牌过期。 - 用户在令牌过期后需要重新登录,从而导致
用户体验不佳
。
我们可以通过两种方式解决上述问题:
-
使用持久存储层来存储 JWT 元数据
。这将使我们能够在用户注销的那一秒使 JWT 失效,从而提高安全性。 - 在访问令牌过期的情况下,
使用刷新令牌的概念生成新的访问令牌
,从而改善用户体验。
使用 Redis 存储 JWT 元数据
为了解决用户登出立即使JWT失效的问题,我们需要将 JWT 元数据保存在持久层中。
这可以在任何的持久层中完成,但强烈建议使用 Redis,主要是:
- 由于我们生成的 JWT 有过期时间,所以 Redis 有一个功能可以自动删除到过期时间的数据。
- Redis 还可以处理大量写入,并且可以水平扩展。
由于 Redis 是一个键值存储,它的键需要唯一,为了实现这一点,我们将使用 uuid
作为键,使用user_id
作为值。
所以,让我们分别安装 redis 包和生成 uuid 的包:
go get github.com/go-redis/redis/v7
go get github.com/twinj/uuid
然后在 main.go 中导入它们:
import (
…
"github.com/go-redis/redis/v7"
"github.com/twinj/uuid"
…
)
注意:您的本地机器上应该已经安装了 redis。如果没有,您可以暂停并执行此操作,然后再继续。
现在让我们初始化 Redis:
var client *redis.Client
func init() {
//Initializing redis
dsn := os.Getenv("REDIS_DSN")
if len(dsn) == 0 {
dsn = "localhost:6379"
}
client = redis.NewClient(&redis.Options{
Addr: dsn, //redis port
})
_, err := client.Ping().Result()
if err != nil {
panic(err)
}
}
Redis 客户端在 init()
函数中初始化。这确保了我们每次运行 main.go
文件时,都会自动连接 Redis。
所以,后面我们创建一个令牌时,我们还将生成一个 uuid
并保存进 Claims 中,就像我们在之前的实现中使用用户 ID 保存进Clamins(声明)一样。
定义元数据
在我们提出的解决方案中,我们需要创建两个 JWT令牌,而不是只创建一个令牌:
- AccessToken(访问令牌)
- RefreshToken(刷新令牌)
为了实现这一点,我们需要定义一个结构来容纳这些令牌定义、它们的过期时间 和 uuid:
type TokenDetails struct {
AccessToken string
RefreshToken string
AccessUuid string
RefreshUuid string
AtExpires int64
RtExpires int64
}
过期时间和 uuid 将在 redis 中保存令牌元数据时被使用。现在,让我们将 CreateToken
函数更新为如下所示:
func CreateToken(userid uint64) (*TokenDetails, error) {
td := &TokenDetails{}
td.AtExpires = time.Now().Add(time.Minute * 15).Unix()
td.AccessUuid = uuid.NewV4().String()
td.RtExpires = time.Now().Add(time.Hour * 24 * 7).Unix()
td.RefreshUuid = uuid.NewV4().String()
var err error
//Creating Access Token
os.Setenv("ACCESS_SECRET", "jdnfksdmfksd") //this should be in an env file
atClaims := jwt.MapClaims{}
atClaims["authorized"] = true
atClaims["access_uuid"] = td.AccessUuid
atClaims["user_id"] = userid
atClaims["exp"] = td.AtExpires
at := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims)
td.AccessToken, err = at.SignedString([]byte(os.Getenv("ACCESS_SECRET")))
if err != nil {
return nil, err
}
//Creating Refresh Token
os.Setenv("REFRESH_SECRET", "mcmvmkmsdnfsdmfdsjf") //this should be in an env file
rtClaims := jwt.MapClaims{}
rtClaims["refresh_uuid"] = td.RefreshUuid
rtClaims["user_id"] = userid
rtClaims["exp"] = td.RtExpires
rt := jwt.NewWithClaims(jwt.SigningMethodHS256, rtClaims)
td.RefreshToken, err = rt.SignedString([]byte(os.Getenv("REFRESH_SECRET")))
if err != nil {
return nil, err
}
return td, nil
}
在上述函数中,Access Token
在 15 分钟后过期,Refresh Token
在 7 天后过期。您还可以看到我们为每个令牌添加了一个 uuid 作为Claims(声明)的一部分。
由于 uuid 每次创建时都是唯一的,因此用户可以创建多个令牌。当用户在不同的设备上登录时会发生这种情况。用户还可以从任何设备注销,而无需从所有设备注销
。挺酷的!
保存 JWT 元数据
现在创建一个 CreateAuth
函数,来让我们把 JWT 元数据保存到 redis 中:
func CreateAuth(userid uint64, td *TokenDetails) error {
at := time.Unix(td.AtExpires, 0) //converting Unix to UTC(to Time object)
rt := time.Unix(td.RtExpires, 0)
now := time.Now()
errAccess := client.Set(td.AccessUuid, strconv.Itoa(int(userid)), at.Sub(now)).Err()
if errAccess != nil {
return errAccess
}
errRefresh := client.Set(td.RefreshUuid, strconv.Itoa(int(userid)), rt.Sub(now)).Err()
if errRefresh != nil {
return errRefresh
}
return nil
}
我们传入了 TokenDetails
其中包含有关 JWT 的过期时间和创建 JWT 时使用的 uuid 的信息。如果 刷新令牌 或 访问令牌 达到过期时间,则 JWT 会自动从 Redis 中删除。
我个人使用Redily,一个 Redis GUI。是一个不错的工具。您可以在下面查看 JWT 元数据是如何存储在 reids 的键值对中的。
在我们再次测试登录之前,我们需要在 Login()
函数中调用 CreateAuth()
:
func Login(c *gin.Context) {
var u User
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(http.StatusUnprocessableEntity, "Invalid json provided")
return
}
//compare the user from the request, with the one we defined:
if user.Username != u.Username || user.Password != u.Password {
c.JSON(http.StatusUnauthorized, "Please provide valid login details")
return
}
// 修改:token 改成了 ts,是一个结构体
ts, err := CreateToken(user.ID)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, err.Error())
return
}
// 新增:保存 jwt 元数据到redis中
saveErr := CreateAuth(user.ID, ts)
if saveErr != nil {
c.JSON(http.StatusUnprocessableEntity, saveErr.Error())
}
tokens := map[string]string{
"access_token": ts.AccessToken,
"refresh_token": ts.RefreshToken,
}
// 修改:现在,返回2个jwt token
c.JSON(http.StatusOK, tokens)
}
我们可以尝试重新登录。保存 main.go
文件并运行它。当 Postman 登录时,我们应该同时得到2个token:
完美!我们既有 access_token
和 refresh_token
,也有令牌元数据持久化在 redis 中。
创建一个需要认证的API:创建待办事项API
现在,我们就可以对请求进行 JWT 身份验证了,在本教程中需要进行身份验证的请求之一是创建待办事项API。
首先,让我们定义一个 Todo
结构:
type Todo struct {
UserID uint64 `json:"user_id"`
Title string `json:"title"`
}
在执行请求的身份验证时,我们需要验证 Header
(标头)中传递的令牌是否有效。我们需要定义一些帮助函数来解决这些问题。
首先,我们需要使用以下 ExtractToken
函数从Header(请求头)中提取令牌:
func ExtractToken(r *http.Request) string {
bearToken := r.Header.Get("Authorization")
//normally Authorization the_token_xxx
strArr := strings.Split(bearToken, " ")
if len(strArr) == 2 {
return strArr[1]
}
return ""
}
然后定义 VerifyToken
函数来验证令牌:
func VerifyToken(r *http.Request) (*jwt.Token, error) {
tokenString := ExtractToken(r)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
//Make sure that the token method conform to "SigningMethodHMAC"
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(os.Getenv("ACCESS_SECRET")), nil
})
if err != nil {
return nil, err
}
return token, nil
}
我们在函数 VerifyToken
中调用 ExtractToken
以获取令牌字符串,然后检查签名方法。
我们还需要创建 TokenValid
函数,来检查此令牌的有效性,它是否仍然有用或已过期 :
func TokenValid(r *http.Request) error {
token, err := VerifyToken(r)
if err != nil {
return err
}
if _, ok := token.Claims.(jwt.Claims); !ok && !token.Valid {
return err
}
return nil
}
另外,我们还将提取在之前设置的 Redis 存储中查找的令牌元数据。为了提取令牌,我们定义了ExtractTokenMetadata
函数:
func ExtractTokenMetadata(r *http.Request) (*AccessDetails, error) {
token, err := VerifyToken(r)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if ok && token.Valid {
accessUuid, ok := claims["access_uuid"].(string)
if !ok {
return nil, err
}
userId, err := strconv.ParseUint(fmt.Sprintf("%.f", claims["user_id"]), 10, 64)
if err != nil {
return nil, err
}
return &AccessDetails{
AccessUuid: accessUuid,
UserId: userId,
}, nil
}
return nil, err
}
该ExtractTokenMetadata 函数返回一个 AccessDetails
(它是一个结构)。该结构包含我们需要在 Redis 中进行查找的元数据 ( access_uuid )。user_id如果有任何原因我们无法从此令牌中获取元数据,则请求将停止并显示错误消息。
上面提到的AccessDetails结构如下所示:
type AccessDetails struct {
AccessUuid string
UserId uint64
}
我们还提到了在 Redis 中查找令牌元数据。让我们定义一个使我们能够做到这一点的函数:
func FetchAuth(authD *AccessDetails) (uint64, error) {
userid, err := client.Get(authD.AccessUuid).Result()
if err != nil {
return 0, err
}
userID, _ := strconv.ParseUint(userid, 10, 64)
return userID, nil
}
FetchAuth()
接收从函数 ExtractTokenMetadata
返回的AccessDetails
元数据 ,然后在 redis
中查找。如果未找到记录,则可能意味着令牌已过期,因此会引发错误。
让我们最后把 CreateTodo
函数连接起来,以便更好地理解上述函数的实现:
func CreateTodo(c *gin.Context) {
var td *Todo
if err := c.ShouldBindJSON(&td); err != nil {
c.JSON(http.StatusUnprocessableEntity, "invalid json")
return
}
tokenAuth, err := ExtractTokenMetadata(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, "unauthorized")
return
}
userId, err := FetchAuth(tokenAuth)
if err != nil {
c.JSON(http.StatusUnauthorized, "unauthorized")
return
}
td.UserID = userId
//you can proceed to save the Todo to a database
//but we will just return it to the caller here:
c.JSON(http.StatusCreated, td)
}
正如所见,我们调用 ExtractTokenMetadata
来提取 JWT元数据,用 FetchAuth
检查元数据是否仍然存在于我们的 Redis 存储中。如果一切正常,则可以将 Todo 保存到数据库中,但我们选择将其返回给调用者。
让我们更新 main()
以包含该 CreateTodo
功能:
func main() {
router.POST("/login", Login)
router.POST("/todo", CreateTodo)
log.Fatal(router.Run(":8080"))
}
要测试 CreateTodo
API,请调用Login登录并复制其返回的 access_token
字段,添加到 Authorization Bearer Token中,如下所示:
然后在请求正文中添加 title 以创建待办事项并,并向
/todo
发起post请求,如下所示:尝试在没有设置 access_token 的情况下创建待办事项是未经授权的:
登出请求
到目前为止,我们已经了解了如何使用 JWT 来发出经过身份验证的请求。当用户注销时,我们将立即撤销/使他们的 JWT 无效
。这是通过从我们的 redis 存储中删除 JWT 元数据来实现的。
我们现在将定义一个函数,使我们能够从 redis 中删除 JWT 元数据:
func DeleteAuth(givenUuid string) (int64,error) {
deleted, err := client.Del(givenUuid).Result()
if err != nil {
return 0, err
}
return deleted, nil
}
uuid 上面的函数会删除redis中作为参数传入的对应的记录。
该 Logout 函数如下所示:
func Logout(c *gin.Context) {
au, err := ExtractTokenMetadata(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, "unauthorized")
return
}
deleted, delErr := DeleteAuth(au.AccessUuid)
if delErr != nil || deleted == 0 { //if any goes wrong
c.JSON(http.StatusUnauthorized, "unauthorized")
return
}
c.JSON(http.StatusOK, "Successfully logged out")
}
在 Logout
函数中,我们首先提取了 JWT 元数据。如果成功,我们将继续删除该元数据,从而立即使 JWT 无效。
在测试之前,更新 main.go 文件以包含 logout 端点,如下所示:
func main() {
router.POST("/login", Login)
router.POST("/todo", CreateTodo)
router.POST("/logout", Logout)
log.Fatal(router.Run(":8080"))
}
提供 access_token
与用户关联的有效值,然后注销该用户。记得添加 access_token
到Authorization Bearer Token,然后点击注销:
现在用户已注销,无法再次使用该 JWT 执行进一步的请求,因为它立即失效。这种实现比在用户注销后等待 JWT 过期更安全。
保护经过身份验证的路由
我们有两条需要身份验证的路由:/todo
和 /logout
。现在,无论有没有登录过,任何人都可以访问这些路由。让我们改变它。
我们需要定义 TokenAuthMiddleware()
函数来保护这些路由:
func TokenAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
err := TokenValid(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, err.Error())
c.Abort()
return
}
c.Next()
}
}
如上所示,我们调用 TokenValid()
函数(之前定义)来检查令牌是否仍然有效或已过期。该功能将用于经过身份验证的路由以保护它们。现在让我们更新 main.go
以包含此中间件:
func main() {
router.POST("/login", Login)
router.POST("/todo", TokenAuthMiddleware(), CreateTodo)
router.POST("/logout", TokenAuthMiddleware(), Logout)
log.Fatal(router.Run(":8080"))
}
刷新令牌
到目前为止,我们可以创建、使用和撤销 JWT。在涉及用户界面的应用程序中,如果访问令牌过期并且用户发出的请求需要身份认证时,会发生什么情况?用户是否会未经授权,并被要求重新登录
?
不幸的是,情况将会如此。但这可以使用刷新令牌的概念来避免。用户无需重新登录,与访问令牌一起创建的刷新令牌将用于创建新的访问令牌和刷新令牌对
。
在 JavaScript 中使用我们的 API时 ,我们可以使用 axios
拦截器轻而易举地刷新 JWT :发送一个以 refresh_token 为主体的 POST 请求 /token/refresh
。
为了实现刷新AccessToken令牌的功能,让我们首先创建 Refresh() 函数:
func Refresh(c *gin.Context) {
mapToken := map[string]string{}
if err := c.ShouldBindJSON(&mapToken); err != nil {
c.JSON(http.StatusUnprocessableEntity, err.Error())
return
}
refreshToken := mapToken["refresh_token"]
//verify the token
os.Setenv("REFRESH_SECRET", "mcmvmkmsdnfsdmfdsjf") //this should be in an env file
token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) {
//Make sure that the token method conform to "SigningMethodHMAC"
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(os.Getenv("REFRESH_SECRET")), nil
})
//if there is an error, the token must have expired
if err != nil {
c.JSON(http.StatusUnauthorized, "Refresh token expired")
return
}
//is token valid?
if _, ok := token.Claims.(jwt.Claims); !ok && !token.Valid {
c.JSON(http.StatusUnauthorized, err)
return
}
//Since token is valid, get the uuid:
claims, ok := token.Claims.(jwt.MapClaims) //the token claims should conform to MapClaims
if ok && token.Valid {
refreshUuid, ok := claims["refresh_uuid"].(string) //convert the interface to string
if !ok {
c.JSON(http.StatusUnprocessableEntity, err)
return
}
userId, err := strconv.ParseUint(fmt.Sprintf("%.f", claims["user_id"]), 10, 64)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, "Error occurred")
return
}
//Delete the previous Refresh Token
deleted, delErr := DeleteAuth(refreshUuid)
if delErr != nil || deleted == 0 { //if any goes wrong
c.JSON(http.StatusUnauthorized, "unauthorized")
return
}
//Create new pairs of refresh and access tokens
ts, createErr := CreateToken(userId)
if createErr != nil {
c.JSON(http.StatusForbidden, createErr.Error())
return
}
//save the tokens metadata to redis
saveErr := CreateAuth(userId, ts)
if saveErr != nil {
c.JSON(http.StatusForbidden, saveErr.Error())
return
}
tokens := map[string]string{
"access_token": ts.AccessToken,
"refresh_token": ts.RefreshToken,
}
c.JSON(http.StatusCreated, tokens)
} else {
c.JSON(http.StatusUnauthorized, "refresh expired")
}
}
虽然该功能发生了很多事情,但让我们尝试了解流程。
- 我们首先从请求正文中获取 refresh_token。
- 然后我们验证了令牌的签名方法。
- 接下来,检查令牌是否仍然有效。
- 然后提取 refresh_uuid 和 user_id,它们是创建刷新令牌时用作声明的元数据。
- 然后我们在 redis 存储中搜索元数据并使用 refresh_uuid 键将其删除。
- 然后,我们创建一对新的访问和刷新令牌,将用于未来的请求。
- 将创建的访问令牌和刷新令牌的元数据保存在 redis 中。
- 创建的令牌返回给调用者。
- 在 else 语句中,如果刷新令牌无效,则不允许用户创建新的一对令牌。我们需要重新登录才能获得新的令牌。
接下来,在函数中添加刷新令牌路由main():
router.POST("/token/refresh", Refresh)
然后,让我们来测试一下:
我们已经成功创建了新的令牌对。太棒了