cookie 是什么
由于 http "无状态" 的特性,在 http1.1 中新增了 cookie 的支持,它是保存在客户端本地的一小块数据,服务端和客户端都可以设置,客户端之后向同一服务器再次发起请求时会携带上该 cookie,可以用来 会话状态管理。
缺陷:
- 每次请求都携带,浪费资源,需要合理设置且不宜过大,不然可能会造成页面白屏(特别是移动端),最大为 4kb 左右,超过最大限制可能被截取丢失。
- cookie 默认不能跨域,且 cookie 存在前端里(会有安全问题),所以我们可能会考虑利用 session 来存储会话信息,前端 cookie 里只保存 session 的标识。
session 是什么
session 表示会话信息,是保存服务器内存的一小块数据(也可以存储到数据库中),原则上存储没有上限,浏览器拿不到所以是安全的,它是基于 cookie 来实现的。
缺陷:
- session 默认都是存在内存中的,如果服务器宕掉了,session 就会丢失,这时候我们可能会考虑存储到数据库中做数据共享,但是任何存储方式都不一定安全,数据库如果也丢了,又没法玩了,我们可以再换一种方案 - jwt
JWT 是什么
服务器根据用户提供的信息生成一个独一无二的令牌,每次请求带上令牌和用的信息,使用用户信息再次生成令牌作对比,这种方式相当于服务器只是参与了计算,没有在服务器存储东西。
cookie 应用
设置 cookie
我们起个服务,在 server 端设置下 cookie
// server.js
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();
router.get('/write', async (ctx) => {
// 单个设置,后面的会完全覆盖前面(即使设置的是 xx,而不是 name 和 age 中的一个),不推荐
// ctx.res.setHeader('Set-cookie', 'xx=ys;');
ctx.res.setHeader('Set-cookie', ['name=ys', 'age=18']);
ctx.body = 'write ok';
});
router.get('/read', async (ctx) => {
ctx.body = ctx.req.headers['cookie'] || 'empty';
});
// allowedMethods 标识如果请求的方法不支持,返回客户端 405 而不是 404
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
console.log(`server start 3000`);
});
浏览器访问 http://localhost:3000/write,打开 Application/cookies 查看 cookie:
http://localhost:3000/read 读取 cookie,页面打印 name=ys; age=18
设置 domain 实现子域 cookie 共享
比如我更改 hosts 文件,设置两个子域不同的域名指向本地 ip:
127.0.0.1 a.ys.com
127.0.0.1 b.ys.com
访问 a.ys.com:3000/write,写入 cookie 成功,此时设置 cookie 默认以当前域名为基准,也就是说,cookie 是设置在 a.ys.com 域的,当我访问 b.ys.com:3000/read,此时肯定读不到 cookie 啦,页面展示 empty。
b.ys.com:3000 访问
我们发现,相同父域下,子域不同却无法共享 cookie,怎么办呢?这时候,我们可以把 cookie 设置到父域下,能达到子域共享 cookie 的目的。
// 这样设置,代表着给 age 字段设置到 .ys.com 域
ctx.res.setHeader('Set-cookie', ['name=ys', 'age=18; domain=.ys.com']);
这时候 b.ys.com:3000/read 就能访问到 age 字段了,注意域名和或 path 不同的话,我们可以设置进多个同名 cookie,比如在 .ys.com 和 a.ys.com 分别设置 age:
ctx.res.setHeader('Set-cookie', ['name=ys', 'age=18;', 'age=18; domain=.ys.com']);
此时在 a.ys.com 下,document.cookie 取到的是 'name=ys; age=18; age=18'
设置 httpOnly,防止 csrf 攻击
xsrf 攻击某些场景:诱导用户的一些行为,如点击一个图片,发送请求通过 url 将本地 cookie 传递给它自己的服务器,然后可以用来伪造身份信息进行攻击,这个场景下肯定利用的是 document.cookie 来获取信息进行传递,httpOnly 设置为 true 能防止客户端拿到 cookie,这样就相对安全一些,samesite 也能做这件事,不过它的限制比较死板,不够灵活。
ctx.res.setHeader('Set-cookie', ['name=ys', 'age=18;', 'age=18; domain=.ys.com; httpOnly=true;']);
这样,document.cookie 取到的是 'name=ys; age=18;',第三个 age 取不到啦,同样浏览器通过代码也无法更改该 cookie。
那么如果不通过代码呢,直接控制台也是可以更改的啊,所以信息放在客户端,始终是不安全的。
cookie 操作封装(1):get & set 方法
上面这种设置方式比较复杂,我希望以下面方式来调用~
router.get('/write', async (ctx) => {
// set cookie
ctx.my.set('name', 'ys', {
domain: '.ys.com',
httpOnly: true,
maxAge: 50 // 50s
});
ctx.my.set('age', '18', {
domain: '.ys.com',
httpOnly: true,
maxAge: 50
});
ctx.body = 'write ok';
});
router.get('/read', async (ctx) => {
// get cookie
ctx.body = ctx.my.get('name');
});
我们来封装一下~
app.use((ctx, next) => {
// key value 数组,叠加,最后一次 setHeader
const cookies = [];
ctx.my = {
set(key, value, options = {}) {
let optsArr = [];
if (options.domain) {
optsArr.push(`domain=${ options.domain }`);
}
if (options.httpOnly) {
optsArr.push(`httpOnly=${ options.httpOnly }`);
}
if (options.maxAge) {
optsArr.push(`max-age=${ options.maxAge }`);
}
cookies.push(`${ key }=${ value }; ${ optsArr.join('; ') }`);
ctx.res.setHeader('Set-cookie', cookies);
},
get(key) {
// "a=1; b=2; b=2" => { a: 1, b: [2, 2] }
let cookieObj = querystring.parse(ctx.req.headers['cookie'], '; ');
console.log(cookieObj,'helo', ctx.req.headers['cookie']);
return cookieObj[key] || '';
}
}
return next();
});
但是这并没有解决刚才我们提到的,可以在控制台篡改 cookie,服务器没有去验证,而是你拿什么给我我用什么,所以我们需要给 cookie 加盐。
cookie 操作封装(2):cookie 加盐
我们目前所有信息都是明文存储传输,这样会导致客户端可以随意更改,我们考虑给用户数据加密,每次传输携带用户数据和上次加密后的结果到服务端再重新加密,进行加密结果的对比,如果结果一致,代表没有篡改过。
exprss 默认的加盐算法是 sha256,koa 默认的加盐算法是 sha1,md5 是个公开的算法,如果使用 md5,则可能加密凭证要被伪造,它们都是不可逆的(无法解密),而且相同内容转换的结果相同,sha256 和 sha1 比 md5 多了个盐值,更加安全。
// sha256(express) sha1(koa) 比 md5 多个盐值
const crypto = require('crypto');
const secret = 'yssecret'; // 秘钥
const toBase64URL = (str) => {
// base64 在客户端往服务器传输的时候,会把 + / = 做特殊处理,所以我们生成的时候进行字符替换
// 因为不需要用来解密,只需要替换为能正常传输的字符即可
return str.replace(/\=/g, '').replace(/\+/g, '-').replace(/\//, '_');
}
app.use((ctx, next) => {
// key value 数组,叠加,最后一次 setHeader
const cookies = [];
ctx.my = {
set(key, value, options = {}) {
//...
// 说明 为了安全 需要给数据签名
if (options.signed) {
// base64 在客户端往服务器传输的时候,会把 + / = 做特殊处理
let sign = toBase64URL(crypto.createHmac('sha1', secret).update([key, value].join('=')).digest('base64'));
cookies.push(`agesign=${sign}`)
}
cookies.push(`${key}=${value}; ${optsArr.join('; ')}`);
ctx.res.setHeader('Set-cookie', cookies);
},
get(key, options) {
let cookieObj = querystring.parse(ctx.req.headers['cookie'], '; '); // "a=1; b=2; b=2" => { a: 1, b: [2, 2] }
if (options.signed) {
// 校验上一次的签名
if (cookieObj[`${key}sign`] == toBase64URL(crypto.createHmac('sha1', secret).update(`${key}=${cookieObj[key]}`).digest('base64'))) {
return cookieObj[key]
} else {
return 'error';
}
}
return cookieObj[key] || '';
}
}
return next();
});
router.get('/write', async (ctx) => {
ctx.my.set('name', 'ys', {
domain: '.ys.com',
httpOnly: true,
maxAge: 50 // 50s
});
ctx.my.set('age', '18', { signed: true });
ctx.body = 'write ok';
});
router.get('/read', async (ctx) => {
// 取值时校验签名
ctx.body = ctx.my.get('age', { signed: true });
});
// ...
这样就完成了加盐,访问 a.ys.com:3000/write:
访问 a.ys.com:3000/read,验证凭证:
注意,这种方法用户存储的值还是明文的,只是我们记录了一个上次加密结果,来验证有没有篡改用户信息。
ctx.cookis.set/get 内置方法
其实以上操作 cookie 和加盐加密的方式,koa 或者 express 中都有实现,只要秘钥一直,加密结果跟咱们结果一致哦。
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const secret = 'yssecret';
const router = new Router();
// app.use 提供cookie用于签名的秘钥
app.keys = [secret];
router.get('/write', async function (ctx) {
// ctx.cookies.set 只要传入第二个 options,都默认签名
// ctx.cookies.set 默认设置 httpOnly: true
ctx.cookies.set('name', 'ys', {
domain: '.ys.com',
httpOnly: true
})
ctx.cookies.set('age', '18', { signed: true })
ctx.body = 'write ok';
})
router.get('/read', async function (ctx) {
ctx.body = ctx.cookies.get('age', { signed: true }) || 'empty';
})
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, function () {
console.log(`server start 3000`)
})
这样就完成了加盐,访问 a.ys.com:3000/write:
访问 a.ys.com:3000/read,验证凭证:
控制台更改 age,然后验证凭证:
session 应用
就算我们加了盐,比如用户首次充值了 100,加盐后的凭证是 abc,下次冲了 200,加盐后的凭证为 bcd,这时候用户学聪明了,下次又冲了 100,并在控制台把 100 改成了 200,把 abc 改成了 bcd,这样骗过了服务器,所以把用户信息明文存储到客户端就是不安全的,我们来看下 session,它是基于 cookie 来实现的。
我们把用户的信息存储到服务器内存,返回了一个用户身份的签名,种到 cookie 中,然后再配个 httpOnly,用户带着自己的身份信息来查询,我从服务器内存来取到用户信息即可。
手动维护 session & 管理 cookie
比如现在有个场景:镇上新开了个理发店,张三第一次去,办一张卡并冲了 500,后面每次理发消费 100 元。
const Koa = require('koa');
const Router = require('@koa/router');
const uuid = require('uuid');
const app = new Koa();
const secret = 'yssecret'
app.keys = [secret];
const router = new Router();
let cardName = 'zs'; // 用户会员卡卡号
let session = {}; // session 就是一个服务器记账的本子,为了稍后能通过这个本找到具体信息
// 第一次访问,给该用户开通会员并重置 500,第二次开始,每次消费 100 元
router.get('/', async function (ctx) {
let user = ctx.cookies.get(cardName, { signed: true });
if (user && session[user]) {
session[user].money -= 100;
ctx.body = '卡内余额: ' + session[user].money
} else {
const id = uuid.v4(); // 冲 500
session[id] = { money: 500 };
ctx.cookies.set(cardName, id, { signed: true });
ctx.body = '恭喜你已经是本店会员了:有500元'
}
})
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, function () {
console.log(`server start 3000`)
})
这种方式每次服务重启,session 都重置了,那么会员就要重新注册了,以前冲的钱也没了,其实我们可以把 session 数据丢到数据库里,这个我们暂且不实现!
第三方包 koa-session
其实已经有人把我们上面的实现封装了一下,这就是 koa-session 包,利用 koa-session 改写是非常方便的,而且不需要我们手动维护 cookie,它内部会自动设置、校验 cookie。
const Koa = require('koa');
const Router = require('@koa/router');
const querystring = require('querystring');
const uuid = require('uuid');
const session = require('koa-session');
const crypto = require('crypto');
const app = new Koa();
const secret = 'yssecret'
app.keys = [secret];
app.use(session({}, app)); // 注册 session 中间件
const router = new Router();
let cardName = 'zs';
router.get('/', async function(ctx) {
let user = ctx.session[cardName];
if (user) {
ctx.session[cardName].money -= 100;
ctx.body = '恭喜你消费了 ' + ctx.session[cardName].money
} else {
ctx.session[cardName] = { money: 500 };
ctx.body = '恭喜你已经是本店会员了 有500元'
}
})
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, function() {
console.log(`server start 3000`)
})
可以看到,他自动帮我们维护了一个叫 koa.sess 的 cookie 哦,koa.sess 也是加盐加密后的凭证。
jwt 应用
jwt(JSON WEB TOKEN)是目前主流的方案,通过把用户身份信息生成 token(令牌) 来实现,它能解决 session 的共享问题(session 也能通过数据库共享,但是数据库还是可能会丢失),jwt 实现了通过令牌来识别身份,首次登录生成一个令牌,用户拿到令牌存储到本地后,每次请求携带令牌,服务器反解令牌得到用户信息,这个非常像 cookie 的签名,但是服务器只存一个秘钥(secret),为了鉴别用户身份。
jwt 实现登录鉴权
实际开发中我们会使用 jsonwebtoken 这个包,功能比较强大,默认支持一些令牌的过期处理(比如续命),不过我们今天先从基础的包(jwt-simple)来一步步实现 jwt,去了解它的原理。
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const jwt = require('jwt-simple');
const secret = 'ys';
app.keys = [secret];
const router = new Router();
// 登录的时候,创建令牌,为了方便测试这里使用了 get 请求
router.get('/login', async (ctx, next) => {
// 生成令牌的数据不要太多,一般情况根据用户的 id 即可
let user = {
id: '5212',
username: '杨帅'
}
let token = jwt.encode(user, secret);
ctx.body = token;
});
// 校验 token
router.get('/validate', async (ctx, next) => {
try {
// jwt 规范规定,token 要放在 header 中 Authorization 内
// (Authorization: Bearer token)
// let token = ctx.get(Authorization).split(' ')[1]
// 这里为了方便,把上一步生成的 token 写死
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjUyMTIiLCJ1c2VybmFtZSI6IuadqOW4hSJ9.U0Cyy5qHC7dHkzAo_sAITzHkg2ZM5HEB4brUmywbtDY";
// token 反解为用户数据
let user = jwt.decode(token, secret);
ctx.body = user;
} catch (error) {
ctx.body = {
err: 1
}
}
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, function () {
console.log(`server start 3000`)
})
这时,我们访问 /login,就会根据用户信息生成一个 token,而访问 /validate 时候,就会把 token 反解为用户信息,那 token 是怎么反解为用户信息的呢?这里面有什么规则?让我们实现一下就知道了。
实现 jwt
我们把生成的 token 分成三段来看
// 第一段是由一个类型和逻辑(就是我们前面提到的 SHA256)的对象转 base64,
// { typ: 'JWT', alg: 'HS256' } -> base64
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
// 用户内容转 -> base64,所以可以反解呀
eyJpZCI6IjUyMTIiLCJ1c2VybmFtZSI6IuadqOW4hSJ9
// 头+内容加盐生成的签名凭证(jwt 加盐算法使用的是 SHA256),用于校验内容是否准确
U0Cyy5qHC7dHkzAo_sAITzHkg2ZM5HEB4brUmywbtDY
const jwt = {
// 生成签名,jwt 第三段
sign(content, secret) {
return this.toBase64URL(crypto.createHmac('sha256', secret).update(content).digest('base64'))
},
toBase64URL: (str) => {
return str.replace(/\=/g, '').replace(/\+/g, '-').replace(/\//, '_');
},
base64urlUnescape(str) {
str += new Array(5 - str.length % 4).join('=');
return str.replace(/-/g, '+').replace(/_/g, '/');
},
toBase64(content) {
return this.toBase64URL(Buffer.from(JSON.stringify(content)).toString('base64'));
},
encode(info, secret) {
console.log(secret, 22)
console.log(info, secret)
// 第一段
const head = this.toBase64({ typ: 'JWT', alg: 'HS256' });
// 第二段
const content = this.toBase64(info);
// 第三段
const sign = this.sign([head, '.', content].join(''), secret);
return head + '.' + content + '.' + sign
},
decode(token, secret) {
let [head, content, sign] = token.split('.');
// 生成新签名
let newSign = this.sign([head, content].join('.'), secret);
if (newSign == sign) {
return JSON.parse(Buffer.from(this.base64urlUnescape(content), 'base64').toString())
} else {
throw new Error('用户更改了信息')
}
}
}
全部代码
const Koa = require('koa');
const crypto = require('crypto');
const Router = require('@koa/router');
const app = new Koa();
// const jwt = require('jwt-simple');
const secret = 'ys';
app.keys = [secret];
const router = new Router();
const jwt = {
// 生成签名,jwt 第三段
sign(content, secret) {
return this.toBase64URL(crypto.createHmac('sha256', secret).update(content).digest('base64'))
},
toBase64URL: (str) => {
return str.replace(/\=/g, '').replace(/\+/g, '-').replace(/\//, '_');
},
base64urlUnescape(str) {
str += new Array(5 - str.length % 4).join('=');
return str.replace(/-/g, '+').replace(/_/g, '/');
},
toBase64(content) {
return this.toBase64URL(Buffer.from(JSON.stringify(content)).toString('base64'));
},
encode(info, secret) {
// 第一段
const head = this.toBase64({ typ: 'JWT', alg: 'HS256' });
// 第二段
const content = this.toBase64(info);
// 第三段
const sign = this.sign([head, '.', content].join(''), secret);
return head + '.' + content + '.' + sign
},
decode(token, secret) {
let [head, content, sign] = token.split('.');
// 生成新签名
let newSign = this.sign([head, content].join('.'), secret);
if (newSign == sign) {
return JSON.parse(Buffer.from(this.base64urlUnescape(content), 'base64').toString())
} else {
throw new Error('用户更改了信息')
}
}
}
// 登录的时候,创建令牌,为了方便测试这里使用了 get 请求
router.get('/login', async (ctx, next) => {
// 生成令牌的数据不要太多,一般情况根据用户的 id 即可
let user = {
id: '5212',
username: '杨帅'
}
let token = jwt.encode(user, secret);
ctx.body = {
err: 0,
data: {
token,
user
}
}
});
// 校验 token
router.get('/validate', async (ctx, next) => {
try {
// jwt 规范规定,token 要放在 header 中 Authorization 内 (Authorization: Bearer token)
// let token = ctx.get(Authorization).split(' ')[1]
// 这里为了方便,把上一步生成的 token 写死
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjUyMTIiLCJ1c2VybmFtZSI6IuadqOW4hSJ9.U0Cyy5qHC7dHkzAo_sAITzHkg2ZM5HEB4brUmywbtDY";
// token 反解为用户数据
let user = jwt.decode(token, secret);
ctx.body = user;
} catch (error) {
ctx.body = {
err: 1
}
}
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, function () {
console.log(`server start 3000`)
})
总结
cookie s优点:
- 简单性:Cookie 是一种基于文本的轻量结构,包含简单的键值对。
- 数据持久性:虽然客户端计算机上 Cookie 的持续时间取决于客户端上的 Cookie 过期处理和用户干预,Cookie 通常是客户端上持续时间最长的数据保留形式。 cookie 缺点:
- 大小受到限制 ,大多数浏览器对 Cookie 的大小有 4096 字节的限制,尽管在当今新的浏览器和客户端设备版本中,支持 8192 字节的 Cookie 大小已愈发常见。
- 非常不安全,cookie 将数据裸露在浏览器中,这样大大增大了数据被盗取的风险,所有我们不应该将中要的数据放在 cookie 中,或者将数据加密处理。
- 容易被 csrf 攻击。
session 优点:
- session 中的信息存储在服务端,相比于 cookie 就在一定程度上加大了数据的安全性。
- session 数据存储在服务端,相比于 jwt 方便进行管理,也就是说当用户登录和主动注销,只需要添加删除对应的 session 就可以,这样管理起来很方便。
session 缺点:
- session 存储在服务端,这就增大了服务器的开销,当用户多的情况下,服务器性能会大大降低。
- 因为是基于 cookie 来进行用户识别的, cookie 如果被截获,用户就会很容易受到跨站请求伪造的攻击。
- 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
jwt 优点:
- 因为 json 的通用性,jwt 可以支持跨语言请求,像 JAVA,J avaScript, NodeJS, PHP 等很多语言都可以使用。
- 因为有了 payload 部分,所以 jwt 可以在自身存储一些其他业务逻辑所必要的非敏感信息。
- 便于传输,jwt 的构成非常简单,字节占用很小,所以它是非常便于传输的。
- 它不需要在服务端保存会话信息, 所以它易于应用的扩展,即信息不保存在服务端,不会存在 session 扩展不方便的情况。
jwt 缺点:
- 登录状态信息续签问题。比如设置 token 的有效期为一个小时,那么一个小时后,如果用户仍然在这个 web 应用上,这个时候当然不能指望用户再登录一次。目前可用的解决办法是在每次用户发出请求都返回一个新的 token,前端再用这个新的 token 来替代旧的,这样每一次请求都会刷新 token 的有效期。但是这样,需要频繁的生成 token。另外一种方案是判断还有多久这个 token 会过期,在 token 快要过期时,返回一个新的token。
- 用户主动注销。jwt 并不支持用户主动退出登录,当然,可以在客户端删除这个token,但在别处使用的token仍然可以正常访问。为了支持注销,我的解决方案是在注销时将该 token 加入黑名单。
使用 jwt 注意点:
- 在 payload 中不应该存放敏感信息,以为该部分客户端是可以解密的。
- secret_key 不能泄露。