在 Go 项目中实现 JWT 用户认证与续期机制
JWT (JSON Web Token) 是一种广泛使用的用户认证方案,因其无状态、跨域支持和灵活性而受到欢迎。本文将结合实际代码,详细讲解如何在 Go 项目中实现 JWT 认证机制,并探讨两种常见的 Token 续期策略:自动续期和 Refresh Token。
1. JWT 基础概念
JWT 由三部分组成:Header、Payload 和 Signature。使用 JWT 进行登录认证的基本工作流程是:
- 用户登录成功后,服务器生成 JWT。
- 服务器将 token 返回给客户端。
- 客户端后续请求携带 token。
- 服务器验证 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 }
-
添加了自定义字段
UserID
和UserAgent
用于安全控制。你可以根据自己的业务需求,添加任意非敏感信息到这个结构中。
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. 客户端使用流程
- 登录后获取 Access Token 和 Refresh Token
- 使用 Access Token 访问受保护资源
- 当 Access Token 过期时调用 /refresh 接口获取新的 Access Token
- 使用新的 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 续期策略对比
在前面案例中,细心的读者可以观察到我们对 AccessToken
和 RefreshToken
分别采用了 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 认证。