Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Go语言JWT Token签发校验与续期注销

Go语言JWT Token签发校验与续期注销实践

作者:菊果子

本文主要介绍了Go语言JWT Token签发校验与续期注销实践,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧

做Web后端开发,尤其是用Go写服务的时候,Token验证这件事躲不掉。HTTP本身就无状态,用户登录以后,服务器怎么认出下一次请求还是同一个客户端?最通用的方案就是给客户端发一个Token,后续请求带着它走。我这些年见过不少项目在Token上踩坑,有的直接拿MapClaims塞用户信息,有的验证时连算法都没锁,有的Token过期时间设计成一个月,访问一次就永远挂着。今天就把Go语言里Token签发、校验、续期、注销这条完整链路拆开讲一遍,把我踩过的坑和工程上比较稳妥的取舍也一起交代清楚。

这篇文章适合正在用Go写Web接口、准备给项目接入登录态的同学,也适合已经在用JWT但发现“登出无效、过期时间难控”等问题的朋友。你会看到一个从设计原理到可运行代码的完整方案,顺便了解为什么有些看起来很美的简化写法,上线后会变成安全事故。

1. 先说清楚:Token到底解决什么问题

1.1 无状态HTTP下的身份识别困境

Web服务的接口成千上万,但HTTP协议不记得上一次请求是谁发的。传统做法是服务端存Session,然后把一个SessionID发给浏览器,以后请求带上这个ID,服务端再查内存、查Redis确认身份。这套方案在小单体里没问题,但服务一旦拆成多个实例,用户的Session可能落在A机器上,请求被负载均衡转发到B机器就认不出来了。要么做Session黏滞,要么搭一套外置Session存储,运维复杂度立刻上去了。

Token思路不一样。它选择把身份信息直接编码进凭证本身,服务端不再保存会话,请求过来时验一下签名、看一下有效期、抽取出用户标识,就能继续往后走。对服务端来说完全无状态,扩容只需要多开实例,不用同步任何会话数据。这也是为什么从单体到微服务演进的过程中,Token几乎成了标准答案。

1.2 JWT是Token最常见的形态

日常说的Token范围很广,一串随机字符串也可以叫Token,但真正在Go项目里高频使用的通常是JWT。JWT全称是JSON Web Token,由三部分组成,用点号隔开:

这三部分都经过Base64Url编码,最后拼出来大概是 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywidXNlcm5hbWUiOiJhZG1pbiJ9.xxxxx 。

很多人误以为JWT内容加密了,其实没有。Base64Url只是编码,任何人拿到Token都能解码看到Payload里的内容。所以邮箱、手机号、密码这类敏感字段绝不能丢进JWT。服务端安全性靠的是签名防篡改,而不是内容隐藏,这一点必须刻在脑子里。

1.3 什么样的场景更适合JWT

JWT最大的价值是自包含、无状态、验签速度快。请求到了服务端不需要联网查询,拿着密钥一算就知道真假。API服务、移动端应用、前后端分离的单页应用,都很适合用它做访问凭证。

但不是所有Login状态都适合JWT。如果你的系统需要非常细粒度的会话控制,比如管理员要能一键踢人、用户发敏感操作要立刻让所有历史Token失效、风控系统要实时监视每次会话,JWT无状态的优势反而会变成劣势。因为Token发出去就脱离服务端掌控了,你没法主动销毁一个还没过期的合法Token,只能额外做黑名单或者版本号处理。所以架构选型时要提前想清楚:你是需要一个“验完即可”的通行证,还是需要一个个看得见摸得着的服务端会话。搞反了,后面上线运维就会很被动。

我在实际项目里一般这么定:面向开放API、多端接入、微服务内部鉴权用JWT;面向后台管理系统、用户敏感操作、要求随时下线的业务,用“短期JWT+服务端Refresh Token”的混合方案,或干脆用Redis存储的Opaque Token。

2. Go语言里如何安全地签发和校验Token

2.1 第三方库选型:这个坑我替你踩过了

Go生态里JWT库不少,常见的有 github.com/golang-jwt/jwt 、 github.com/dgrijalva/jwt-go 、 github.com/lestrrat-go/jwx 。先说结论:直接选 github.com/golang-jwt/jwt/v5 ,别犹豫。

dgrijalva/jwt-go 是老牌子,但已经很久不维护,后来还曝出漏洞,社区fork出了 golang-jwt/jwt 。 lestrrat-go/jwx 功能很强,支持JWE、JWK、JWS全套,适合做企业级平台,但对大多数Web项目来说属于“重”,API也没那么直观。

golang-jwt v5是在v3/v4基础上重新梳理过的版本,错误信息已经用 errors.Is 做了包装,解析过期Token、签名错误都能精确判断,写业务代码舒服很多。下面我全部用这个库演示。

2.2 自定义Claims:别再把用户ID塞进MapClaims

部分教程图省事,直接用 jwt.MapClaims ,把Token里的数据当字典取。写demo没问题,但放到工程里就很痛苦:取出来的值是 interface{} ,每次都要断言;字段名拼错要到运行时才发现;后期加字段没有类型检查,编译器完全帮不上忙。

工程化做法是自定义一个Claims结构体,直接内嵌 jwt.RegisteredClaims 。这样注册声明里的过期时间、签发时间、Token ID这些标准字段直接用官方类型,自己的业务字段用强类型定义。

package auth
import (
	"errors"
	"time"
	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
)
var (
	ErrTokenMalformed = errors.New("token is malformed")
	ErrTokenExpired   = errors.New("token is expired")
	ErrTokenInvalid   = errors.New("token is invalid")
)
type CustomClaims struct {
	UserID   int64  `json:"uid"`
	Username string `json:"username"`
	TokenType string `json:"type"` // access 或 refresh
	jwt.RegisteredClaims
}

我建议Payload里只放用户唯一标识和少量非敏感信息,比如这里放 uid 和 username 就够。头像、昵称、手机号这类经常变化的数据不要放进去,否则用户改完资料后旧Token里的数据还是旧的,又得为这点小事去做Token延期。Token体积也会越来越大,每次请求都要多传几KB,纯属浪费带宽。

2.3 签发逻辑:过期时间、Token ID、颁发时间缺一不可

写签发函数时,很多人只填 ExpiresAt 就完事,结果后面想实现“踢用户下线”时发现手里没有可用的Token唯一标识。我的习惯是: ExpiresAt 和 IssuedAt 必须设置, ID 一定用UUID生成,还要带上 Issuer ,方便将来同一个密钥体系里区分不同服务的Token。

func signToken(uid int64, username, secret, tokenType string, ttl time.Duration) (string, error) {
	now := time.Now()
	claims := CustomClaims{
		UserID:    uid,
		Username:  username,
		TokenType: tokenType,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
			IssuedAt:  jwt.NewNumericDate(now),
			NotBefore: jwt.NewNumericDate(now),
			ID:        uuid.NewString(),
			Issuer:    "auth-demo",
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString([]byte(secret))
}

func SignAccessToken(uid int64, username, secret string, ttl time.Duration) (string, error) {
	return signToken(uid, username, secret, "access", ttl)
}

func SignRefreshToken(uid int64, username, secret string, ttl time.Duration) (string, error) {
	return signToken(uid, username, secret, "refresh", ttl)
}

ID 字段的官方名称叫 jti (JWT ID),是实现黑名单、Refresh Token轮换的关键。没有它,后面想吊销一个指定Token都没法精确点名。 Issuer 也同样重要——很多公司会把用户端、管理端、合作方接口放在同一个认证服务下签发,如果Token里看不出源,排查问题时会更困难。

2.4 验签:只Parse不检查算法等于自动放行

验签是整个链路里最容易写错的地方。只调用 jwt.Parse 然后判断错误是不够的,因为JWT的Header里 alg 字段是由客户端传过来的,攻击者可以把它改成 none 或者改成其它算法尝试绕过。

正确的解析流程有三件事必须做满。

一是用 jwt.ParseWithClaims ,把自定义Claims传进去,让库帮我们做Base64解码和类型填充。二是keyfunc里必须判断签名算法的真实类型,不能盲目返回密钥。三是加 jwt.WithValidMethods ,明确服务端只接受HS256,从白名单上把其它算法全部屏蔽掉。

func VerifyToken(tokenString, secret, expectedType string) (*CustomClaims, error) {
	claims := new(CustomClaims)

	token, err := jwt.ParseWithClaims(tokenString, claims,
		func(token *jwt.Token) (interface{}, error) {
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, ErrTokenInvalid
			}
			return []byte(secret), nil
		},
		jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
		jwt.WithExpirationRequired(),
		jwt.WithLeeway(30*time.Second),
	)

	if err != nil {
		if errors.Is(err, jwt.ErrTokenExpired) {
			return nil, ErrTokenExpired
		}
		if errors.Is(err, jwt.ErrTokenMalformed) {
			return nil, ErrTokenMalformed
		}
		if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
			return nil, ErrTokenInvalid
		}
		return nil, err
	}

	if !token.Valid {
		return nil, ErrTokenInvalid
	}

	if claims.TokenType != expectedType {
		return nil, ErrTokenInvalid
	}

	return claims, nil
}

这里的 WithLeeway(30*time.Second) 很实用。不同机器之间存在时钟偏差,如果签发方服务器时钟和验签方服务器时钟差了几秒,在过期边界上就会出现“明明刚刚签发却说已过期”的问题。给个30到60秒的宽容窗口,能有效减少这种偶发故障。

WithExpirationRequired 是强制Token必须带 exp 字段。有些伪造Token可能故意不写过期时间,要是不强制校验,这种Token就永远不会过期了。

3. 把Token验证接进Web项目:一个完整示例

3.1 接口分层和配置准备

下面我用Gin框架演示一个最小但完整的登录鉴权闭环,项目结构按工程化习惯拆开:

auth-demo/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── auth/
│   │   ├── jwt.go
│   │   └── middleware.go
│   ├── config/
│   │   └── config.go
│   └── handler/
│       ├── auth_handler.go
│       └── profile_handler.go
├── go.mod
└── .env

这种拆法是Go项目里比较常见的“标准布局”:入口在 cmd ,业务实现在 internal ,外部包想引用也引用不进来。JWT相关操作集中在 internal/auth 里,中间件也在同一层,配置统一从环境变量读取,不要在代码里硬编码密钥。

配置文件逻辑很简单:

package config

import (
	"os"
	"time"
)

type Config struct {
	AccessSecret  string
	RefreshSecret string
	AccessTTL     time.Duration
	RefreshTTL    time.Duration
}

func Load() *Config {
	return &Config{
		AccessSecret:  os.Getenv("JWT_ACCESS_SECRET"),
		RefreshSecret: os.Getenv("JWT_REFRESH_SECRET"),
		AccessTTL:     2 * time.Hour,
		RefreshTTL:    14 * 24 * time.Hour,
	}
}

有人觉得访问Token和刷新Token用同一个密钥不就行了?不建议。如果access token的密钥泄露,理论上攻击者也能伪造refresh token,那整个登录态体系就全完了。两个密钥分开,即使访问Token被大规模泄露,刷新Token签发体系还保留着一定隔离性。

3.2 中间件:每一层受保护接口的看门人

中间件负责在业务Handler执行前完成Token提取和验证。验证通过后,把用户信息放进Gin的Context里,后面的业务代码直接 c.Get 就能拿到当前用户,不用再去查一次Token里存了什么,也不用反复做解码。

package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"

	"auth-demo/internal/auth"
)

func JWTAuth(secret string) gin.HandlerFunc {
	return func(c *gin.Context) {
		tokenStr := c.GetHeader("Authorization")
		tokenStr = strings.TrimPrefix(tokenStr, "Bearer ")

		if tokenStr == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
			return
		}

		claims, err := auth.VerifyToken(tokenStr, secret, "access")
		if err != nil {
			switch err {
			case auth.ErrTokenExpired:
				c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token expired", "code": "TOKEN_EXPIRED"})
			default:
				c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized", "code": "TOKEN_INVALID"})
			}
			return
		}

		c.Set("uid", claims.UserID)
		c.Set("username", claims.Username)
		c.Set("token_id", claims.ID)
		c.Next()
	}
}

在Gin里,路由使用中间件极其简单:

api := r.Group("/api")
api.Use(middleware.JWTAuth(cfg.AccessSecret))
{
	api.GET("/profile", handler.Profile)
}

这段代码一写, /api 下的所有路由在进入具体处理器之前都会先过一遍Token校验。受保护接口的代码就不需要再关心当前用户是谁、要不要验证登录,直接从Context拿用户信息即可。

3.3 登录接口:签发完整的Token对

登录接口要做的事情是查用户、比密码、发Token。密码处理我建议用 golang.org/x/crypto/bcrypt ,不要自己发明哈希算法,也不要存明文密码。演示代码中我以map代指用户存储,正式项目替换为数据库查询即可。

package handler

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"golang.org/x/crypto/bcrypt"

	"auth-demo/internal/auth"
	"auth-demo/internal/config"
)

type LoginRequest struct {
	Username string `json:"username" binding:"required"`
	Password string `json:"password" binding:"required"`
}

type UserStore struct {
	users map[string]struct {
		ID           int64
		PasswordHash string
	}
}

func (h *AuthHandler) Login(c *gin.Context) {
	var req LoginRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
		return
	}

	// 实际项目中从数据库读取,这里做演示查询
	user, ok := h.store.users[req.Username]
	if !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
		return
	}

	if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
		return
	}

	accessToken, err := auth.SignAccessToken(user.ID, req.Username, h.cfg.AccessSecret, h.cfg.AccessTTL)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
		return
	}

	refreshToken, err := auth.SignRefreshToken(user.ID, req.Username, h.cfg.RefreshSecret, h.cfg.RefreshTTL)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"access_token":  accessToken,
		"refresh_token": refreshToken,
		"expires_in":    int(h.cfg.AccessTTL.Seconds()),
		"token_type":    "Bearer",
	})
}

注意一个细节:用户不存在和密码错误我给的是同一个提示——“invalid username or password”。这是有意为之,可以避免攻击者通过返回差异去遍历合法用户名。这个习惯在很多安全规范里都能看到,平时开发容易忽略,但重要接口必须这样处理。

3.4 Profile接口:被保护资源如何读取用户

业务侧写一个最简单的例子。

func (h *AuthHandler) Profile(c *gin.Context) {
	uid, ok := c.Get("uid")
	if !ok {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"uid":      uid,
		"username": c.GetString("username"),
	})
}

这里演示了Token把用户身份“一路携带”到业务层的过程。整个链路可以这样验证:先请求登录接口拿到Token,再带Token访问Profile接口,服务器就能正确识别用户。

curl -X POST http://localhost:8080/api/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123"}'

curl -X GET http://localhost:8080/api/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

如果第二步不带Authorization头,Gin会立刻返回401,这就是中间件在工作。

4. 生产环境绕不开的四个问题:过期、续签、注销和密钥

4.1 过期时间别想当然,长短各有代价

Token有效期设多长,本质是一次安全性和用户体验的博弈。设太短,用户时不时被要求重新登录,体验很差;设太长,Token一旦被偷走,攻击者能长时间冒用身份,安全风险巨大。

行业里比较常见的值:普通Web应用的Access Token设15分钟到2小时,移动端或不需要频繁操作的场景可以放宽到24小时,管理后台建议调短到15分钟到30分钟。Access Token只解决“当前请求有没有权限”,真正能下线、能撤销的靠的是Refresh Token,Refresh Token一般给7天到30天。

如果你只用一个长时间有效的Token,不搞续签和刷新,那用户改了密码之后,旧Token还会继续生效到过期,这往往是业务上无法接受的。因此我强烈建议:即使是小项目,也要把短期Access Token和长期Refresh Token分开设计。

4.2 Token续签的三种主流方案

第一种是做Refresh Token轮换。客户端拿到Access Token后,一旦发现接口返回401且错误码是 TOKEN_EXPIRED ,就调刷新接口,携带Refresh Token换取一组新Token。服务端把旧的Refresh Token标记为已使用,新签发的Refresh Token只能使用一次。这个方案的好处是即使Refresh Token泄露,一旦被重放,服务端能立刻察觉。

第二种是滑动过期。用户每操作一次,只要剩余有效时间低于某个阈值,就重新签发一个更长有效期的Token。这种方案适合纯前端产品,代码实现最简单,但缺点是会话变成了“只要活跃就永不超时”,安全边界模糊。

第三种是混合方案:Access Token用JWT,Refresh Token用不透明随机字符串存在Redis。这是我自己最喜欢的方式,也是很多中大型系统的做法。JWT负责验签效率,Redis负责可控性。Redis里存的是:

refresh:7f3a9c2e8b1d = 12345
TTL 1209600

刷新时从Redis里查询这个key,存在且未过期才允许换发新Access Token。

我把三者对比一下:

方案主动吊销能力服务端状态实现复杂度典型场景
单一长生命周期JWT弱无低一次性授权,如邮件验证链接
JWT Refresh轮换中少中前后端分离Web应用
JWT + Redis Refresh强有但薄中高管理后台、多端登录

4.3 注销和强制下线:无状态Token的最大软肋

JWT设计之初就没打算让你主动销毁它。用户点了登出,服务端唯一能做的“推荐动作”是让客户端把Token删掉,可如果Token已经被恶意窃取,这个删除动作对攻击者没有任何约束力。

解决这个问题有两个常用手段。

一是做黑名单。在Redis里给每个要注销的Token的 jti 设一个key,过期时间正好是Token剩余有效期。中间件验签通过后再查一次黑名单,存在就直接拒绝。代码层面核心就是两步:

// 注销接口里
remaining := time.Until(claims.ExpiresAt.Time)
redisClient.Set(ctx, "block:"+claims.ID, "1", remaining)

// 中间件里验完jwt后
n, err := redisClient.Exists(ctx, "block:"+claims.ID).Result()
if err == nil && n > 0 {
	c.AbortWithStatusJSON(401, gin.H{"error": "token revoked"})
	return
}

二是做用户版本号。给用户表加一个 token_version 字段,签发Token时把这个版本号放Payload里。用户改密码、被强制下线时把版本号加一,中间件验证时发现版本号对不上直接拒绝。这个方案比黑名单更彻底,针对的是“某个用户的所有历史Token全部作废”的场景,非常适合管理员踢人和账号封禁。

4.4 密钥管理:别再把secret写死在代码里

HS256使用的是同一个对称密钥完成签名和验签,优点是计算快,缺点是密钥一旦泄漏,任何人都能伪造任意用户身份的Token。密钥管理要注意下面几条。

5. 常见报错和排查经验实录

5.1 从报错信息快速定位

项目接JWT后,我见过最多的就是下面这些报错,每个都能对应到具体的问题:

报错或表现常见原因排查方向
token is expiredToken已过有效期,或签发时间在验签服务器时间之后看两端服务器时钟;看过期时间设置是否过短
signature is invalid密钥不一致、Token被篡改、签名算法不匹配核对签发和验签的secret;看是否换了算法
token contains an invalid number of segmentsToken结构缺段,多半不是合法JWT检查从Header里取到的字符串有没有被截断;检查Bearer前缀剥离
malformed tokenToken内容格式损坏确认客户端没有把Token编码两次;看Cookie里是否带引号
keyfunc returned an error / unexpected signing method验签keyfunc里没有锁定算法,或算法白名单配置不对检查 WithValidMethods ;确认Header里的alg和期望值一致
带了Token仍然401中间件没注册到对应路由组;Header名写错;用户版本号被篡改检查路由Use的位置;看请求头到底是Authorization还是Authentication

如果只是“发起登录时提示 invalid_token ”,那要先区分是你自己项目的登录接口报错,还是集成了第三方授权体系后的报错。上下文很重要,不要一看报错就急着改JWT代码。

5.2 我平时排查Token问题时的实操步骤

第一件事不是看代码,而是先看Token本身。把Token扔到jwt.io这类解码界面,确认 exp 、 iat 、 uid 、 jti 这些字段是否符合预期。如果过期时间比当前时间还早,那问题大概率出在签发逻辑上;如果Payload缺失某个字段,那就是Claims定义或赋值环节出了问题。

第二是看服务端日志。验签失败时不要只打一个 auth failed ,要把错误类型编入日志,同时绝对不要把完整Token打进去。Token一旦出现在日志系统里,就相当于把钥匙串丢进公共抽屉。我一般的做法是打 jti 和错误类型,真要重放攻击调查时,也能靠时间戳和用户ID定位。

第三是带一个可手动触发的调试接口。很多小项目没有这个习惯,出问题时只能反复改代码重启。我会在本地开发环境加一个 GET /debug/token/verify 接口,接收Token并返回解析后的Claims,生产环境关闭即可。

5.3 关于“token exchange failed”这类报错的一点提醒

搜索Token问题时经常会看到 token exchange failed 、 sign-in could not be completed 这类报错。如果你遇到的是这种,那基本上和本文自己签发的JWT不是同一条链路。这类报错通常出现在OAuth 2.0/OIDC体系中,你的Go服务作为客户端去认证服务器换Access Token时失败。

常见成因包括:配置的 client_id / client_secret 不匹配、回调地址没有注册、授权码已经过期或使用过、刷新Token已经被吊销、认证服务器要求客户端带上额外的鉴权参数等。遇到这种报错,第一步是打开认证服务器侧的管理后台看日志,

到此这篇关于Go语言JWT Token签发校验与续期注销实践的文章就介绍到这了,更多相关Go语言JWT Token签发校验与续期注销内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文