许多网站在初期阶段通常使用 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:
- Refresh Token:用于更换 Access Token,有效期为 30 天。
- 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 方案时,除了代码注释中提到的内容外,还有一些关键注意事项值得留意:
- 确保通讯安全:使用 HTTPS 协议传输数据,以降低 Token 被拦截的风险。
- 限制 Token 的更换频率:要控制 Token 的更换次数,并定期刷新 Token。例如,限制用户的 Access Token 每天只能更换 50 次,如果超出次数则要求用户重新登录,同时每 15 分钟更换一次 Token。这样可以减少 Token 被盗后的潜在影响。
- 安全存储 Web Token:对于 Web 用户,当 Token 存储在 Cookie 中时,建议设置
HttpOnly
和SameSite=Strict
标记,以防止 Cookie 被恶意脚本窃取。