目录

GO语言开发实践-jwt 双 token 无感刷新

jwt 双 token 实现客户端无感刷新。

动机

最近没事玩玩管理后台的时候发现,我们给用户 token 设置了一个过期时间后,登录一段时间后,用户 token 一过期,就需要跳转到登录页面重新登录,这种方式太繁琐了。

  • 于是我查了一下相关资料,发现有一种方法可以解决这种问题,就是在生成 jwt token 的时候,生成 2 个 token 。

  • 一个用于正常访问的 accessToken ,一个用于刷新 accessToken 的 refreshToken 。

  • 当然了,这种方式也需要前端配合才行,前端判断 accessToken 是否过期,过期的话,先将需要访问的 url 存起来,然后通过 refreshToken 刷新 accessToken ,再拿新的 accessToken 发起请求。

  • 前端 cookies 还要设置 refreshToken 的过期时间,如果长时间没有发起请求(比如24小时,通常是 accessToken 的 2 倍),则将 token 移出,这时候我们就会返回到登录页重新登录获取 token 。

编码

后端用的是 golang + gin ,库用的是 jwt-go

jwt 作为中间件的形式对每个请求做校验。

主要的流程就是如下,具体详细信息看代码和注释:

  1. 生成token

  2. 校验token

  3. 过期重新生成

Todo:

  • 目前没有对 token 做持久化,后期可以考虑写到 redis 或者数据库,然后可以对 token 做一些操作,如禁用 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
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
type JWTMiddleware struct {
    AccessSecret  []byte
    RefreshSecret []byte
    Timeout       int
    MaxRefresh    int
}

func InitAuth() (*JWTMiddleware, error) {
    authMiddleware := &JWTMiddleware{
        AccessSecret:  []byte(config.Conf.Jwt.AKey),
        RefreshSecret: []byte(config.Conf.Jwt.RKey),
        Timeout:       config.Conf.Jwt.Timeout,
        MaxRefresh:    config.Conf.Jwt.MaxRefresh,
    }
    return authMiddleware, nil
}

type Claims struct {
    User dto.UserInfoDto
    jwt.StandardClaims
}

// GetToken 获取accessToken和refreshToken
func (jm *JWTMiddleware) GetToken(user dto.UserInfoDto) (string, string, int64) {
    // accessToken 的数据
    aT := Claims{
        user,
        jwt.StandardClaims{
            Issuer:    "isekiro",
            IssuedAt:  time.Now().Unix(),
            ExpiresAt: time.Now().Add(time.Duration(jm.Timeout) * time.Hour).Unix(),
        },
    }
    // refreshToken 的数据
    rT := Claims{
        user,
        jwt.StandardClaims{
            Issuer:    "isekiro",
            IssuedAt:  time.Now().Unix(),
            ExpiresAt: time.Now().Add(time.Duration(jm.MaxRefresh) * time.Hour).Unix(),
        },
    }
    accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, aT)
    refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, rT)
    accessTokenSigned, err := accessToken.SignedString(jm.AccessSecret)
    if err != nil {
        common.Log.Errorln("获取Token失败,Secret错误")
        return "", "", 0
    }
    refreshTokenSigned, err := refreshToken.SignedString(jm.RefreshSecret)
    if err != nil {
        common.Log.Errorln("获取Token失败,Secret错误")
        return "", "", 0
    }
    return accessTokenSigned, refreshTokenSigned, aT.ExpiresAt
}

func (jm *JWTMiddleware) ParseRefreshToken(refreshTokenString string) (*Claims, bool, error) {
    refreshToken, err := jwt.ParseWithClaims(refreshTokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        return jm.RefreshSecret, nil
    })
    if err != nil {
        common.Log.Errorln(err)
        return nil, false, err
    }
    if claims, ok := refreshToken.Claims.(*Claims); ok && refreshToken.Valid {
        return claims, true, nil
    }

    return nil, false, errors.New("invalid token")
}

func (jm *JWTMiddleware) ParseAccessToken(accessTokenString string) (*Claims, bool, error) {
    accessToken, err := jwt.ParseWithClaims(accessTokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        return jm.AccessSecret, nil
    })
    if err != nil {
        v, _ := err.(*jwt.ValidationError)
        if v.Errors == jwt.ValidationErrorExpired {
            common.Log.Errorln("the token has expired")
        } else {
            return nil, false, errors.New("invalid token")
        }
    }
    if claims, ok := accessToken.Claims.(*Claims); ok && accessToken.Valid {
        return claims, false, nil
    }
    return nil, false, errors.New("invalid token")
}

func (jm *JWTMiddleware) LoginHandler(c *gin.Context) {
    var req vo.RegisterAndLoginRequest

    // 请求json绑定
    if err := c.ShouldBind(&req); err != nil {
        common.Log.Errorln(err)
        r.Response(c, http.StatusInternalServerError, e.INVALID_PARAMS, nil, false)
        return
    }

    // 密码通过RSA解密
    decodeData, err := util.RSADecrypt([]byte(req.Password), config.Conf.System.RSAPrivateBytes)
    if err != nil {
        common.Log.Errorln(err)
        r.Response(c, http.StatusInternalServerError, e.ERROR_AUTH_DECRYPT_PWD, nil, false)
        return
    }

    userService := serviceSystem.NewUserService()

    u := &modelSystem.User{
        Username: req.Username,
        Password: string(decodeData),
    }

    // 密码校验
    user, err := userService.Login(u)
    if err != nil {
        common.Log.Errorln(err)
        r.Response(c, http.StatusForbidden, e.ERROR_AUTH_CHECK_PWD, nil, false)
        return
    }

    activeRoles := []string{}
    for _, role := range user.Roles {
        activeRoles = append(activeRoles, role.Code)
    }

    // 返回双 token
    accessTokenString, refreshTokenString, expiresAt := jm.GetToken(*user)
    if accessTokenString == "" || refreshTokenString == "" {
        r.Response(c, http.StatusUnauthorized, e.ERROR_AUTH_GET_TOKEN, nil, false)
        return
    }
    r.Response(c, http.StatusOK, e.SUCCESS, gin.H{
        "accessToken":  accessTokenString,
        "refreshToken": refreshTokenString,
        "roles":        activeRoles,
        "expires":      expiresAt,
        "username":     user.Username,
        "userId":       user.ID,
    }, true)
}

// JWTAuthMiddleware 用鉴权到中间件
func (jm *JWTMiddleware) JWTAuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 默认双Token放在请求头Authorization的Bearer中,并以空格隔开
        authHeader := c.Request.Header.Get("Authorization")
        if authHeader == "" {
            c.Abort()
            r.Response(c, http.StatusUnauthorized, e.ERROR_AUTH_EMPTY_TOKEN, nil, false)
            return
        }
        parts := strings.Split(authHeader, " ")
        if !(len(parts) == 2 && parts[0] == "Bearer") {
            c.Abort()
            r.Response(c, http.StatusUnauthorized, e.ERROR_AUTH_TOKEN_FORMAT, nil, false)
            return
        }
        parseToken, isUpd, err := jm.ParseAccessToken(parts[1])
        if err != nil {
            common.Log.Errorln(err)
            c.Abort()
            r.Response(c, http.StatusUnauthorized, e.ERROR_AUTH_NOT_VALID_TOKEN, nil, false)
            return
        }
        if isUpd {
            c.Abort()
            r.Response(c, http.StatusUnauthorized, e.ERROR_AUTH_EXPIRES_TOKEN, nil, false)
            return
        }
        c.Set("user", parseToken.User)
        c.Next()
    }
}

func (jm *JWTMiddleware) RefreshHandler(c *gin.Context) {
    var req vo.RefreshTokenRequest
    // 请求json绑定
    if err := c.ShouldBind(&req); err != nil {
        common.Log.Errorln(err)
        r.Response(c, http.StatusUnauthorized, e.INVALID_PARAMS, nil, false)
        return
    }
    // 提取refreshToken
    refreshToken := req.RefreshToken
    if refreshToken == "" {
        r.Response(c, http.StatusUnauthorized, e.ERROR_AUTH_EMPTY_TOKEN, nil, false)
        return
    }
    parseRefreshToken, isUpd, err := jm.ParseRefreshToken(refreshToken)
    if err != nil {
        common.Log.Errorln(err)
        r.Response(c, http.StatusUnauthorized, e.ERROR_AUTH_NOT_VALID_TOKEN, nil, false)
        return
    }
    // refreshToken 已经失效,需要刷新双Token
    if isUpd {
        accessTokenString, refreshTokenString, expiresAt := jm.GetToken(parseRefreshToken.User)
        r.Response(c, http.StatusOK, e.SUCCESS, gin.H{
            "accessToken":  accessTokenString,
            "refreshToken": refreshTokenString,
            "expires":      expiresAt,
        }, true)
        return
    }
}

总结

  1. 目前发现 jwt-go 库时间戳只能到秒,用毫秒会报错。

  2. 没有对 refreshToken 做二次校验,只做过期时间校验,后期需要对这一部分进行完善。不然攻击者拿到旧的合法的 token 也可以刷新 accessToken 。