Web App Token 鉴权方案的设计与思考
作为一种新兴的鉴权方案,Token 作为 Session ID 的替代品有许多天然优势,很多主流产品也从 Session ID 鉴权变成了 Token 鉴权。在我近期完成的产品实践中,也使用了 Token 进行鉴权。作为鉴权方案设计的参与者,我今天与负责该部分业务的同学进行了一些讨论,同时咨询了几位业界老司机,结合我在产品设计之初的一些调研,总结出这篇文章分享给大家。
我不是安全专家,只是在能力范围内对系统安全尽可能地做了优化,考虑不周的地方欢迎大家批评指正。
关于用户系统
对于一个简单的用户系统(不考虑复杂的权限控制,只考虑最单一的“合法用户”的鉴定),其功能其实可以被拆的很简单:注册、登录、鉴权。
- 注册:用户将用户名和密码交给服务器,并由服务器存储的过程。
- 登录:用户将用户名和密码交给服务器,服务器鉴定是否正确的过程(在 Token鉴权系统中,这一步如果通过,会生成并返回 Token)。
- 鉴权:用户将 Token 发送给服务器,服务器校验该 Token 是否合法的过程(不考虑复杂鉴权)。
安全问题
流程清楚了,我们就来分析一下问题。不考虑前端可能出现的网络抓包等问题,仅从服务器角度考虑,我们可能遇到的安全问题有以下几个:
- 密码泄漏
- 生成 Token 的 Secret Key(Salt)泄漏
- Token 泄漏 / 伪造
归纳一下:我们要解决的最重要的安全问题,就是用户最机密的安全信息被泄漏或伪造。
我们的鉴权设计实践
在我最近完成的产品上,为了规避这些问题,我们在关键步骤上进行了一些处理。整个鉴权系统依赖 Apache Shiro 框架;同时,在密码处理,Token 认证上,我们结合了一些自己的解决方案。
整个流程大致是这样的:(流程图软件到期了 TAT)
注册
Register登录
Login鉴权
Authentication关于密码加密存储与验证
密码是一定要进行加密存储的。用户系统最核心的数据表,就是包含用户名(ID)、加密后的密码、Salt 的表。Salt 的生成,我们使用了 Shiro 提供的随机字符串生成工具,与用户名连接后,再进行 MD5。然后使用 Salt 加密密码,然后同时保存 Salt 和加密后的密码。
当用户登录时,我们使用 Salt 对用户输入的密码进行加密,再尝试与存储的密码进行匹配。
关于 Token 方案(JWT Token)
我们使用 JWT Token 作为我们的 Token 方案。
JWT Token
JWT Token 的全称是 JSON Web Token。一个 JWT Token 由三部分构成:Header,Payload,Signature。Header 规定了 Token 使用的加密方式与 Token 的类型,Payload 是 Token 中包含的用户信息(用户名,过期时间等),Signature 是 Header 的 Base64 值 + Payload 的 Base64 值 + Secret Key 生成的字符串,再对该字符串使用 Header 中规定的散列方式(HS256 或 RS256)取散列值后得到的字符串。一个典型的 JWT Token 是这个样子的:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
// HEADER
{
"alg": "HS256",
"typ": "JWT"
}
// PAYLOAD
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
// SIGNATURE
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
Header、Payload 和 Signature 用 .
分隔。
验证 Token 的时候,我们只需要将前两段(即 Header 和 Payload 的 Base64)加上 Secret Key,然后按照 Header 规定的加密方式进行加密,将生成的字符串与第三段(Signature)比对即可。当然,Token 验证的实践上,不同的项目存在一些分歧:有些人会将生成的 Token 直接存在数据库(比如 Redis)里,然后通过 Query 的方式验证是否合法。这一点我们随后讨论。
一个 JWT Token 唯一不可见的部分,就是 Secret Key。它是保证这个 Token 合法且安全的唯一字段。拿不到 Secret Key ,就无法生成 Token,也无法验证 Token。这种 Token 机制很常见(HTTPS 的握手过程就类似这样,SSH 连接也是 - 私钥只有一方持有),难点在于,如何生成并管理 Secret Key。
Secret Key
首先,所有用户使用相同的 Secret Key 一定是不合理的。所以我们要解决的第一个问题是,如何为每一个用户生成唯一的 Secret Key ?
还记得刚才的 Salt 么?每个用户的 Salt 都是唯一的,我们使用 Salt ,但不直接使用 Salt 作为 Secret Key。我们使用 Salt + 加密后的密码,再取 MD5 值作为该用户的 Secret Key。每次鉴权前,我们通过这个方式生成 Secret Key,再使用 Secret Key 进行鉴权。
安全性分析
整套系统的安全之处在于,我们没有将任何敏感信息本地化。假设一种最坏的情况:攻击者拿到了我们数据库的全部数据,他能做什么?
- 获取密码:密码被加密了,而且每个用户使用不同的 Salt 进行加密,加密方法是自定义的,不知道加密方法的话难以破解。
- 获取 Token:我们没有保存任何的 Token。
- 获取 Secret Key:Secret Key 是算出来的,即便拿到了 Salt,不知道算法也无法直接得到 Secret Key。
我们避免了直接保存任何安全信息。攻击者拿到的数据,都无法被直接利用。即便尝试破解,代价也是巨大的。
关于 Redis
在我看到的一些实践中,有些项目喜欢使用 Redis 存储生成的 Token,从而简化鉴权流程,提升鉴权效率。这样做可以吗?
我咨询了一位业界专家,同时查阅了相关资料,我给出的答案是:可以,但是不合理,不推荐。
避免用 Redis 直接存储 Token
还记得我们安全性分析的前提么:如果攻击者拿到了我们数据库的全部数据,他能做什么?
将 Token 保存在 Redis 中,一定是有风险的。如果服务器被攻破,用户 Token 泄漏的话,在规定的过期时间内,这些被泄漏的 Token 将会使用户账户变得非常危险。
当然,如果系统运行在内网环境,或者系统本身对用户安全的要求不高,这种方案从某种程度上讲,确实可以提升鉴权效率,简化鉴权流程。但是鉴于其可能存在的安全问题,�我不推荐。
可以用 Redis 缓存 Salt
在我们的产品设计中,我们使用 Salt 计算 Secret Key,然后再进行 Token 认证。我们可以在用户登录时把 Salt 缓存到 Redis 中以提升查询效率。
进一步优化
使用 Payload 生成 Secret Key
现在,整个系统的安全性基本可靠了。但是,仔细分析系统的设计,还是有一点问题:每次鉴权都需要去查询 Salt,I/O 开销比较大。这恰恰也是有些人使用 Redis 的原因之一 —— 提升查询速度。
仔细分析一下,我们用 Salt 当做了生成 Secret Key 的 Seed ,目的在于保证 Secret Key 唯一,同时不直接存储 Secret Key 。但其实,保持 Secret Key 唯一的方式有很多,不一定要通过 Salt 。实际上只有登录操作必须依赖 Salt,鉴权操作完全可以使用别的机制。
我们可以使用 JWT 的 Payload 中的某些字段,通过特定算法生成 Secret Key。比如:有效期时间戳 + 用户名,再取 SHA256 散列值(当然可以更复杂,不过要注意性能开销)。因为生成 Secret Key 的算法是不透明的,所以 Secret Key 也是相对安全的。
如果对把生成 Token 的信息放在 Payload 中心存顾虑的话,我们可以在服务器上通过静态配置文件的方式设置固定的 Secret Salt ,配合 Payload 生成 Secret Key。
通过这样的方式,我们可以避免在鉴权阶段对数据库进行访问,提升响应效率。我们也可以利用 Secret Salt 进行细粒度的权限角色划分,在此就不赘述了。
更标准的密码加密模式
关于密码加密等方式,我的老师给了我一些建议:可以使用 Blowfish 算法进行对称加密。这样的加密更标准,更安全。
JWT Token 与前端
JWT Token 应该放在哪
官方建议使用 Bearer 的模式,即:
Authorization: Bearer <token>
合理使用 Payload,避免 Token 过长
JWT Token 是有 Payload 的,这从一定程度上会造成 Payload 滥用。我在 Chrome 上遇到一个奇怪的 Bug:如果 Authorization 过长,Chrome 传递这个字段的时候会发生截断。我们的产品刚开始研发的时候,过度依赖 JWT 的 Payload 传递用户基本信息(用户名、所属用户组、邮箱等),造成 Token 长度非常长。后来对 Token 进行了几次瘦身,才避免了 Chrome 上的 Bug。
前端真的需要依赖 Payload 吗?
答案是否定的。前端并不关心,也不应该关心 Token 的 Payload 是什么,真正使用 Payload 的应该是后端。前端获取用户信息的方式,应当是在用户登录的时候,由服务器作为 HTTP Response 回传,并使用 Cookie / Local Storage / Session Storage 进行持久化存储,而不是通过解析 Token 的 Payload 获得。
以上就是我对 Web App Token 鉴权方案的一些思考。以下是有关 Web App Token 认证的文档:
RFC 6749: The OAuth 2.0 Authorization Framework
RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage
RFC 7519 JSON Web Token (JWT)