用户passport 服务Token:如何降低用户身份鉴权的流量压力?

许多网站在初期阶段通常使用 Session 方式来实现用户登录鉴权。具体而言,当用户成功登录后,服务端会将用户的相关信息存储在 Session 缓存 中,并生成一个唯一的 session_id,这个 ID 被存储在用户的 Cookie 中。之后,用户每次发送请求时,都会携带该 session_id,服务端则通过该 ID 查找到 Session 缓存中的用户记录,从而进行身份验证和用户信息的管理。

这种用户鉴权方式的优势在于,所有用户信息都存储在服务端,不会暴露任何敏感数据给客户端,同时每个登录用户都有共享的 Session 缓存空间。但是,随着网站流量的增长,这种设计也会暴露出明显的缺点——用户中心的身份鉴权在高并发下表现不稳定。

具体而言,用户中心需要维护大量的 Session 缓存,并且频繁被各个业务系统访问。如果缓存出现故障,所有依赖它的子系统将无法进行用户身份确认,导致服务中断。这主要是由于 Session 缓存与各子系统的高耦合。每次请求都至少需要访问一次缓存,因此缓存的容量和响应速度直接影响了全站的 QPS 上限,降低了系统的隔离性,使各子系统之间互相影响。

那么,如何降低用户中心与各子系统之间的耦合度,从而提高系统性能呢?接下来我们一起来探讨。

JWT 登陆和 token 校验

常见方式是采用签名加密的 token,这是登录的一个行业标准,即 JWT(JSON Web Token):

上图就是 JWT 的登陆流程,用户登录后会将用户信息放到一个加密签名的 token 中,每次请求都把这个串放到 header 或 cookie 内带到服务端,服务端直接将这个 token 解开即可直接获取到用户的信息,无需和用户中心做任何交互请求。

token 生成代码如下:

import "github.com/dgrijalva/jwt-go" 
//签名所需混淆密钥 不要太简单 容易被破解 
//也可以使用非对称加密,这样可以在客户端用公钥验签 
var secretString = []byte("jwt secret string 137 rick")  
type TokenPayLoad struct { 
    UserId   uint64 `json:"userId"` //用户id 
    NickName string `json:"nickname"` //昵称 
    jwt.StandardClaims //私有部分 
} 
// 生成JWT token 
func GenToken(userId uint64, nickname string) (string, error) { 
    c := TokenPayLoad{ 
        UserId: userId, //uid 
        NickName: nickname, //昵称 
//这里可以追加一些其他加密的数据进来 
//不要明文放敏感信息,如果需要放,必须再加密 
//私有部分 
        StandardClaims: jwt.StandardClaims{ 
//两小时后失效 
            ExpiresAt: time.Now().Add(2 * time.Hour).Unix(), 
//颁发者 
            Issuer:    "geekbang", 
        }, 
    } 
//创建签名 使用hs256 
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, c) 
// 签名,获取token结果 
return token.SignedString(secretString) 
}

可以看出,这种 Token 内部包含了过期时间,接近过期的 Token 会在客户端自动与服务端通信进行更新。这样设计可以大大增加恶意截取客户端 Token 并伪造用户身份的难度。同时,服务端还可以实现与用户中心的解耦,业务服务端只需解析请求中的 Token 就能获取用户信息,而不必每次请求都去访问用户中心。Token 的刷新完全可以由客户端主动向用户中心发起,而无需业务服务端频繁请求用户中心来更换 Token。

那么,JWT(JSON Web Token)是如何保证数据不会被篡改并确保数据完整性的呢?接下来我们来看看它的组成。

JWT token 解密后的数据结构如下图所示:

//header 
//加密头 
{ 
"alg": "HS256", // 加密算法,注意检测个别攻击会在这里设置为none绕过签名 
"typ": "JWT" //协议类型 
} 
//PAYLOAD 
//负载部分,存在JWT标准字段及我们自定义的数据字段 
{ 
"userid": "9527", //我们放的一些明文信息,如果涉及敏感信息,建议再次加密 
"nickname": "Rick.Xu", // 我们放的一些明文信息,如果涉及隐私,建议再次加密 
"iss": "geekbang", 
"iat": 1516239022, //token发放时间 
"exp": 1516246222, //token过期时间 
} 
//签名 
//签名用于鉴定上两段内容是否被篡改,如果篡改那么签名会发生变化 
//校验时会对不上

JWT 如何验证 token 是否有效,还有 token 是否过期、是否合法,具体方法如下:

func DecodeToken(token string) (*TokenPayLoad, error) { 
    token, err := jwt.ParseWithClaims(token, &TokenPayLoad{}, func(tk *jwt.Token) (interface{}, error) { 
return secret, nil 
    }) 
if err != nil { 
return nil, err 
    } 
if decodeToken, ok := token.Claims.(*TokenPayLoad); ok && token.Valid { 
return decodeToken, nil 
    } 
return nil, errors.New("token wrong") 
}

JWT

(JSON Web Token)的解码相对简单,第一部分和第二部分都是通过 Base64 编码的。解码这两部分即可获取到 payload 中的所有数据,其中包括用户昵称、UID、用户权限和 Token 的过期时间。要验证 Token 是否过期,只需将其中的过期时间与当前时间进行对比,即可确认 Token 是否有效。而验证 Token 的合法性则通过 签名验证来完成。任何对信息的修改都无法通过签名验证。如果 Token 通过了签名验证,就表明它没有被篡改过,是一个合法的 Token,可以直接使用。

这个过程如下图所示:

通过 Token 方式,可以显著减轻用户中心的压力,不再需要频繁访问用户信息接口。各业务服务端只需解码并验证 Token 的合法性,即可直接获取用户信息。然而,这种方式也存在一些缺点。比如,当用户被拉黑后,客户端通常要等到 Token 过期才会自动登出,这会导致管理上的一定延迟。

如果希望实现实时管理,可以在服务端暂存新生成的 Token,并在每次用户请求时与缓存中的 Token 进行对比。不过,这样的操作会影响系统性能,因此少数公司会采用这种方式。为了提高 JWT 系统的安全性,Token 通常设置较短的过期时间,通常为十五分钟左右。Token 过期后,客户端会自动向服务端请求更新。

token 的更换和离线

那么如何对 JWT 的 token 进行更换和离线验签呢?具体的服务端换签很简单,只要客户端检测到当前的 token 快过期了,就主动请求用户中心更换 token 接口,重新生成一个离当前还有十五分钟超时的 token。但是期间如果超过十五分钟还没换到,就会导致客户端登录失败。为了减少这类问题,同时保证客户端长时间离线仍能正常工作,行业内普遍使用双 token 方式,具体你可以看看后面的流程图:

在这个方案中,使用了两种 Token

  1. Refresh Token:用于更换 Access Token,有效期为 30 天。
  2. Access Token:用于存储当前用户信息和权限信息,每隔 15 分钟进行一次更换。

当客户端尝试请求用户中心进行 Token 更换但失败,且客户端处于离线状态时,只要本地的 Refresh Token 未过期,系统仍然能够正常运作。客户端可以持续使用 Access Token,直到 Refresh Token 到期,此时系统会提示用户重新登录。通过这种方式,即便用户中心出现故障,业务系统也可以正常运转一段时间,提升了系统的健壮性和用户体验。

用户中心检测更换 token 的实现如下:

//如果还有五分钟token要过期,那么换token 
if decodeToken.StandardClaims.ExpiresAt < TimestampNow() - 300 { 
//请求下用户中心,问问这个人禁登陆没 
//....略具体 
//重新发放token 
  token, err := GenToken(.....) 
if err != nil { 
return nil, err 
  } 
//更新返回cookie中token 
  resp.setCookie("xxxx", token) 
}

安全建议

在使用 JWT 方案时,除了代码注释中提到的内容外,还有一些关键注意事项值得留意:

  1. 确保通讯安全:使用 HTTPS 协议传输数据,以降低 Token 被拦截的风险。
  2. 限制 Token 的更换频率:要控制 Token 的更换次数,并定期刷新 Token。例如,限制用户的 Access Token 每天只能更换 50 次,如果超出次数则要求用户重新登录,同时每 15 分钟更换一次 Token。这样可以减少 Token 被盗后的潜在影响。
2