原文链接:Here
原作者:Matias Woloski
几周前我们发表了一篇短文《cookies与tokens在单页应用中的对比》(主要以AngularJs应用为例)。社区里对这个话题很感兴趣,于是我们接着发表了第二篇《在socket.io等实时框架中基于Token的认证》。趁着大家对这个话题还保持着热情,我们决定再写一篇文章进一步探讨基于Token认证的常见问题。我们开始吧~
1.Tokens需要保存在Local Storage、Session Storage或Cookies中
在Tokens被应用于单页应用的背景下,有人提出了问题:在浏览器中刷新页面时,token会发生什么变化。答案很简单:你需要将token保存在Local Storage、Session Storage或客户端内置的Cookies中,在浏览器不支持Session Storage的情况下,它会被polyfills为cookies。
你或许想问“但如果我将token保存在cookie的话,岂不是很危险”。其实不然,这种情况下,你使用的cookies只是作为存储机制而非验证机制(即是说,web框架不会使用cookie来验证用户,因此不存在XSRF攻击的危险)
2.Tokens像Cookies一样会过期,但你有更多的控制权
Tokens具有生命周期(在JSON Web Tokens中由exp
属性控制),否则用户就可以登录一次却永远无需认证了。Cookies由于相同的原因也具有生命周期。
在Cookies中,如下不同情况生命周期也不一样:
1.当关闭浏览器时,Cookies会被销毁(如Session Cookies)
2.你也可以实现一个服务端检查机制(通常由Web框架帮你完成),你可以设置生命周期或滑动窗口生命周期。
3.Cookies在一段时间内可以是永久的(即使关闭浏览器也不会被销毁)
在Tokens中,一旦过期,你只需要获取一个新的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 }); });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
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 ( ) & gt ; 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 } ) ;
} ) ;
|
3.Local/Session Storage无法跨域,请使用Cookies作标记
如果你将cookie的作用域名设置为.yourdomain.com
,那么它可以在yourdomain.com
和app.yourdomain.com
中被访问。如果用户在主域登录却被重定向到app.yourdomain.com
,要验证cookie也是很方便的。
Tokens存储在local/session storage中,这意味着不同域名之间是无法相互访问的(即使是子域名)。那么你该怎么做呢?
其中一种做法是,当用户在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'); });
|
$ .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' ) ;
} ) ;
|
然后,你可以在
yourdomain.com
验证cookie的存在并重定向到
app.yourdomain.com
。而Token可以在app的子域中使用(如果token依然合法)。
可能会出现cookie存在而token已经被删除的情况。这种情况下,用户必须重新登录。这里需要强调的是,正如我们之前所说,我们并不将cookie当作验证机制,而仅仅把它作为能够跨域保存信息的存储机制。
4.每个CORS请求都会先发一个预请求
有人指出,Authorization报头并非一个简单的报头,因此在向特定URLs发送请求前都需要发送一个预请求。
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 ....
|
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报头信息,所以你的web框架需要对OPTIONS
和后续的请求作一些处理(提示:微软的IIS因为某些原因在这方面存在一些问题)。
5.当你处理流媒体时,请用token获取签名请求
在使用cookies时,你可以轻易地触发文件下载和一些文本流。然而,在tokens中,是通过XHR来完成请求的,你无法依赖于此。解决的办法就是像AWS那样生成一个签名请求,例如,Hawk Bewits就是一个支持该方法的优秀框架:
请求:
POST /download-file/123 Authorization: Bearer...
|
POST / download - file / 123
Authorization : Bearer . . .
|
回应:
ticket=lahdoiasdhoiwdowijaksjdoaisdjoasidja
|
ticket = lahdoiasdhoiwdowijaksjdoaisdjoasidja
|
其中,ticket是无状态的。它通过URL: host + path + query + headers + timestamp + HMAC来生成,并带有过期时间。所以它能在一段时间内(比如5分钟)被用来下载文件。
你会被重定向到/download-file/123?ticket=lahdoiasdhoiwdowijaksjdoaisdjoasidja
。服务器将验证ticket是否合法并完成接下来的业务操作。
6.比起XSRF,处理XSS较为容易
Cookies具有允许设置HttpOnly
标记的特性,它可以只允许服务器访问而非JavaScript。这很有用,因为它保护Cookies不被客户端代码注入攻击(XSS)。
由于tokens被存储在local/Session Storage或者客户端内置的Cookie中,这容易遭受XSS攻击并被攻击者获取到token。这是个令人担忧的问题,因此你需要将tokens的生存时间设置得短一点。
Cookies层面上,一种主要的攻击是XSRF。真实情况是,XSRF是最让人忽略的攻击之一。普通的开发者可能甚至不了解这种风险,因此很多应用缺少反XSRF攻击的机制。然而,每个人都知道注入是什么。简单地说,如果你允许在不转义的情况下渲染用户输入的内容,那么你的应用就暴露在XSS攻击之下了。根据我们的经验,防范XSS比XSRF容易。因为并非所有的web框架都内置了反XSRF机制,而在绝大多数支持转义语法的模板引擎中,防范XSS却是容易得多。
7.每次请求都需要发送token,请注意它的大小
当你每发送一次API请求,都需要将token附加到Authorization
头部中发出去。
GET /foo Authorization: Bearer ...2kb token...
|
GET / foo
Authorization : Bearer . . . 2kb token . . .
|
和Cookies的比较
GET /foo connect.sid: ...20 bytes cookie...
|
GET / foo
connect .sid : . . . 20 bytes cookie . . .
|
token的大小取决于你将多少信息存在里面了,这可能会很大。相比起来,Session Cookies只保存一个标记(connect.sid,PHPSESSID等等),主体内容被保存在服务端(仅有一台服务器就保存在内存,在服务器群中则保存在数据库)。
现在,你完全可以实现一个类似tokens的机制。这个token拥有你需要的基本信息,在服务端中根据每次API的调用,你都可以为token补充更多的信息。Cookies的确也能做到这样,但不同的是tokens有一个好处,你可以完全控制它,毕竟它是你代码的一部分。
GET /foo Authorization: Bearer ……500 bytes 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(); });
|
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。
8.如果存储敏感信息,请对token加密
Token的签名能够防止信息被篡改,TLS/SSL能够防止中间人攻击。但如果包含了用户的敏感信息(身份证号等),你就需要对它进行加密。JWT(JSON Web Tokens)用JWE(JSON Web Encryption)作为规范,但大多数类库都还没有实现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; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
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那样,将敏感信息保存在数据库中。
9.JSON Web Tokens可以在OAuth中使用
Tokens往往和OAuth联系在一起。OAuth2是一种解决身份认证授权的协议。经过用户同意授权访问自己的数据后,认证服务器会返回一个access_token
,这样可以使用用户的身份访问对应的APIs。
通常这些tokens是不透明的。他们被称之为bearer
tokens,并且以随机字符串保存到某种类型的哈希表中,并存储在服务器里(数据库、缓存等)。内容包括过期时间、请求范围(例如访问好友列表)以及授权的用户。然后当API被调用时,token会被发送给服务器,服务器会在哈希表中查找信息并开始验证(比如token是否已过期,是否超出请求范围)。
这种token和我们一直讨论的签名token(如JWT)的主要区别是,后者是无状态的,它们并不需要存储在哈希表中,因此它是一种更轻量级的方法。OAuth2并没有规定access_token
的格式,所以你可以返回一个经授权服务器包含的带有“请求范围、权限列表和过期时间”的JWT。
10.Tokens不是万能的,请仔细考虑授权使用场景
几年前,我们为一家大公司实施开发基于token的架构。这是一个有大量信息需要被保护的拥有超过10万名员工的公司。他们想要实现一个基于“身份验证和授权”的集中式管理组织系统。试想想“用户X可以读取W地区里Z医院的临床试验Y的ID和名称”的应用场景。这种细粒度的授权,你可以想像,不管是在技术还是管理上,都是很难处理的。
- Tokens会变得很大
- 你的apps/APIs会变得很复杂
- 不管是让谁来授予权限都是很难进行下去的
站在信息架构的角度上,为确保创建合理的作用范围和权限,我们放弃了这个工作。
结论:要抵制把一切东西都转换成tokens的诱惑,在使用这种方式时请务必先做好各种分析。