在 Go 项目中实现 JWT 用户认证与续期机制

JWT (JSON Web Token) 是一种广泛使用的用户认证方案,因其无状态、跨域支持和灵活性而受到欢迎。本文将结合实际代码,详细讲解如何在 Go 项目中实现 JWT 认证机制,并探讨两种常见的 Token 续期策略:自动续期和 Refresh Token。

1. JWT 基础概念

JWT 由三部分组成:Header、Payload 和 Signature。使用 JWT 进行登录认证的基本工作流程是:

  1. 用户登录成功后,服务器生成 JWT。
  2. 服务器将 token 返回给客户端。
  3. 客户端后续请求携带 token。
  4. 服务器验证 token 的有效性。

我们可以在 https://jwt.io/ 网站对 JWT 进行分析,查看其具体的组成成分。

2. 基本准备

在本篇,我们将使用 Go 语言,通过一个完整的案例实现在 HTTP 接口中,使用 JWT 进行用户登录和认证流程。本文假设读者已掌握基本的 Go 语言语法和网络编程经验,并对 Gin 框架有基本的了解。

为了快速响应失败,本文案例中使用了封装好的异常处理机制:

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
package utils

var (
ErrUser = errors.New("")
ErrSys = errors.New("")
)

// 定义用户侧错误,会直接将错误内容返回给用户,不打印日志。
func UserErr(msg string) error {
return fmt.Errorf("%w%v", ErrUser, msg)
}

func UserErrf(format string, a ...any) error {
return fmt.Errorf("%w%v", ErrUser, fmt.Sprintf(format, a...))
}

// 定义系统内部错误,会固定返回 internal server error 给用户,但是会将原始错误信息输出到日志中,便于内部排查。
func SystemErr(err error) error {
return fmt.Errorf("%w%v", ErrSys, err)
}

func SystemErrf(format string, a ...any) error {
return fmt.Errorf("%w%v", ErrSys, fmt.Sprintf(format, a...))
}

func GinErr(c *gin.Context, req any, err error, msgs ...string) {
if errors.Is(err, ErrUser) {
c.JSON(http.StatusOK, err.Error())
return
}

msg := "internal server error"
if len(msgs) > 0 {
msg = msgs[0]
}
slog.Error(msg,
slog.Any("req", req),
slog.String("err", err.Error()),
)
c.JSON(http.StatusOK, "internal server error")
}

3. 实现用户认证

在进行实际代码编写之前,你需要先初始化好项目并引入 jwt 依赖:

1
go get -u github.com/golang-jwt/jwt/v5

在代码中使用的时候,可以:

1
import "github.com/golang-jwt/jwt/v5"

那接下来我们就正式开始我们的功能实现。

3.1 定义 Claims 结构

首先,我们需要定义 JWT 的载荷(Payload)结构,即决定将什么信息存储在 token 当中。

1
2
3
4
5
type UserClaims struct {
jwt.RegisteredClaims
UserID uint64 `json:"user_id"` // 用户ID
UserAgent string `json:"user_agent"` // 用户设备信息
}

这里我们:

  • 组合了 jwt.RegisteredClaims,它包含了标准的 JWT 字段(如过期时间),帮助我们实现了 jwt.Clamis 接口:

    1
    2
    3
    4
    5
    6
    7
    8
    type Claims interface {
    GetExpirationTime() (*NumericDate, error)
    GetIssuedAt() (*NumericDate, error)
    GetNotBefore() (*NumericDate, error)
    GetIssuer() (string, error)
    GetSubject() (string, error)
    GetAudience() (ClaimStrings, error)
    }

    jwt.RegisteredClaims 的实现如下:

    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
    type RegisteredClaims struct {
    Issuer string `json:"iss,omitempty"`
    Subject string `json:"sub,omitempty"`
    Audience ClaimStrings `json:"aud,omitempty"`
    ExpiresAt *NumericDate `json:"exp,omitempty"`
    NotBefore *NumericDate `json:"nbf,omitempty"`
    IssuedAt *NumericDate `json:"iat,omitempty"`
    ID string `json:"jti,omitempty"`
    }
    func (c RegisteredClaims) GetExpirationTime() (*NumericDate, error) {
    return c.ExpiresAt, nil
    }
    func (c RegisteredClaims) GetNotBefore() (*NumericDate, error) {
    return c.NotBefore, nil
    }
    func (c RegisteredClaims) GetIssuedAt() (*NumericDate, error) {
    return c.IssuedAt, nil
    }
    func (c RegisteredClaims) GetAudience() (ClaimStrings, error) {
    return c.Audience, nil
    }
    func (c RegisteredClaims) GetIssuer() (string, error) {
    return c.Issuer, nil
    }
    func (c RegisteredClaims) GetSubject() (string, error) {
    return c.Subject, nil
    }
  • 添加了自定义字段 UserIDUserAgent 用于安全控制。你可以根据自己的业务需求,添加任意非敏感信息到这个结构中。

3.2 登录接口实现

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
const (
AccessTokenDuration = time.Minute * 15
RefreshTokenDuration = time.Hour * 24 * 7
)

func (u *UserHandler) LoginJWT(ctx *gin.Context) {
// 1. 校验用户信息,在本案例中,使用邮箱加密码进行登录
user, err := u.svc.Login(ctx.Request.Context(), req.Email, req.Password)
if err != nil {
utils.GinErr(ctx, req, utils.UserErr(err), "login failed")
return
}

// 2. 创建 JWT Claims
accessClaims := UserClaims{
UserID: user.ID,
UserAgent: ctx.Request.UserAgent(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)), // 15分钟过期
},
}

// 3. 生成 Access Token
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS512, accessClaims)
accessTokenStr, err := accessToken.SignedString(AccessTokenKey)
if err != nil {
utils.GinErr(ctx, req, utils.SystemErr(err), "generate access token failed")
return
}

// 4. 生成 Refresh Token,用于 Token 续期
refreshClaims := RefreshClaims{
UserID: user.ID,
UserAgent: ctx.Request.UserAgent(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)), // 7天过期
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS512, refreshClaims)
refreshTokenStr, err := refreshToken.SignedString(RefreshTokenKey)
if err != nil {
utils.GinErr(ctx, req, utils.SystemErr(err), "generate refresh token failed")
return
}

// 5. 返回两个 token
ctx.Header("x-jwt-token", accessTokenStr)
ctx.Header("x-refresh-token", refreshTokenStr)
ctx.JSON(http.StatusOK, "login success")
}

3.3 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
type LoginJWTMiddlewareBuilder struct {
whiteList []string
}

func NewLoginJWTMiddlewareBuilder() *LoginJWTMiddlewareBuilder {
return &LoginJWTMiddlewareBuilder{
whiteList: []string{},
}
}

func (b *LoginJWTMiddlewareBuilder) IgnorePaths(paths ...string) *LoginJWTMiddlewareBuilder {
b.whiteList = append(b.whiteList, paths...)
return b
}

func (b *LoginJWTMiddlewareBuilder) Build() gin.HandlerFunc {
return func(ctx *gin.Context) {
// 1. 提取 token
authCode := ctx.GetHeader("Authorization")
tokenStr := strings.TrimPrefix(authCode, "Bearer ")

// 2. 解析和验证 token
uc := web.UserClaims{}
token, err := jwt.ParseWithClaims(tokenStr, &uc, func(token *jwt.Token) (interface{}, error) {
return web.AccessTokenKey, nil
})

// 3. 验证 token 有效性
if token == nil || !token.Valid {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}

// 4. 验证 UserAgent
if uc.UserAgent != ctx.Request.UserAgent() {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}

// 5. 设置用户信息到上下文
ctx.Set("user_id", uc.UserID)
ctx.Set("claims", uc)
}
}

3.4 注册中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func initWebServer() *gin.Engine {
server := gin.Default()

server.Use(
middleware.CORS(),
middleware.NewLoginJWTMiddlewareBuilder().
IgnorePaths("/users/signup").
IgnorePaths("/users/login").
Build(),
)
web.RegisterRoutes(server)
return server
}

func RegisterRoutes(server *gin.Engine) {
// ...
userHandler.RegisterRoutes(server)
}

func (u *UserHandler) RegisterRoutes(server *gin.Engine) {
ur := server.Group("/users")
ur.POST("/login", u.LoginJWT)
// ...
}

4. 在其他接口中使用 Token 的相关信息

1
2
3
4
5
6
7
8
9
10
11
12
func (u *UserHandler) Profile(ctx *gin.Context) {
// 可以获取 user_id
userID := ctx.GetUint64("user_id")
// 也可以直接获取整个 claims。
// 这里我们可以选择不进行断言,因为理论上我们的可以保证这里通过断言。
// 如果这里发生 panic 了,则说明我们的内部逻辑没有形成闭环,存在问题。
// panic 可以第一时间暴露问题,然后被解决掉。
// 不过这个时候建议你使用 gin 的 recover 中间件进行全局保护,避免整个服务因为 panic 而宕机。
uc, _ := ctx.Get("claims")
userClaims := uc.(*UserClaims)
// ...
}

5. Refresh Token 机制

5.1 添加刷新 Token 接口

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
func (u *UserHandler) RefreshToken(ctx *gin.Context) {
// 从请求头获取 Refresh Token
refreshTokenStr := ctx.GetHeader("x-refresh-token")
if refreshTokenStr == "" {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}

// 解析和验证 Refresh Token
var refreshClaims RefreshClaims
refreshToken, err := jwt.ParseWithClaims(refreshTokenStr, &refreshClaims, func(token *jwt.Token) (interface{}, error) {
return RefreshTokenKey, nil
})
if err != nil || !refreshToken.Valid {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}

// 验证 User Agent
if refreshClaims.UserAgent != ctx.Request.UserAgent() {
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}

// 生成新的 Access Token
accessClaims := UserClaims{
UserID: refreshClaims.UserID,
UserAgent: ctx.Request.UserAgent(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
},
}
newAccessToken := jwt.NewWithClaims(jwt.SigningMethodHS512, accessClaims)
newAccessTokenStr, err := newAccessToken.SignedString(AccessTokenKey)
if err != nil {
utils.GinErr(ctx, nil, utils.SystemErr(err), "generate new access token failed")
return
}

// 对 Refresh Token 进行续期
refreshClaims := RefreshClaims{
UserID: user.ID,
UserAgent: ctx.Request.UserAgent(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)), // 7天过期
},
}
newRefreshToken := jwt.NewWithClaims(jwt.SigningMethodHS512, refreshClaims)
newRefreshTokenStr, err := newRefreshToken.SignedString(RefreshTokenKey)
if err != nil {
utils.GinErr(ctx, req, utils.SystemErr(err), "generate new refresh token failed")
return
}


// 返回新的 Access Token 和续期后的 Refresh Token
ctx.Header("x-jwt-token", newAccessTokenStr)
ctx.Header("x-refresh-token", newRefreshTokenStr)
ctx.JSON(http.StatusOK, "token refreshed")
}

5.2 注册路由

RegisterRoutes 方法中添加新路由:

1
2
3
4
5
6
func (u *UserHandler) RegisterRoutes(server *gin.Engine) {
ur := server.Group("/users")
ur.POST("/login", u.LoginJWT)
ur.GET("/profile", u.Profile)
ur.POST("/refresh", u.RefreshToken)
}

6. 客户端使用流程

  1. 登录后获取 Access Token 和 Refresh Token
  2. 使用 Access Token 访问受保护资源
  3. 当 Access Token 过期时调用 /refresh 接口获取新的 Access Token
  4. 使用新的 Access Token 继续访问

刷新 token 的客户端示例代码(笔者并不擅长写前端代码 hhh,所以这是让 ChatGPT 帮忙写的 😄):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function refreshAccessToken() {
const response = await fetch('/users/refresh', {
method: 'POST',
headers: {
'x-refresh-token': localStorage.getItem('refreshToken')
}
});

if (response.ok) {
const newAccessToken = response.headers.get('x-jwt-token');
localStorage.setItem('accessToken', newAccessToken);
const newRefreshToken = response.headers.get('x-refresh-token');
localStorage.setItem('refreshToken', newRefreshToken);
return newAccessToken;
}

// 如果刷新失败,重定向到登录页
window.location.href = '/login';
}

7. Token 续期策略对比

在前面案例中,细心的读者可以观察到我们对 AccessTokenRefreshToken 分别采用了 2 种不同的续期策略。

自动续期

优点:

  • 简单易用:在每次请求时自动检查并续期 Token,用户体验流畅。
  • 无额外存储需求:不需要存储 Refresh Token,减少了存储和管理的复杂性

缺点:

  • 安全性较低:如果 Token 被盗用,攻击者可以通过自动续期保持长时间的访问。
  • Token 过期时间不固定:Token 的有效期会不断延长,难以控制。

Refresh Token

优点:

  • 更高的安全性:即使 Access Token 被盗用,攻击者也无法续期,除非同时获取 Refresh Token。
  • 可控的 Token 生命周期:Access Token 有固定的短期有效期,Refresh Token 有较长的有效期。
  • 支持 Token 撤销:可以实现 Refresh Token 的黑名单机制,支持手动撤销。

缺点:

  • 实现复杂度较高:需要额外的接口和逻辑来处理 Refresh Token。
  • 存储需求:需要安全存储 Refresh Token,可能需要数据库支持。

8. 总结

JWT 实现用户认证的优势在于无状态、跨域支持和灵活性。通过合理使用 JWT 和选择合适的 Token 续期策略,我们可以构建安全、可靠的用户认证系统。希望本文能帮助您在 Go 项目中更好地实现 JWT 认证。


在 Go 项目中实现 JWT 用户认证与续期机制
https://hedon.top/2025/02/15/go-action-jwt/
Author
Hedon Wang
Posted on
2025-02-15
Licensed under