cookie、session 和 jwt原理

72 阅读15分钟

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优点:

  1. 简单性:Cookie 是一种基于文本的轻量结构,包含简单的键值对。
  2. 数据持久性:虽然客户端计算机上 Cookie 的持续时间取决于客户端上的 Cookie 过期处理和用户干预,Cookie 通常是客户端上持续时间最长的数据保留形式。 cookie 缺点:
  3. 大小受到限制 ,大多数浏览器对 Cookie 的大小有 4096 字节的限制,尽管在当今新的浏览器和客户端设备版本中,支持 8192 字节的 Cookie 大小已愈发常见。
  4. 非常不安全,cookie 将数据裸露在浏览器中,这样大大增大了数据被盗取的风险,所有我们不应该将中要的数据放在 cookie 中,或者将数据加密处理。
  5. 容易被 csrf 攻击。

session 优点:

  1. session 中的信息存储在服务端,相比于 cookie 就在一定程度上加大了数据的安全性。
  2. session 数据存储在服务端,相比于 jwt 方便进行管理,也就是说当用户登录和主动注销,只需要添加删除对应的 session 就可以,这样管理起来很方便。

session 缺点:

  1. session 存储在服务端,这就增大了服务器的开销,当用户多的情况下,服务器性能会大大降低。
  2. 因为是基于 cookie 来进行用户识别的, cookie 如果被截获,用户就会很容易受到跨站请求伪造的攻击。
  3. 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

jwt 优点:

  1. 因为 json 的通用性,jwt 可以支持跨语言请求,像 JAVA,J avaScript, NodeJS, PHP 等很多语言都可以使用。
  2. 因为有了 payload 部分,所以 jwt 可以在自身存储一些其他业务逻辑所必要的非敏感信息。
  3. 便于传输,jwt 的构成非常简单,字节占用很小,所以它是非常便于传输的。
  4. 它不需要在服务端保存会话信息, 所以它易于应用的扩展,即信息不保存在服务端,不会存在 session 扩展不方便的情况。

jwt 缺点:

  1. 登录状态信息续签问题。比如设置 token 的有效期为一个小时,那么一个小时后,如果用户仍然在这个 web 应用上,这个时候当然不能指望用户再登录一次。目前可用的解决办法是在每次用户发出请求都返回一个新的 token,前端再用这个新的 token 来替代旧的,这样每一次请求都会刷新 token 的有效期。但是这样,需要频繁的生成 token。另外一种方案是判断还有多久这个 token 会过期,在 token 快要过期时,返回一个新的token。
  2. 用户主动注销。jwt 并不支持用户主动退出登录,当然,可以在客户端删除这个token,但在别处使用的token仍然可以正常访问。为了支持注销,我的解决方案是在注销时将该 token 加入黑名单。

使用 jwt 注意点:

  1. 在 payload 中不应该存放敏感信息,以为该部分客户端是可以解密的。
  2. secret_key 不能泄露。