Go语言中使用JWT鉴权、Token刷新完整示例,拿去直接用!

在现代 Web 应用中,JWT(JSON Web Token)已经成为了主流的认证与授权解决方案。它轻量、高效、易于实现,并且非常适合于微服务架构。

在本文中,我们将通过 Go 语言及其流行的 Gin 框架,来深入探讨如何使用 JWT 实现用户认证和安全保护。

什么是 JWT?

JSON Web Tokens(JWT)是一种开放标准(RFC 7519),用于在网络应用环境间安全地传递声明。JWT是一个紧凑、URL安全的方式,用于在双方之间传递信息。在认证流程中,JWT被用来验证用户身份,并传递用户状态信息。

其结构主要包括三部分:

  • Header:包含令牌的类型和签名算法。
  • Payload:携带用户信息(如用户 ID)和一些标准声明(如签发者、过期时间等)。
  • Signature:用来验证令牌的真实性,防止被篡改。

JWT 的魅力在于它是自包含的,可以通过令牌直接获取用户信息,而无需在服务器端维护会话状态。

使用 Gin 和 JWT 实现用户认证

让我们从实际代码开始,演示如何在 Gin 中集成 JWT 认证。

package main

import (
    "log"
    "strings"
    "time"

    "github.com/davecgh/go-spew/spew"
    "github.com/gin-gonic/gin"
    jwtPkg "github.com/golang-jwt/jwt/v4"
    "github.com/pkg/errors"
)

在上述代码中,我们首先导入了必要的包,包括用于处理 JWT 的 github.com/golang-jwt/jwt/v4 包和用于错误处理的 github.com/pkg/errors 包。

JWT 结构体定义

var (
    ErrTokenGenFailed         = errors.New("令牌生成失败")
    ErrTokenExpired           = errors.New("令牌已过期")
    ErrTokenExpiredMaxRefresh = errors.New("令牌已过最大刷新时间")
    ErrTokenMalformed         = errors.New("请求令牌格式有误")
    ErrTokenInvalid           = errors.New("请求令牌无效")
    ErrTokenNotFound          = errors.New("无法找到令牌")
)

// JWT 定义一个 jwt 对象
type JWT struct {
    Key        []byte // 密钥
    MaxRefresh int64  // 最大刷新时间(分钟)
    ExpireTime int64  // 过期时间(分钟)
    Issuer     string // 签发者
}

JWT 结构体包含了实现 JWT 所需的关键信息,如密钥、最大刷新时间、过期时间和签发者信息。我们使用这些字段来配置和管理 JWT。

生成 JWT

func NewJWT(secret, issuer string, maxRefreshTime, expireTime int64) *JWT {
    if maxRefreshTime <= expireTime {
        log.Fatal("最大刷新时间必须大于 token 的过期时间")
    }

    return &JWT{
        Key:        []byte(secret), // 密钥
        MaxRefresh: maxRefreshTime, // 允许刷新时间
        ExpireTime: expireTime,     // token 过期时间
        Issuer:     issuer,         // token 的签发者
    }
}

通过 NewJWT 方法,我们可以创建一个 JWT 实例。这个实例将用于生成、解析和刷新 JWT。需要注意的是,最大刷新时间必须大于 token 的过期时间,否则会导致逻辑错误。

解析 JWT

func (j *JWT) ParseToken(c *gin.Context, userToken ...string) (*JWTCustomClaims, error) {
    var (
        tokenStr string
        err      error
    )

    if len(userToken) > 0 {
        tokenStr = userToken[0]
    } else {
        tokenStr, err = j.GetToken(c)
        if err != nil {
            return nil, err
        }
    }

    token, err := j.parseTokenString(tokenStr)

    if err != nil {
        validationErr, ok := err.(*jwtPkg.ValidationError)
        if ok {
            switch validationErr.Errors {
            case jwtPkg.ValidationErrorMalformed:
                return nil, ErrTokenMalformed
            case jwtPkg.ValidationErrorExpired:
                return nil, ErrTokenExpired
            }
        }
        return nil, ErrTokenInvalid
    }

    if claims, ok := token.Claims.(*JWTCustomClaims); ok && token.Valid {
        return claims, nil
    }

    return nil, ErrTokenInvalid
}

ParseToken 方法用于解析 JWT 并验证其有效性。如果令牌无效或者过期,会返回相应的错误信息。这个方法是我们在各个需要鉴权的 API 接口中最常用的一个方法。

刷新 JWT

func (j *JWT) RefreshToken(c *gin.Context) (string, error) {
    tokenStr, err := j.GetToken(c)
    if err != nil {
        return "", err
    }

    token, err := j.parseTokenString(tokenStr)

    if err != nil {
        validationErr, ok := err.(*jwtPkg.ValidationError)
        if !ok || validationErr.Errors != jwtPkg.ValidationErrorExpired {
            return "", err
        }
    }

    claims := token.Claims.(*JWTCustomClaims)
    maxRefreshTime := time.Duration(j.MaxRefresh) * time.Minute

    if claims.IssuedAt > time.Now().Add(-maxRefreshTime).Unix() {
        claims.StandardClaims.ExpiresAt = j.expireAtTime()
        return j.createToken(*claims)
    }

    return "", ErrTokenExpiredMaxRefresh
}

RefreshToken 方法允许在 token 过期但仍在允许刷新时间内时,重新生成一个新的 token。这对于长时间需要保持登录状态的应用非常有用。

这只是刷新 token 的一种思路,还有一种思路也可以刷新 token,但是就需要用到两个 token,一个 access_token 和 refresh_token ,这里我直接将代码贴进来,大家可以参考参考。

package main

import (
    "log"
    "time"

    jwtPkg "github.com/golang-jwt/jwt/v4"
)

type ARJWT struct {
    // 密钥,用以加密 JWT
    Key []byte

    // 定义 access token 过期时间(单位:分钟)即当颁发 access token 后,多少分钟后 access token 过期
    AccessExpireTime int64

    // 定义 refresh token 过期时间(单位:分钟)即当颁发 refresh token 后,多少分钟后 refresh token 过期
    // 一般来说,refresh token 的过期时间会比 access token 的过期时间长
    RefreshExpireTime int64

    // token 的签发者
    Issuer string
}

func NewARJWT(secret, issuer string, accessExpireTime, refreshExpireTime int64) *ARJWT {
    if refreshExpireTime <= accessExpireTime {
        log.Fatal("refresh token 过期时间必须大于 access token 过期时间")
    }
    return &ARJWT{
        Key:               []byte(secret),    // 密钥
        AccessExpireTime:  accessExpireTime,  // access token 过期时间
        RefreshExpireTime: refreshExpireTime, // refresh token 过期时间
        Issuer:            issuer,            // token 的签发者
    }
}

// GenerateToken 生成 access token 和 refresh token
func (arj *ARJWT) GenerateToken(userId string) (accessToken, refreshToken string, err error) {
    // 生成 access token 在 access token 中需要包含我们自定义的字段,比如用户 ID
    mc := JWTCustomClaims{
        UserID: userId,
        StandardClaims: jwtPkg.StandardClaims{
            // ExpiresAt 是一个时间戳,代表 access token 的过期时间
            ExpiresAt: time.Now().Add(time.Duration(arj.AccessExpireTime) * time.Minute).Unix(),
            // 签发人
            Issuer: arj.Issuer,
        },
    }

    // 生成 access token
    accessToken, err = jwtPkg.NewWithClaims(jwtPkg.SigningMethodHS256, mc).SignedString(arj.Key)
    if err != nil {
        log.Printf("generate access token failed: %v \n", err)
        return "", "", err
    }

    // 生成 refresh token
    // refresh token 只需要包含标准的声明,不需要包含自定义的声明
    refreshToken, err = jwtPkg.NewWithClaims(jwtPkg.SigningMethodHS256, jwtPkg.StandardClaims{
        // ExpiresAt 是一个时间戳,代表 refresh token 的过期时间
        ExpiresAt: time.Now().Add(time.Duration(arj.RefreshExpireTime) * time.Minute).Unix(),
        // 签发人
        Issuer: arj.Issuer,
    }).SignedString(arj.Key)

    return
}

func (arj *ARJWT) ParseAccessToken(tokenString string) (*JWTCustomClaims, error) {
    claims := new(JWTCustomClaims)

    token, err := jwtPkg.ParseWithClaims(tokenString, claims, func(token *jwtPkg.Token) (interface{}, error) {
        return arj.Key, nil
    })

    if err != nil {
        validationErr, ok := err.(*jwtPkg.ValidationError)
        if ok {
            switch validationErr.Errors {
            case jwtPkg.ValidationErrorMalformed:
                return nil, ErrTokenMalformed
            case jwtPkg.ValidationErrorExpired:
                return nil, ErrTokenExpired
            }
        }
        return nil, ErrTokenInvalid
    }

    if _, ok := token.Claims.(*JWTCustomClaims); ok && token.Valid {
        return claims, nil
    }

    return nil, ErrTokenInvalid
}

func (arj *ARJWT) RefreshToken(accessToken, refreshToken string) (newAccessToken, newRefreshToken string, err error) {
    // 先判断 refresh token 是否有效
    if _, err = jwtPkg.Parse(refreshToken, func(token *jwtPkg.Token) (interface{}, error) {
        return arj.Key, nil
    }); err != nil {
        return
    }

    // 从旧的 access token 中解析出 JWTCustomClaims 数据出来
    var claims JWTCustomClaims
    _, err = jwtPkg.ParseWithClaims(accessToken, &claims, func(token *jwtPkg.Token) (interface{}, error) {
        return arj.Key, nil
    })
    if err != nil {
        validationErr, ok := err.(*jwtPkg.ValidationError)
        // 当 access token 是过期错误,并且 refresh token 没有过期时就创建一个新的 access token 和 refresh token
        if ok && validationErr.Errors == jwtPkg.ValidationErrorExpired {
            // 重新生成新的 access token 和 refresh token
            return arj.GenerateToken(claims.UserID)
        }
    }

    return
}

关于这两种刷新 token 的方式对比,可以直接参考阅读我这里的文章,https://github.com/pudongping/golang-tutorial/tree/main/project/jwt_demo 有比较详细的说明。

结语

通过本文,我们探索了如何在 Go 中使用 Gin 框架实现 JWT 鉴权,包括 token 的生成、解析、刷新等功能。这套方案不仅高效而且易于扩展,可以满足大多数 Web 应用的安全需求。

完整的代码在这里:https://github.com/pudongping/golang-tutorial/blob/main/project/jwt_demo/jwt.go

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容