2.6 Cookie和Session

大家都知道,在做Web项目时,用户登录系统是需要鉴权的,无论是什么方式的鉴权,都需要在浏览器上记录一个登录态,通用的实现方式是在Cookie上种一个字符串,下次请求的时候会带上这个Cookie进行鉴权。

2.6.1 你真的了解Cookie吗

我们首先了解一下Cookie的背景,当年网景公司有一个程序员为了解决用户网购查看购物车历史记录的问题,提出了Cookie这个概念,后来发现该设计确实能解决HTTP无状态的问题,于是该方案被各大浏览器所采用。那么什么是HTTP无状态呢?这里举一个生活中的例子来解释一下。

一天早上,我碰到了门卫王大爷,如果是在有状态的情况下,对话是下面这样的。

我:王大爷早上好,我是小刘。

王大爷:哦,小刘呀,早上好啊。

我:王大爷,我要去上班了,帮我开一下门。

王大爷:好嘞。

王大爷在第一次对话中,知道了我的身份,即小刘,那发起下一次对话的时候,王大爷就知道我是小刘了。如果是在无状态的情况下,对话就是下面这样了。

我:王大爷早上好,我是小刘。

王大爷:哦,小刘呀,早上好啊。

我:王大爷,我要去上班了,帮我开一下门。

王大爷:咦,你是谁呀,是这个小区的吗?

这就是无状态会话带来的问题,每次对话都相当于一次新的对话,不会记录上一次的会话身份,这就是HTTP无状态性。Cookie就是用来解决这个问题的。

Cookie是存在于客户端的,这里以Chrome为例。打开控制台,找到应用程序下的Cookies,点开就能看到存储的Cookie了,如图2-7所示。

0

图2-7 Chrome中的Cookie

因为Koa框架本身就集成了操作Cookie的中间件,所以操作Cookie比较方便,直接使用Koa提供的方法即可。读取Cookie和设置Cookie的两个方法如下。

  • ctx.cookies.get(name, [options]):读取上下文请求中的Cookie。
  • ctx.cookies.set(name, value, [options]):在上下文中写入Cookie。

下面展示一个实例,代码如下。

const Koa = require('koa')
const app = new Koa()
const Router = require('koa-router')
const router = new Router()

router.get('/setCookie', async (ctx) => {
  ctx.cookies.set(
    'id',
    '123456',
    {
      domain: '127.0.0.1',     // Cookie所在的domain(域名)
      expires: new Date('2022-10-01'), // Cookie的失效时间
      httpOnly: false,         // 是否只在HTTP请求中获取
      overwrite: false                // 是否允许重写
    }
  )
  ctx.body = `设置成功`
})

router.get('/getCookie', async (ctx) => {
  const cookie = ctx.cookies.get('id')
  console.log(cookie)
  ctx.body = `cookie为:${cookie}`
})

// 加载路由中间件
app.use(router.routes())

app.listen(4000, () => {
  console.log('server is running, port is 4000')
})

当请求/setCookie路由的时候,会在Response对象中带上一个Set-Cookie的头,将其种在浏览器中,效果如图2-8所示。

0

图2-8 Cookie种在浏览器里

看起来Cookie已经可以解决HTTP无状态的问题了,为什么还会有Session呢?这是因为Cookie在浏览器端是可见的,如果把鉴权的明文信息存储在Cookie里,肯定是不安全的,而Session正好可以解决这个问题。

2.6.2 Session的秘密

简单来说,Session是客户端和服务端之前的会话机制,它是基于Cookie实现的,Session信息一般是存储在服务端的,会给浏览器返回一个SessionID之类的标识,下次请求带上SessionID就可以解锁对应的会话信息。可以将SessionID理解为一把钥匙,这把钥匙只有服务端能够理解并解锁,这就是Session安全的原理。

提示

上面提到,Session信息一般是存储在服务端的,其实Session也可以存储在客户端,比如Cookie中、localStorage中。这种存储方式虽然方便,但是有不足:一是安全性差,Session信息虽然可以是加密的,但是如果被破解,其他人也会拥有该Session权限;二是不灵活,因为Session存储在客户端,服务端无法干预,所以在Session过期之前会一直有效。

Session的实现流程如图2-9所示。

0

图2-9 Session的流程图

Session信息可以存储在数据库中,也可以存储在Redis中,在实际的项目中,多数存储在Redis中,主要是因为Redis速度快,并且使用方便。本节以Redis为例进行讲解。首先需要在电脑上安装Redis,读者可以到官网(https://redis.io/download)自行下载及安装。安装后在终端执行redis-server命令,效果如图2-10所示。

0

图2-10 Redis安装成功

接下来实现一个模拟登录的功能,当新用户登录系统时,提示用户第一次登录,后续再登录的时候,提示用户已经登录。

首先,实现客户端的登录页面功能,用户可以输入用户名和密码进行登录,效果如图2-11所示。

0

图2-11 登录页面

该页面代码文件为index.html(因为后端代码有读取该文件的操作,所以这里说明文件名是为了方便读者理解代码逻辑),实现如下。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,
    initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>登录</title>
</head>

<body>
  <div>
    <label for="user">用户名:</label>
    <input type="text" name="user" id="user">
  </div>
  <div>
    <label for="psd">密码:</label>
    <input type="password" name="psd" id="psd">
  </div>
  <button type="button" id="login">登录</button>
  <h1 id="data"></h1>
  <script>
    const login = document.getElementById('login');
    login.addEventListener('click', function (e) {
      const usr = document.getElementById('user').value;
      const psd = document.getElementById('psd').value;
      if (!usr || !psd) {
        return;
      }
      //采用fetch发起请求
      const req = fetch('http://localhost:4000/login', {
        method: 'post',
        body: `usr=${usr}&psd=${psd}`,
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        }
      })
      req.then(stream =>
        stream.text()
      ).then(res => {
        document.getElementById('data').innerText = res;
      })
    })

  </script>
</body>

</html>

接下来实现服务端的逻辑。服务端主要负责接收客户端传过来的用户名和密码并生成Session信息。当用户再次登录时,需要通过Session信息判断该用户是否登录过。为了方便读者理解,将代码实现分为两步:第一步Session信息直接返回客户端,放在Cookie里面;第二步加入Redis存储,把Session信息存储在服务端。

先看第一步的实现,Session存储在Cookie里的代码(文件名为app.js)实现如下。

const Koa = require('koa');
const fs = require('fs');
const Router = require('koa-router')
const bodyParser = require('koa-bodyparser')
const session = require('koa-session');
const app = new Koa();
const router = new Router()

const sessionConfig = {
  // Cookie 键名
  key: 'koa:sess',
  // 过期时间为一天
  maxAge: 86400000,
  // 不做签名
  signed: false,
};

app.use(session(sessionConfig, app));
app.use(bodyParser())
app.use(router.routes())
// 用来加载前端页面
router.get('/', async ( ctx ) => {
  ctx.set({ 'Content-Type': 'text/html' });
  ctx.body = fs.readFileSync('./index.html');
})

// 当用户登录时
router.post('/login', async ( ctx ) => {
  const postData = ctx.request.body  // 获取用户的提交数据
  if (ctx.session.usr) {
    ctx.body = `欢迎, ${ctx.session.usr}`;
  } else {
    ctx.session = postData;
    ctx.body = '您第一次登录系统';
  }
})

app.listen(4000, () => {
  console.log('server is running, port is 4000')
})

由上面的代码逻辑可以看出,当用户第一次登录时,是没有Session信息的,那么会提示“您第一次登录系统”。当用户再次登录时,服务端根据Cookie获得用户信息,提示用户已经登录过。

比如,用户名为liujianghong,密码是123456。用户在前端页面第一次登录时,效果如图2-12所示。

0

图2-12 第一次登录效果图

当用户再次登录时,效果如图2-13所示。

0

图2-13 再次登录效果图

Cookie里面存储的就是Session信息。这种方式既不安全又不灵活。接下来,在服务端接入Redis,将Session信息存储在Redis里。Node端用到的Redis包是ioredis,简单好用,读者自行安装即可。这里主要讲解一下Redis类的实现。Redis主要以key-value的形式存储数据,那么就会涉及CRUD操作,先看一下Redis类的封装,文件名为store.js,代码如下。

const Redis = require('ioredis');
class RedisStore {
  constructor(redisConfig) {
    this.redis = new Redis(redisConfig);
  }
  // 获取
  async get(key) {
    const data = await this.redis.get(`SESSION:${key}`);
    return JSON.parse(data);
  }
  // 设置
  async set(key, sess, maxAge) {
    await this.redis.set(
      `SESSION:${key}`,
      JSON.stringify(sess),
      'EX',
      maxAge / 1000
    );
  }
  // 销毁
  async destroy(key) {
    return await this.redis.del(`SESSION:${key}`);
  }
}

module.exports = RedisStore;

Redis类的封装主要包括获取、设置、销毁3个操作,这些操作主要是通过Redis提供的方法实现的。Session的具体信息存储在Redis里,还需要一个key值来映射Session信息,这里用一个工具生成ID作为Redis中的key值。接下来丰富app.js逻辑,增量代码如下。

const Store = require('./store')
const shortid = require('shortid');
const redisConfig = {
  redis: {
    port: 6379,
    host: '127.0.0.1',
    password: '',
  },
};

const sessionConfig = {
  // Cookie 键名
  key: 'koa:sess',
  // 保存期限为一天
  maxAge: 86400000,
  // 不做签名
  signed: false,
  // 提供外部存储
  store: new Store(redisConfig),
  // 键的生成函数
  genid: () => shortid.generate(),
};

本地Redis的默认端口是6379,并且是没有密码的,如果是具体的线上服务,Redis是需要经过申请的,并且也是有密码的。shortid的作用是生成一个简短的ID,作为Cookie中的value,以及Redis中的key,它是连接SessionID和Session信息的桥梁。效果如图2-14所示。

0

图2-14 接入Redis后第一次登录

注意

执行app.js的时候,Redis需要处于运行状态。

可以看到,Cookie中的Value是生成的shortid。再看一下Redis中的信息,在终端输入redis-cli,打开Redis客户端,输入命令get SESSION: jRVzJ7eFp,可以获得Session信息,效果如图2-15所示。

0

图2-15 Redis查询Session信息

本节主要讲解了Cookie和Session在Koa中的使用,用户登录的相关内容还有很多,比如在一些大型企业里会有很多系统,如果员工访问每个系统都需要手动登录,用户体验会非常糟糕,目前业界比较通用的方案是SSO(Single Sign-On,单点登录)。在4.2.2节中,笔者会深入介绍其原理。