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 框架有基本的了解。
为了快速响应失败,本文案例中使用了封装好的异常处理机制:
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...)) }
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"` 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 }
|
添加了自定义字段 UserID
和 UserAgent
用于安全控制。你可以根据自己的业务需求,添加任意非敏感信息到这个结构中。
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) { 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 }
accessClaims := UserClaims{ UserID: user.ID, UserAgent: ctx.Request.UserAgent(), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)), }, }
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 }
refreshClaims := RefreshClaims{ UserID: user.ID, UserAgent: ctx.Request.UserAgent(), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)), }, } 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 }
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) { authCode := ctx.GetHeader("Authorization") tokenStr := strings.TrimPrefix(authCode, "Bearer ")
uc := web.UserClaims{} token, err := jwt.ParseWithClaims(tokenStr, &uc, func(token *jwt.Token) (interface{}, error) { return web.AccessTokenKey, nil })
if token == nil || !token.Valid { ctx.AbortWithStatus(http.StatusUnauthorized) return }
if uc.UserAgent != ctx.Request.UserAgent() { ctx.AbortWithStatus(http.StatusUnauthorized) return }
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) { userID := ctx.GetUint64("user_id") 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) { refreshTokenStr := ctx.GetHeader("x-refresh-token") if refreshTokenStr == "" { ctx.AbortWithStatus(http.StatusUnauthorized) return }
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 }
if refreshClaims.UserAgent != ctx.Request.UserAgent() { ctx.AbortWithStatus(http.StatusUnauthorized) return }
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 }
refreshClaims := RefreshClaims{ UserID: user.ID, UserAgent: ctx.Request.UserAgent(), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)), }, } 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 }
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. 客户端使用流程
- 登录后获取 Access Token 和 Refresh Token
- 使用 Access Token 访问受保护资源
- 当 Access Token 过期时调用 /refresh 接口获取新的 Access Token
- 使用新的 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 续期策略对比
在前面案例中,细心的读者可以观察到我们对 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 认证。