原文:10 Things You Should Know about Tokens
序言
几周之前,我们发表了一篇针对AngularJs应用的文章:单页应用中cookies和tokens对比。这样的主题在社区反应良好,所以我们发表了第二篇文章:类似socket.io的实时框架中的基于Token的认证。这篇文章同样大受欢迎,所以我们决定继续写一篇文章,以此探索基于Token的认证中的一些最常见问题的更多细节。所以,就有了这篇文章。
1 Tokens 应该被存储在local/session storage或者cookies中
在应用tokens的单页应用中,有人就遇到过这样的问题:刷新浏览器后,怎么处理tokens?答案很简单:你必须将tokens存储在某个位置:session storage、local storage或者客户端的 cookies。当浏览器不支持session storage 的时候,绝大部分的session storage的实现会依赖于cookies。
你可以会产生这样的疑问:如果我在cookies中存储了Token,那我岂不是又回到了原点?实际上并非如此,这种情形下,你只是使用cookies来实现存储机制,而非认证机制(例如此时cookie并没有被web框架用来认证一个用户,因此不会带来XSRF攻击问题)。
2 Tokens也会像cookies一样过期,但你有更多的控制权
Tokens有过期时间(在JWT中用exp属性表示),否则就可以一次登录永久认证了。出于同样的原因,cookies也有过期时间。
在cookies的范畴内,有以下几种选择来控制cookie的生命周期:
- (1)当浏览器关闭时,session cookies会被销毁;
- (2)另外你可以实现服务端的检查(通常你使用的web框架会为你完成),并且可以设置过期时间。
- (3)通过设置过期时间,cookies可以持久化(关闭浏览器后仍不销毁)
在tokens的范畴内,当token过期后,你只需要得到一个新的token即可。你可以在某个节点刷新token:
- (1)校验旧的token
- (2)检查用户是否依然存在、授权有没有被收回等一切对你的应用有意义的事情
- (3)生成一个带有新的过期时间的新的token
你甚至可以在token中存储原始的生成时间,并在两周后强制用户重新登录。
app.post('/refresh_token', function (req, res) {
// verify the existing token
var profile = jwt.verify(req.body.token, secret);
// if more than 14 days old, force login
if (profile.original_iat - new Date() > 14) { // iat == issued at
return res.send(401); // re-logging
}
// check if the user still exists or if authorization hasn't been revoked
if (!valid) return res.send(401); // re-logging
// issue a new token
var refreshed_token = jwt.sign(profile, secret, { expiresInMinutes: 60*5 });
res.json({ token: refreshed_token });
});
如果你想使一个过期时间很长的token失效,你需要类似注册表的东西来再次验证已发布的tokens。
3 Local/session storage无法跨域工作,请使用标记cookie
如果你设置一个cookie的作用域为.yourdomain.com
,那么这个cookie将在yourdomain.com
和app.yourdomain.com
中都有效。这样的话,假如用户已经在yourdomain.com
登录,你可以很容易通过redirect的方式使该用户进入app.yourdomain.com
。
然而,存储在local/session storage的tokens并不支持跨域访问,即使是该域名下的子域名也不可以。这时你该怎么办呢?
一种可能的方案是,当用户在app.yourdomain.com
认证时,你生成一个token,并在yourdomain.com
设置一个cookie。
$.post('/authenticate', function() {
// store token on local/session storage or cookie
....
// create a cookie signaling that user is logged in
$.cookie('loggedin', profile.name, '.yourdomain.com');
});
然后,在youromdain.com
中你可以检查该cookie是否存在,如果该cookie存在就通过redirect的方式让用户进入app.yourdomain.com
。这个token在应用的子域名中是可用的,通过子域认证后,就可以正常使用token了(如果token此时仍然有效,就使用该token,如果失效了,就生成新的,直到最后一次登录超过了你设置的阈)。
当然,这样有可能发生一种情况:cookie存在,但token被删除了或者其他事情发生了。在这种情形下,用户就必须重新登录了。我想说的重点是,正如之前所说,我们使用cookie并不是进行认证,而仅仅因为cookie的存储机制支持跨域访问时存储信息,我们用cookie进行存储tokens而已。
4 每一个跨域请求都将进行preflight请求
有人指出,Authorization header并不是一个简单的header,因此对于特定的urls必须要进行一次 pre-flight请求。
OPTIONS https://api.foo.com/bar
GET https://api.foo.com/bar
Authorization: Bearer ....
OPTIONS https://api.foo.com/bar2
GET https://api.foo.com/bar2
Authorization: Bearer ....
GET https://api.foo.com/bar
Authorization: Bearer ....
只有在设置了类似Content-Type: application/json
的请求头时才会这么干。
但对于绝不部分应用来说,这种情况已经非常普遍。
需要注意的是,OPTIONS请求本身并没有Authorization header,所以你的web框架应该区别对待OPTIONS请求和后续的请求(提示:Microsoft IIS由于某些原因会出现一些问题)。
5 当你需要stream something时,请使用token来得到一个签名的请求
使用cookie的时候,你可以很容易地触发文件下载并传送一些内容。但是,在tokens的世界中,由于请求是通过XHR来完成,所以你不能指望它。解决的办法就是,像AWS那样生成一个签名请求。 Hawk Bewits是一个实现该功能的非常好的框架。
REQUEST:
POST /download-file/123
Authorization: Bearer...
RESPONSE:
ticket=lahdoiasdhoiwdowijaksjdoaisdjoasidja
这里的ticket是无状态的,它是基于URL( host + path + query + headers + timestamp + HMAC)生成的,并且有过期时间。所以它可以用来在接下来的,比如5分钟,来下载文件。
然后你redirect到/download-file/123?ticket=lahdoiasdhoiwdowijaksjdoaisdjoasidja
。服务器将检查该ticket的有效性并继续照常工作。
6 处理XSS比处理XSRF更容易
Cookies有这样的一个特性:在服务器端为cookie设置HttpOnly标识,这样Cookies就只能通过服务器
访问,而不能通过JavaScript访问(译者注:HttpOnly属性可以阻止客户端脚本访问Cookie)。这一点非常有用:可以阻止通过XSS攻击来获取cookie中的内容。
由于tokens存储在local/session storage或者 cookie中,因此token可以通过XSS攻击来获取。这是一个值得警惕的地方。鉴于此,你应该把token的过期时间设置地尽量短。
但是如果你考虑到cookies的攻击面,其中一个主要的就是XSRF。现实是,XSRF是被误解甚多的攻击,大部分开发者可能并没有理解这种风险,所以很多应用缺少防XSRF攻击的策略。但是,每个人都知道注入是什么。简单来讲,如果你允许在你的网站输入但没有对输入内容进行转义,你将面临XSS攻击。根据我们的经验,防范XSS攻击比防范XSRF攻击要容易一些。另外,并不是每个web框架都建立了防范XSRF的机制。而XSS攻击可以很容易地通过大部分的模板引擎的默认的转义语法进行防范。
7 每次请求都携带token,请留意它的大小
每次你写一个API的请求,都会在Authorization header中发送token:
GET /foo
Authorization: Bearer ...2kb token...
vs
GET /foo
connect.sid: ...20 bytes cookie...
取决于你在token中存放多少信息,token可能会很大。而cookies通常只会包含一个身份信息(connect.sid,PHPSESSID等),其他内容则存放在服务器中(如果只有一个服务器则是在内存中,如果是服务器群则是在数据库中)。
现在你可以随意实现token机制。token中包含最基本的信息,在服务器端你可以在每个API请求中用更多数据扩充它。这正是cookie做的事情,不同之处在于,你对token可以进行有完全的控制权,可以有意识地决定存放哪些数据,它已经是你代码的一部分了。
GET /foo
Authorization: Bearer ……500 bytes token….
然后在服务端:
app.use('/api',
// validate token first
expressJwt({secret: secret}),
// enrich req.user with more data from db
function(req, res, next) {
req.user.extra_data = get_from_db();
next();
});
```
值得一提的是,你也可以将session完全存储在cookie中(而不是仅仅只存储一个身份)。有些web平台支持这样做,有些则不支持。例如,在node.js中你可以使用[mozilla/node-client-sessions](https://github.com/mozilla/node-client-sessions)。
### 8 如果你在token中存放私密信息,请对token加密
token签名可以阻止篡改它的内容。TLS/SSL可以阻止中间人攻击。但是如果payload包含用户敏感信息(像SSN等),你也可以对敏感信息加密。对于JWT而言意味着实现JWE规范,但大部分的依赖库都还没有实现JWE标准。所以,最简单的就是,像下面这样使用AES-CBC加密:
```
app.post('/authenticate', function (req, res) {
// validate user
// encrypt profile
var encrypted = { token: encryptAesSha256('shhhh', JSON.stringify(profile)) };
// sing the token
var token = jwt.sign(encrypted, secret, { expiresInMinutes: 60*5 });
res.json({ token: token });
}
function encryptAesSha256 (password, textToEncrypt) {
var cipher = crypto.createCipher('aes-256-cbc', password);
var crypted = cipher.update(textToEncrypt, 'utf8', 'hex');
crypted += cipher.final('hex');
return crypted;
}
```
当然你也可以像第7条指出的那样,将私密信息放在数据库中。
更新:[Pedro Felix](https://twitter.com/pmhsfelix) 正确地指出:MAC-then-encrypt对于 [Vaudenay-style attacks](http://www.thoughtcrime.org/blog/the-cryptographic-doom-principle/)是非常脆弱的。我已经更新了encrypt-then-MAC代码。
### 9 JWT可以用在OAuth中:Bearer Token
Tokens常常和OAuth联系在一起。OAuth2是一种用来解决身份认证问题的授权协议。在OAuth2中,用户首先会被提示同意读取他/她的数据,然后授权服务器会返回一个access_token,它可以代表该用户的身份去调用APIs。
通常来讲,这些tokens都是不透明的,被称为bearer tokens。bearer tokens是一种随机字符串,这些字符串会与过期时间、请求的范围(如:好友列表)以及当前授权用户信息一起,以哈希表的方式存储在服务器(数据库、缓存等)中。之后,当API被调用的时候,token一同被发送,服务器就会在哈希表中查找,以此判断授权信息(token是否过期?当前token是否对想要调用的API有足够的作用范围?)。
这些tokens与我们之前讨论的普通tokens的主要区别在于,签名的tokens(像JWT)是无状态的,它们无需存储在哈希表中,因此是一种更轻量级的方案。OAuth2并没有规定access_token的格式,所以你可以从授权服务器返回一个包含作用域/权限、过期时间的JWT。
### 10 Tokens并非灵丹妙药,请慎重考虑的授权用例。
几年前,我们帮助某大型公司实现了一套基于token的架构。这是一家拥有10万+员工、大量信息需要被保护的公司。他们想对“认证和授权”建立一个集中的、全机构的仓库。想象一个这样的用例:用户X可以获取位于国家W的医院Z的临床试验Y的id和name。你可以想象,这种细粒度的授权机制,无论从技术上还是行政上,都会很快变得无法管理。
* Tokens可以变得很大
* 你的apps/APIs变得更复杂
* 无论是谁来负责授予权限这个工作,他管理起来都会很痛苦
最终我们致力于信息架构上,以此确保生成合理的范围和权限。结论:抵抗住将所有东西都放在tokens中的诱惑,在决定从头到尾用token方案之前做一些分析和调整。
**免责声明:**在面对安全问题的时候,请确保你尽力做好每件事。这里的代码和建议仅仅作为参考。
### 原文概念补充
**Polyfill**:用于实现浏览器并不支持的原生API的代码。
**XSS** : `XSS`攻击通常指的是通过利用网页开发时留下的漏洞,通过巧妙的方法注入恶意指令代码到网页,使用户加载并执行攻击者恶意制造的网页程序。这些恶意网页程序通常是[JavaScript](https://zh.wikipedia.org/wiki/JavaScript),但实际上也可以包括[Java](https://zh.wikipedia.org/wiki/Java),[VBScript](https://zh.wikipedia.org/wiki/VBScript),[ActiveX](https://zh.wikipedia.org/wiki/ActiveX),[Flash](https://zh.wikipedia.org/wiki/Flash)或者甚至是普通的[HTML](https://zh.wikipedia.org/wiki/HTML)。攻击成功后,攻击者可能得到更高的权限(如执行一些操作)、私密网页内容、[会话](https://zh.wikipedia.org/wiki/%E4%BC%9A%E8%AF%9D)和[cookie](https://zh.wikipedia.org/wiki/Cookie)等各种内容。
**XSRF**:跨站请求伪造(`Cross-site request forgery`),也被称为`one-click attack `或者`session riding`,通常缩写为`CSRF` 或者`XSRF`, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟[跨网站脚本](https://zh.wikipedia.org/wiki/%E8%B7%A8%E7%B6%B2%E7%AB%99%E6%8C%87%E4%BB%A4%E7%A2%BC)(XSS)相比,**XSS** 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并执行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去执行。这利用了web中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。
**man in the middle attacks**:在[密码学](https://zh.wikipedia.org/wiki/%E5%AF%86%E7%A0%81%E5%AD%A6)和[计算机安全](https://zh.wikipedia.org/wiki/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%AE%89%E5%85%A8)领域中,中间人攻击(`Man-in-the-middle attack`,缩写:`MITM`)是指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方直接对话,但事实上整个会话都被攻击者完全控制。在中间人攻击中,攻击者可以拦截通讯双方的通话并插入新的内容。在许多情况下这是很简单的(例如,在一个未加密的[Wi-Fi](https://zh.wikipedia.org/wiki/Wi-Fi) [无线接入点](https://zh.wikipedia.org/wiki/%E6%97%A0%E7%BA%BF%E6%8E%A5%E5%85%A5%E7%82%B9)的接受范围内的中间人攻击者,可以将自己作为一个中间人插入这个网络)。
一个中间人攻击能成功的前提条件是攻击者能将自己伪装成每一个参与会话的终端,并且不被其他终端识破。中间人攻击是一个(缺乏)相互[认证](https://zh.wikipedia.org/wiki/%E8%AE%A4%E8%AF%81)的攻击。大多数的加密协议都专门加入了一些特殊的认证方法以阻止中间人攻击。例如,[SSL](https://zh.wikipedia.org/wiki/SSL)协议可以验证参与通讯的一方或双方使用的证书是否是由权威的受信任的[数字证书认证机构](https://zh.wikipedia.org/wiki/%E6%95%B0%E5%AD%97%E8%AF%81%E4%B9%A6%E8%AE%A4%E8%AF%81%E6%9C%BA%E6%9E%84)颁发,并且能执行双向身份认证。
菜鸟一枚,不当之处,欢迎指正。