当前位置: 首页 > article >正文

在 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 框架有基本的了解。

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

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 依赖:

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

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

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

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

3.1 定义 Claims 结构

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

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

这里我们:

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

    type Claims interface {
    	GetExpirationTime() (*NumericDate, error)
    	GetIssuedAt() (*NumericDate, error)
    	GetNotBefore() (*NumericDate, error)
    	GetIssuer() (string, error)
    	GetSubject() (string, error)
    	GetAudience() (ClaimStrings, error)
    }
    

    jwt.RegisteredClaims 的实现如下:

    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 登录接口实现

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 中间件实现

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 注册中间件

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 的相关信息

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 接口

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 方法中添加新路由:

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 帮忙写的 😄):

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 认证。


http://www.kler.cn/a/548762.html

相关文章:

  • 总结前端常用数据结构 之 数组篇【JavaScript -包含常用数组方法】
  • easyCode代码模板配置
  • Mybatisplus自定义sql
  • 双指针-三数之和
  • 机器视觉--switch语句
  • 海尔小红书年度规划方案拆解
  • 使用 Ansys Fluent 进行电池热滥用失控传播仿真
  • 使用 Ansys MotorCAD 进行轴向磁通电机设计
  • Python的imutils库详细介绍
  • 详解tensorflow的tensor和Python list及Numpy矩阵的区别
  • Day7 微服务 Elasticsearch搜索引擎、DSL查询(叶子查询、复合查询、排序、分页、高亮)、RestClient查询、数据聚合
  • 网络安全学习笔记
  • 二十九、vite项目集成webpack+vue2项目
  • 深蓝学院自主泊车第2次作业-EKF
  • Vue 4.0发布!JSX vs 模板语法:谁才是前端的未来?
  • 线性表之顺序表
  • 【2025最新计算机毕业设计】基于SpringBoot+Vue高校社团管理系统 【提供源码+答辩PPT+文档+项目部署】
  • DeepSeek专题:以专业角度详细讲讲Deepseek-R1的高质量数据合成过程⌛
  • 机试刷题_字符串的排列【python】
  • 容器运行常见数据库