cookie 和 session
众所周知,HTTP 是一个无状态协议,所以客户端每次发出请求时,下一次请求无法得知上一次请求所包含的状态数据,如何能把一个用户的状态数据关联起来呢?
比如在淘宝的某个页面中,你进行了登陆操作。当你跳转到商品页时,服务端如何知道你是已经登陆的状态?
cookie
首先产生了 cookie 这门技术来解决这个问题,cookie 是 http 协议的一部分,它的处理分为如下几步:
服务器向客户端发送 cookie。
通常使用 HTTP 协议规定的 set-cookie 头操作。
规范规定 cookie 的格式为 name = value 格式,且必须包含这部分。
浏览器将 cookie 保存。
每次请求浏览器都会将 cookie 发向服务器。
其他可选的 cookie 参数会影响将 cookie 发送给服务器端的过程,主要有以下几种:
path:表示 cookie 影响到的路径,匹配该路径才发送这个 cookie。
expires 和 maxAge:告诉浏览器这个 cookie 什么时候过期,expires 是 UTC 格式时间,maxAge 是 cookie 多久后过期的相对时间。当不设置这两个选项时,会产生 session cookie,session cookie 是 transient 的,当用户关闭浏览器时,就被清除。一般用来保存 session 的 session_id。
secure:当 secure 值为 true 时,cookie 在 HTTP 中是无效,在 HTTPS 中才有效。
httpOnly:浏览器不允许脚本操作 document.cookie 去更改 cookie。一般情况下都应该设置这个为 true,这样可以避免被 xss 攻击拿到 cookie。
express 中的 cookie
express 在 4.x 版本之后,session管理和cookies等许多模块都不再直接包含在express中,而是需要单独添加相应模块。
var express = require('express');
// 首先引入 cookie-parser 这个模块
var cookieParser = require('cookie-parser');
var app = express();
app.listen(3000);
// 使用 cookieParser 中间件,cookieParser(secret, options)
app.use(cookieParser());
app.get('/', function (req, res) {
// 如果请求中的 cookie 存在 isVisit, 则输出 cookie
// 否则,设置 cookie 字段 isVisit, 并设置过期时间为1分钟
if (req.cookies.jack) {
console.log(req.cookies.jack);
res.send("welcome");
} else {
res.cookie('jack', 'content', {maxAge: 60 * 1000});
res.send("no cookie");
}
});
这里开发调试的时候用supervisor来启动,代码有改动,它会自动重启,避免不必要的手动重启工作。
新版本的开发者工具界面,在application里面可以看到cookies 这些存储
现在我们看到一个cookie名字为jack 内容为content的cookie就存储了,时间期限也有。
如果没有设置时间(maxage/expires),
那就是session cookie,
浏览器关闭的时候cookie就没了。
session
cookie 虽然很方便,但是使用 cookie 有一个很大的弊端,cookie 中的所有数据在客户端就可以被修改,数据非常容易被伪造,那么一些重要的数据就不能存放在 cookie 中了,而且如果 cookie 中数据字段太多会影响传输效率。为了解决这些问题,就产生了 session,session 中的数据是保留在服务器端的。
session 的运作通过一个session_id来进行。session_id通常是存放在客户端的 cookie 中,比如在 express 中,默认是connect.sid这个字段,当请求到来时,服务端检查 cookie 中保存的 session_id 并通过这个 session_id 与服务器端的 session data 关联起来,进行数据的保存和修改。
这意思就是说,当你浏览一个网页时,服务端随机产生一个 1024 比特长的字符串,然后存在你 cookie 中的connect.sid字段中。当你下次访问时,cookie 会带有这个字符串,然后浏览器就知道你是上次访问过的某某某,然后从服务器的存储中取出上次记录在你身上的数据。由于字符串是随机产生的,而且位数足够多,所以也不担心有人能够伪造。伪造成功的概率比坐在家里编程时被邻居家的狗突然闯入并咬死的几率还低。
session 可以存放在 1)内存、2)cookie本身、3)Redis 或 memcached 等缓存中,或者4)数据库中。线上来说,缓存的方案比较常见,存数据库的话,查询效率相比前三者都太低,不推荐;cookie session 有安全性问题,下面会提到。
express 中操作 session 要用到express-session(https://github.com/expressjs/session) 这个模块,主要的方法就是session(options),其中 options 中包含可选参数,主要有:
name: 设置 cookie 中,保存 session 的字段名称,默认为connect.sid。
store: session 的存储方式,默认存放在内存中,也可以使用 redis,MongoDB 等。express 生态中都有相应模块的支持。
secret: 通过设置的 secret 字符串,来计算 hash 值并放在 cookie 中,使产生的 signedCookie 防篡改。
cookie: 设置存放 session id 的 cookie 的相关选项,默认为
(default: { path: '/', httpOnly: true, secure: false, maxAge: null })
genid: 产生一个新的 session_id 时,所使用的函数, 默认使用uid2这个 npm 包。
rolling: 每个请求都重新设置一个 cookie,默认为 false。
resave: 即使 session 没有被修改,也保存 session 值,默认为 true。
1) 在内存中存储 session
express-session默认使用内存来存 session,对于开发调试来说很方便。
var express = require('express');
// 首先引入 express-session 这个模块
var session = require('express-session');
var app = express();
app.listen(5000);
app.use(cookieParser('jack2016'));
//解析cookie secret为‘jack2016’的cookie,可不可以不写secret?不写会报错
}));
// 按照上面的解释,设置 session 的可选参数
app.use(session({
secret:'jack2016',
// 建议使用 128 个字符的随机字符串,这里不写secret的话cookie存储的是不加密的sessionid
name:'jacks', //cookie名字,这里cookie存的内容是用secret加密的sessionid,
cookie: {maxAge:60*2000},//cookie设置,maxAge设置时间好像受到限制,太小直接没效,设置的够大无论是60*60还是60*60*24*12好像都是固定的4小时,这里有点疑惑。
}));
/* GET home page. */
router.get('/',function(req,res,next) {
console.log(req.sessionID,req.cookies.jack,req.signedCookies.jack);
res.render('index',{title:'Express'});
});
signedCookie
cookie 虽然很方便,但是使用 cookie 有一个很大的弊端,cookie 中的所有数据在客户端就可以被修改,数据非常容易被伪造
其实不是这样的,那只是为了方便理解才那么写。要知道,计算机领域有个名词叫签名,专业点说,叫信息摘要算法。
比如我们现在面临着一个菜鸟开发的网站,他用 cookie 来记录登陆的用户凭证。相应的 cookie 长这样:dotcom_user=alsotang,它说明现在的用户是 alsotang 这个用户。如果我在浏览器中装个插件,把它改成dotcom_user=ricardo,服务器一读取,就会误认为我是 ricardo。然后我就可以进行 ricardo 才能进行的操作了。之前 web 开发不成熟的时候,用这招甚至可以黑个网站下来,把 cookie 改成dotcom_user=admin就行了,唉,那是个玩黑客的黄金年代啊。
OK,现在我有一些数据,不想存在 session 中,想存在 cookie 中,怎么保证不被篡改呢?答案很简单,签个名。
假设我的服务器有个秘密字符串,是this_is_my_secret_and_fuck_you_all,我为用户 cookie 的dotcom_user字段设置了个值alsotang。cookie 本应是
{dotcom_user: 'alsotang'}
这样的。
而如果我们签个名,比如把dotcom_user的值跟我的 secret_string 做个 sha1
sha1('this_is_my_secret_and_fuck_you_all' + 'alsotang') === '4850a42e3bc0d39c978770392cbd8dc2923e3d1d'
然后把 cookie 变成这样
{
dotcom_user: 'alsotang',
'dotcom_user.sig': '4850a42e3bc0d39c978770392cbd8dc2923e3d1d',
}
这样一来,用户就没法伪造信息了。一旦它更改了 cookie 中的信息,则服务器会发现 hash 校验的不一致。
毕竟他不懂我们的 secret_string 是什么,而暴力破解哈希值的成本太高。
console.log(req.sessionID,req.headers.cookie.split('=')[1],req.cookies.jack,req.signedCookies.jack);
24iyzhhm1c3xbtaZR6ZTwBiN6Y6oXtvA s%3ADBAuR10qvoQzVl_LCA6BYOfcyDobptqH.mIKackk41TL7vpkpBWXnbARR5tx%2BT8CbFIsIn%2F40J6w undefined DBAuR10qvoQzVl_LCA6BYOfcyDobptqH
app.use(cookieParser()) vs app.use(cookieParser('jack2016'))
写了secret后req.cookies.jack就undefined
只能通过req.headers.cookie分割获取了
我们比较req.sessionID和req.signedCookies.jack发现是加密前的一样的 我们在浏览器村的cookie是机密的,也就是signed cookie,
分割线,传统的身份验证方法从最早的cookie到session以及给session cookie做个加密,接下来我们来看看token认证。
诸如Ember,Angular,Backbone之类的前端框架类库正随着更加精细的Web应用而日益壮大。正因如此,服务器端的组建也正正在从传统的任务中解脱,转而变的更像API。API使得传统的前端和后端的概念解耦。开发者可以脱离前端,独立的开发后端,在测试上获得更大的便利。这种途径也使得一个移动应用和网页应用可以使用相同的后端。
当使用一个API时,其中一个挑战就是认证(authentication)。在传统的web应用中,服务端成功的返回一个响应(response)依赖于两件事。一是,他通过一种存储机制保存了会话信息(Session)。每一个会话都有它独特的信息(id),常常是一个长的,随机化的字符串,它被用来让未来的请求(Request)检索信息。其次,包含在响应头(Header)里面的信息使客户端保存了一个Cookie。服务器自动的在每个子请求里面加上了会话ID,这使得服务器可以通过检索Session中的信息来辨别用户。这就是传统的web应用逃避HTTP面向无连接的方法(This is how traditional web applications get around the fact that HTTP is stateless)。
API应该被设计成无状态的(Stateless)。这意味着没有登陆,注销的方法,也没有sessions,API的设计者同样也不能依赖Cookie,因为不能保证这些request是由浏览器所发出的。自然,我们需要一个新的机制。这篇文章关注于JSON Web Tokens,简写为JWTs,一个可能的解决这个问题的机制。这篇文章利用Node的Express框架作为后端,以及Backbone作为前端。
背景
我们来简短的看一下几个通常的保护(secure)API的方法。
一个是使用在HTTP规范中所制定的Basic Auth, 它需要在在响应中设定一个验证身份的Header。客户端必须在每个子响应是附加它们的凭证(credenbtial),包括它的密码。如果这些凭证通过了,那么用户的信息就会被传递到服务端应用。
第二个方面有点类似,但是使用应用自己的验证机制。通常包括将发送的凭证与存储的凭证进行检查。和Basic Auth相比,这种需要在每次请求(call)中发送凭证。
第三种是OAuth(或者OAuth2)。为第三方的认证所设计,但是更难配置。至少在服务器端更难。
使用Token的方法
不是在每一次请求时提供用户名和密码的凭证。我们可以让用户通过token交换凭证(we can allow the client to exchange valid credentials for a token),这个token提供用户访问服务器的权限。Token通常比密码更加长而且复杂。比如说,JWTs通常会应对长达150个字符。一旦获得了token,在每次调用API的时候都要附加上它。然后,这仍然比直接发送账户和密码更加安全,哪怕是HTTPS。
把token想象成一个安全的护照。你在一个安全的前台验证你的身份(通过你的用户名和密码),如果你成功验证了自己,你就可以取得这个。当你走进大楼的时候(试图从调用API获取资源),你会被要求验证你的护照,而不是在前台重新验证。
关于JWTs
JWTs是一份草案,尽管在本质上它是一个老生常谈的一种更加具体的认证个授权的机制。一个JWT被周期(period)分寸了三个部分。JWT是URL-safe的,意味着可以用来查询字符参数。(译者注:也就是可以脱离URL,不用考虑URL的信息)。
JWT的第一部分是一个js对象,表面JWT的加密方法。实例使用了HMAC SHA-266
{
"typ":"JWT",
"alg":"HS256"
}
在加密之后,这个对象变成了一个字符串:
eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9
JWT的第二部分是token的核心,他也是一个JS兑现,包含了一些信息。有一些是必须的,有一些是选择性的。一个实例如下:
{"iss":"joe",
"exp":1300819380,
"http://example.com/is_root":true}
这被称为JWT Claims Set。因为这篇文章的目的,我们将忽视第三个参数。但是你可以阅读这篇文章.这个iss是issuer的简写,表明请求的实体。通常意味着请求API的用户。exp是expires的简写,是用来限制token的生命周期。一旦加密,JSON token就像这样:
eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ
第三个也是最后一个部分,是JWT根据第一部分和第二部分的签名(Signature)。像这个样子:
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
整个的JWT是这样的
eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
在规范中,有一些选择性的附加属性。有iat表明什么时候token被半吧,nbf去验证在什么时间之前token无效,和aud去指明这个token的收件人是谁。
##处理Tokens我们将用JWT simple模块去处理token,它将使我们从钻研如何加密解密中解脱出来。如果你有兴趣,可以阅读这篇说明,或者读这个仓库的源码。首先我们将使用下面的命令安装这个库。记住你可以在命令中加入--save,让其自动的让其加入到你的package.json文件里面。
npm install jwt-simple
在你应用的初始环节,加入以下代码。这个代码引入了Express和JWT simple,而且创建了一个新的Express应用。最后一行设定了app的一个名为jwtTokenSecret的变量,其值为‘YOUR_SECRET_STRING’(记得把它换成别的)。
var express=require('express');varjwt=require('jwt-simple');
var app=express();app.set('jwtTokenSecret','YOUR_SECRET_STRING');
获取一个Token我们需要做的第一件事就是让客户端通过他们的账号密码交换token。这里有2种可能的方法在RESTful API里面。第一种是使用POST请求来通过验证,使服务端发送带有token的响应。除此之外,你可以使用GET请求,这需要他们使用参数提供凭证(指URL),或者更好的使用请求头。
这篇文章的目的是为了解释token验证的方法而不是基本的用户名/密码验证机制。所以我们假设我们已经通过请求得到了用户名和密码:
/**
* 验证token
*/
functionauthToken(credentialsRequired) {
returncompose()
.use(function(req,res,next) {
if(req.query && req.query.hasOwnProperty('access_token')) {
req.headers.authorization='Bearer '+ req.query.access_token;
}
next();
})
.use(expressJwt({
secret: config.session.secrets,
credentialsRequired:credentialsRequired//是否抛出错误
}))
}
/**
* 验证用户是否登录
*/
functionisAuthenticated() {
returncompose()
.use(authToken(true))
.use(function(err,req,res,next) {
//expressJwt 错误处理中间件
if(err.name==='UnauthorizedError') {
returnres.status(401).send();
}
next();
})
.use(function(req,res,next) {
User.findById(req.user._id,function(err,user) {
if(err)returnres.status(500).send();
if(!user)returnres.status(401).send();
req.user= user;
next();
});
});
}
/**
* 验证用户权限
*/
functionhasRole(roleRequired) {
if(!roleRequired)throw newError('Required role needs to be set');
returncompose()
.use(isAuthenticated())
.use(functionmeetsRequirements(req,res,next) {
if(config.userRoles.indexOf(req.user.role) >= config.userRoles.indexOf(roleRequired)) {
next();
}
else{
returnres.status(403).send();
}
});
}
下一步,我们就需要返回JWT token通过一个验证成功的响应。
注意到jwt.encode()函数有2个参数。第一个就是一个需要加密的对象,第二个是一个加密的密钥。这个token是由我们之前提到的iss和exp组成的。注意到Moment.js被用来设置token将在7天之后失效。而res.json()方法用来传递这个JSON对象给客户端。
验证Token
为了验证JWT,我们需要写出一些可以完成这些功能的中间件(Middleware):
检查附上的token
试图解密
验证token的可用性
如果token是合法的,检索里面用户的信息,以及附加到请求的对象上
我们来写一个中间件的框架
为了获得最大的可扩展性,我们允许客户端使用一下3个方法附加我们的token:作为请求链接(query)的参数,作为主体的参数(body),和作为请求头(Header)的参数。对于最后一个,我们将使用Headerx-access-token。
下面是我们的允许在中间件的代码,试图去检索token:
下一步,我们讲解析JWT:
如果解析的过程失败,那么JWT Simple组件将会抛出一段异常。如果异常发生了,或者没有token,我们将会调用next()来继续处理请求。这代表喆我们无法确定用户。如果一个合格的token合法并且被解码,我们应该得到2个属性,iss包含着用户ID以及exp包含token过期的时间戳。我们将首先处理后者,如果它过期了,我们就拒绝它:
最后,将这个中间件附加到路由里面:
var jwtauth=require('./jwtauth.js');
app.get('/something',[express.bodyParser(),jwtauth],function(req,res){// do something});
或者匹配一些路由
app.all('/api/*',[express.bodyParser(),jwtauth]);
客户端我们提供了一个简单的get端去获得一个远端的token。这非常直接了,所以我们不用纠结细节,就是发起一个请求,传递用户名和密码,如果请求成功了,我们就会得到一个包含着token的响应。
我们现在研究的是后续的请求。一个方法是通过jQuery的ajaxSetup()方法。这可以直接用来做Ajax请求,或者通过前端框架使用包装过的Ajax方法。比如,假设我们将我们的请求使用window.localStorage.setItem('token', 'the-long-access-token');放在本地存储(Local Storage)里面,我们可以通过这种方法将token附加到请求头里面:
var token=window.localStorage.getItem('token');
if(token){
$.ajaxSetup({headers:{'x-access-token':token}});
}
很简单,但是这会劫持所有Ajax请求,如果这里有一个token在本地存储里面。它将会附加到一个名为x-access-token的Header里面。
使用Backbone我们将前一个方法换成Backbone应用。最简单的方法就是使用全局的Backbone.sync(),如下面所示
// Store "old" sync functionvarbackboneSync=Backbone.sync// Now
override Backbone.sync=function(method,model,options){/*
* "options" represents the options passed to the underlying $.ajax call
*/
var token=window.localStorage.getItem('token');
if(token)
{
options.headers={'x-access-token':token}
}
// call the original functionbackboneSync(method,model,options);};
更多的安全
你可以保存签证过的token记录在服务器上,来添加一个附加的安全层,,然后在每一步验证token的时候验证这个记录。这将会组织第三方伪装一个token,也将会使得服务器可以失效一个token。我不会提到这个方面,但是它应当被直接的实现