使用 JWT 实现会话认证

基于 JWT 来实现 Token 认证很简单,相对复杂的签名认证算法都已经封装到工具包里,使用起来很容易。

而真正造成 JWT 的应用困难其实是在适应业务上,并不是所有业务都适合原生的 JWT 特性。我们很多时候选型使用 JWT 看重的是其无状态和校验方便的优点,但在实际的业务场景中经常是要 Revoke 功能的。

如果要实现 Token 的 Revoke 那 Token 会变成有状态,这让人使用起来非常纠结。 我都有状态了为啥还要用 JWT?直接生成一个 ID 存 Redis 不更简单?直接用 Redis 缓存有效性来控制 Token 的有效性,失效删除就好了。

最终选择使用 JWT 作为 Token 主要是基于以下考虑:

  1. JWT 实现完善,有丰富的开发工具包;
  2. 不需要自己再设计一套 Token 结构和校验算法;
  3. 可以利用 JWT 进行前置验证(JWT 正确性、是否超时失效),避免每次都查询缓存;
  4. JWT 可以基于 Payload 存储数据,使用很灵活,可以提供保存一些数据在 Token 中避免查询;
  5. JWT 签名方法很丰富,能满足以后演进的需要,能实现一处签发,多处验证。

功能点

使用 JWT 作为认证 Token 应该需要满足我们以下几个需求(部分需求为我当前业务需要的功能,在其它系统中并不常见)。

Token 包含用户基本信息

包含用户的基本信息保证了登录认证时,系统可以通过 Token 携带的信息确定用户的身份。JWT 的 Payload 中可以携带信息,我们可以把用户的 uid 等信息存储在 payload 中。

防止篡改

非法用户不能通过伪造或修改 Payload 的方式,篡改 Token 代表的实例。JWT 基于签名实现的校验,如果内容被篡改,签名校验无法通过,所以可以有效防止篡改。

支持不同有效期

我正在开发的系统需要两种类型的 Token,一种是短期 Token,用户使用 API 可以生成;一种是长期 Token,由管理员通过管理系统生成。

JWT 在生成时支持指定签名有效期,可以生成需要的 Token。

长期 Token 需要支持 Revoke

JWT 通常都是无状态的,因为 JWT 一般都只生成短期 Token,有效期也只是几个小时。就算 Token 泄漏也只是影响短暂的时间,过期后就自动失效,所以也不会有 Revoke 的需求。

这是 JWT 的一个优点,无需专门的维护和存储 Token。但在里需要生成长期 Token,生成后如果无状态将无法保证安全,被盗用后的不能失效会产生持续的安全风险。

为了实现 Revoke,很显然需要对 Token 进行持久化操作。可以将 Token 按照有效期不同进行区分:

  1. 短期 Token:无状态管理,仅能通过 API 生成,不支持 Revoke;
  2. 长期 Token:有状态管理,由管理员在管理后台生成,生成时写表保存,通过 Redis 缓存 Token 数据,支持 Revoke。

当对长期 Token 进行 Revoke 操作时,同步修改表数据和 Token 缓存中的 Token 状态,实时失效。

Token 在校验时,分为以下步骤:

  1. 先校验 JWT 签名
  2. 根据 Token 失效时间,如果是长期 Token,查询缓存是否实效。

实现

JWT 相关实现依赖于第三方的 Golang 包 github.com/golang-jwt/jwt/v4,通过以下方式获取:

1
go get github.com/golang-jwt/jwt/v4

首先实现 JWT 的 Payload 结构,用来存储我们的 Payload 数据。Payload 在这个包中叫作 Claims,是一个接口。

1
2
3
4
5
6
7
type Token struct {
jwt.RegisteredClaims

Sub string `json:"sub"` // User Name
Uid uint64 `json:"uid"` // App Id
TokenId uint64 `json:"token_id"` // Token Id,用于唯一标识 Token
}

在 Payload 中除了用户的信息,还包括了 Token Id,用于方便标识 Token。可以快速定位到 Token 的缓存信息,方便查询有效性。

jwt.RegisteredClaims 是 JWT 包中的结构本,该结构体实现了 Claims 接口,通过引入这个结构体可以让 jwt 在解析的时候自动把 Payload 的数据写到我们的字段中。

校验

方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (j *JwtChecker) Verify(token string) (*ptoken.Token, error) {
tokenObj, err := jwt.ParseWithClaims(token, &ptoken.Token{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}

return []byte(j.secret), nil
}, jwt.WithJSONNumber())

if err != nil {
return nil, err
}

if claims, ok := tokenObj.Claims.(*ptoken.Token); ok && tokenObj.Valid {
return claims, nil
}

return nil, fmt.Errorf("invalid token: %v", token)
}
  1. 方法首先通过 Keyfunc 校验签名方法是否 HMAC
  2. 如果 ParseWithClaims 没错误,那么 Token 正确解析
  3. 判断 Token 是否有效并进行类型转换

长期 Token 判断

从解析好的 Token 对象中可以直接获取到 Token 的失效时间。

1
2
3
4
5
6
7
8
func (t *Token) IsLongToken() bool {
return IsTimeLongToken(t.ExpiresAt.Time)
}

func IsTimeLongToken(expireTime time.Time) bool {
now := time.Now()
return expireTime.After(now.Add(DefaultShortTermTokenPeriod))
}

如果 Token 是长期 Token,我们可以去缓存中查询 token 是否有效。

生成 Token

生成 Token 很简单,只需要依次调用 NewWithClaimsSignedString 就能生成签名后的 Token,这里使用 HmacSHA256 的签名算法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func DoGenerateToken(token *Token, secret string, expire time.Duration) (*TokenHolder, error) {
now := time.Now()
token.IssuedAt = jwt.NewNumericDate(now)
token.ExpiresAt = jwt.NewNumericDate(now.Add(expire))

jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, token)
signed, err := jwtToken.SignedString([]byte(secret))
if err != nil {
return nil, err
}

return &TokenHolder{
Token: token,
JwtToken: signed,
}, nil
}

Token 的失效时间需要在签名前手动设置到 ExpiresAt 字段,并不是以某个参数的方式提供的。

总结

本文介绍的和一般 JWT 用法并无多少区别,JWT 大家也都是这样用的。而对于需要 Revoke 这个功能,我这里是使用有效时间,将其区分成两种情况。

对于短期 Token,使用的还是 JWT 常规方法;对于长期 Token 则进行持久化,并对 Token 的信息进行多级缓存。

在我们的使用场景中,长期 Token 是非常少的,而长期 Token 的 Revoke 就更少了,所以查询持久化的 Token 信息并不会很多。

如果未来有演进需要,大量使用长期 Token,也可以将 Token 信息缓存调整成为 Revoke 黑名单列表的方式。只对 Revoke 的 Token 进行记录和查询,毕竟 Revoke 的数据终究还是少数。

作者

Jakes Lee

发布于

2022-11-14

更新于

2023-01-03

许可协议

评论