Skip to content

Latest commit

 

History

History
566 lines (523 loc) · 16.9 KB

第7章:状态保持方案.md

File metadata and controls

566 lines (523 loc) · 16.9 KB

第7章:状态保持方案

假设今天双十一,你兴致冲冲的打开淘宝,登陆后开始和别人一起“秒杀”,不巧的是你手滑关闭了浏览器,于是你赶紧重新打开淘宝,发现想买的东西居然还没售罄,果断付款。

在这个过程中,你有没有发现,在你点错退出到重新打开淘宝时,是不用重新登陆的。也许正是避免登陆的那十几秒帮助你成功的秒杀了想要的东西。这是因为浏览器记录了你的登陆状态。

浏览器记录用户状态可以用在很多场景,例如微博和QQ空间的主题,登陆状态记录,存储大规模数据以供查询等等。

浏览器要如何记录用户的状态?这就是本节的内容。


Cookie

cookie算是最古老的存储方式之一了。服务器在返回请求时,在头部添加

Set-Cookie: <cookie-key>=<cookie-value>

这样,响应就携带了一个cookie。浏览器会把这个cookie存储下来,在下一次浏览器请求这个服务器的时候,cookie又会被发送给服务端,我们可以把要传送的数据通过cookie携带,达到通讯的目的。

cookie也可以通过客户端的javascript访问,虽然提供了便利但也增大了危险性(cookie可能被客户端修改)。因此如果是不希望cookie被客户端操作,可以在设置cookie时加上httpOnly属性,例如:

Set-Cookie: user=miku;httpOnly

这样客户端就不再能访问到这个cookie了。

cookie是危险的,在传输过程中可能遭到攻击,因此如果不使用**https**协议进行传输的话,cookie中不能放置任何涉及用户隐私的信息,例如密码。

下面我们就编写一个简单的服务端cookie模块,它能增删查改cookie,来让大家对操作cookie有直观的认识。

在项目目录中创建api/lib/cookie.js

/**
 * 用作中间件的cookie模块
 * 负责操作cookie
 * 
 * @class Cookie
 */
class Cookie {
  /**
   * 构造cookie模块
   * req是httpRequest
   * res是httpResponse
   * _cookie用于记录cookie列表
   * 你可以通过req.cookie访问模块
   * 返回请求时,会自动发送_cookie列表到客户端
   *
   * @param {Object} req
   * @param {Object} res
   * @return {Void}
   */
  constructor(req, res) {
    this._cookie = []
    this._req = req
    this._res = res
    const _hackHead = res.writeHead
    const cookie = req.headers.cookie
    if(cookie) {
      const cook = cookie.split(';')
      cook.forEach((c) => {
        this._cookie.push(c.trim())
      })
    }
    req.cookie = this
    res.writeHead = (...args) => {
      res.setHeader('Set-Cookie', this._cookie)
      _hackHead.apply(res, args)
    }
  }
  /**
   * 解析请求中的cookie
   * 解析后的cookie是一个对象
   * 对象的key为cookie的key
   *
   * @param {Void}
   * @return {Object} cookies | null
   */
  parse() {
    if (this._req.headers.cookie){
      let cookies = {}
      this._req.headers.cookie.split(';').forEach((cookie) => {
        let parts = cookie.split('=')
        cookies[ parts[0].trim() ] = ( parts[1] || '').trim()
      })
      return cookies
    } else {
      return null
    }
  }
  /**
   * 向cookie列表中添加或修改cookie
   * k是要添加的cookie名
   * v是要添加的cookie值
   * opt是cookie的选项,支持的值有:
   *   maxAge: 存活时间,以秒为单位
   *   expires: 过期时间,格式为GMT_String
   *   domain: 浏览器需要发送该cookie的主机名
   *   path: 需要发送cookie的url路径(包含其子域名)
   *   secure: 只能以https进行传输
   *   httpOnly: 在客户端不能被javascript访问
   *
   * @param {String} k
   * @param {String} v
   * @param {Object} opt
   * @return {Void}
   */
  set(k, v, opt={}) {
    let cookie = `${k}=${v}`
    const keys = Object.keys(opt)
    for( let i = 0; i < keys.length; i++ ) {
      const thisKey = keys[i]
      cookie += `;${thisKey}=${opt[thisKey]}`
    }
    this._cookie.push(cookie)
  }
  /**
   * 删除一个cookie
   * k是要删除的cookie的key值
   *
   * @param {String} k
   * @return {Void}
   */
  remove(k) {
    const regStr = '^' + k + '=[^;]+'
    const reg = new RegExp(regStr)
    for(let i = 0; i < this._cookie.length; i++) {
      const cook = this._cookie[i]
      if(reg.test(cook)) {
        this._cookie[i] = cook + ';expires=Thu, 01 Jan 1970 00:00:00 GMT'
        break
      }
    }
  }
}
module.exports = Cookie

配合之前写的core模块,现在我们可以用这种方式操作cookie了:

const Cookie = require('./../api/lib/cookie')
const app = require('./../api/lib/core')()
app.use('/', (req, res, next) => {
  new Cookie(req, res)
  next()
})
app.get('/', (req, res) => {
  //设置cookie
  req.cookie.set('user', 'mirone')
  //修改cookie
  req.cookie.set('user', 'kaka')
  //删除cookie
  req.cookie.remove('user')
  //解析cookie
  const cookieObj = req.cookie.parse()
  res.end()
})

Session

上文中提到了,cookie在非https协议的情境下是不安全的,所以应该尽量避免把重要信息放在其中。那么像登陆信息这样的数据,就不能直接存储在cookie中了。以存储用户登陆状态信息为例:

ifLogIn=true

这样记录登陆状态是非常危险的,只要被人修改状态,就能直接登录了。

那么肯定有人说,如果把用户+密码存储在后端,不就可以防止被修改状态了吗;登陆时直接验证本地的用户和密码即可。

这种机制存在一个问题,就是如果网络服务被监听,那么监听者可以直接拿到用户和密码,以后就可以凭此直接登陆。

因此我们需要一个机制,让判断的凭据可变而又难以找到规律。(很多网站会采用user+password+一个固定字符串然后加密的手段,但是这种办法是很容易被找到规律的)。

从字面意思来讲,session是指会话,用于在客户端与服务端之间创建关联。

session往往是依赖cookie实现的,session和cookie的最大区别在于它存储在服务端,一般的工作流程是:

  1. 用户登陆成功,服务端生成一个sessionId存储在客户端,同时把这个sessionId对应的用户存储在服务端
  2. 客户端再次访问网站,将本地cookie发送到服务端,服务端比对cookie中的sessionId与服务端存储的sessionId,如果比对合格,则登陆成功,否则登陆失败

依据我们之前已经做好的core和cookie模块,就很容易作出session模块了。

在项目目录中创建api/lib/session.js

/**
 * 随机生成sid
 * 算法为当前时间+随机数
 * 
 * @param {Void}
 * @return {Number}
 */
function generateSid() {
  return (new Date()).getTime() + Math.random().toFixed(2)
}
/**
 * 登陆成功时调用
 * 实现单点登陆,删除数据库中相应username
 * 重新生成username和sid
 * sid发送给客户端
 * req是httpRequest
 * user是登陆的用户名称
 * db是依赖于model模块的数据库连接实例
 * 返回一个Promise实例
 *
 * @param {Object} req
 * @param {Object} db
 * @param {String} user
 * @return {Promise}
 */
function sessionLogin(req, db, user) {
  const _time = new Date()
  _time.setYear(_time.getYear() + 1902)
  return new Promise((resolve) => {
    db.removeOne({
      username: user
    }, () => {
      db.add({
        username: user,
        _id: generateSid()
      }, (doc) => {
        req.cookie.set('sid', doc._id, {
          expires: _time,
          httpOnly: true
        })
        resolve()
      })
    })
  })
}
/**
 * 校验用户登陆状态
 * 依赖于core模块
 * 是core模块的中间件
 * 如果登陆成功,req.username有值,否则为null
 * req是指httpRequest
 * db是依赖于model模块的数据库连接实例
 * 返回一个Promise实例
 *
 * @param {Object} req
 * @param {Object} db
 * @return {Promise}
 */
function sessionFilter(req, db) {
  const _cookieObj = req.cookie.parse()
  const _time = new Date()
  _time.setYear(_time.getYear() + 1902)
  return new Promise((resolve) => {
    if(!_cookieObj||!_cookieObj.sid) {
      resolve()
    } else {
      db.findOne({
        _id: _cookieObj.sid
      }, (doc) => {
        if(doc) {
          req.username = doc.username
          req.cookie.set('sid', doc._id, {
            expires: _time,
            httpOnly: true
          })
        }
        resolve()
      })
    }
  })
}
module.exports = {
  /**
   * 可供调用的登陆状态生成函数
   * req是httpRequest
   * db是依赖于model模块的数据库连接实例
   * user是用户的名称
   * fn是之后操作的回调函数
   *
   * @param {Object} req
   * @param {Object} db
   * @param {String} user
   * @param {Function} fn
   * @return {Void}
   */
  login: (req, db, user, fn) => {
    sessionLogin(req, db, user).then(fn)
  },
  /**
   * 可供调用的filter中间件
   * req是httpRequest
   * db是依赖于model模块的数据库连接实例
   * next是依赖于中间件模块的next函数
   *
   * @param {Object} req
   * @param {Object} db
   * @param {String} user
   * @param {Function} next
   * @return {Void}
   */
  filter: (req, db, next) => {
    sessionFilter(req, db).then(() => {
      next()
    })
  }
}

调用session模块时,也很简单:

const app = require('./../api/lib/core')()
const Cookie = require('./../api/lib/cookie')
const session = require('./../api/lib/session')
const Model = require('./../api/model/model')
const model = new Model()
model.addModel('session', {
  sid: {type: String},
  username: {type: String}
})
const db = model.init()
app.use('/', (req, res, next) => {
  new Cookie(req, res)
  next()
})
//中间件,访问时校验session
app.use('/', (req, res, next) => {
  session.filter(req, db.session, next)
})
app.get('/', (req, res) => {
  if(req.username) {
    //session登陆成功
  } else {
    //session登陆失败
  }
})
//登陆成功时写入session
app.get('/login', (req, res) => {
  session.login(req, db.session, 'yourUsername', () => {
    res.end()
  })
})

Web Storage和IndexedDB

Web Storage

如果用cookie存储信息,除了安全问题,还有一个必须要面对的问题就是容量。

单个cookie的最大大小是4kb,一个请求允许的最大cookie数量根据浏览器不同而不同,但是如果发送过多的cookie,后端处理会变得异常艰难。

因此,当客户端要存储的数据量比较大(例如用户自定义配置信息),或者客户端的数据存储不需要告知服务端时(例如文本编辑器的保存功能),就需要使用其它方案了。

Web Storage可以让网页在客户端存储数据。它分为localStoragewebStorage。它们的区别是前者在关闭浏览器时清空,后者没有存活时间的限制。

关于Web Storage的属性和操作,推荐阅读阮一峰老师的教程

IndexedDB

当你要存储的数据量大到连Web Storage都无法满足你的时候,你就可以考虑使用IndexedDB了。

IndexedDB是客户端数据库,它能存储大量数据,查找也非常方便。使用IndexedDB可以用在两种场合,一种是需要前端存储大量数据但不需要告诉服务端时(例如游戏进度,个人配置),一种是查询数据量大,放在本地可以免除频繁请求造成的延迟和性能负担。

关于IndexedDB的属性和操作,推荐阅读阮一峰老师的教程


跑通注册登陆数据流

还记得我们的server.js文件么?我们现在要实现第一个功能:注册登陆。

建立一个view文件夹,放置我们的视图文件。在其中创建4个文件,分别命名为welcome.htmllogin.htmljoin.htmltodo.html。然后开始编辑:

welcome.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta http-equiv="Cache-Control" content="no-siteapp">
  <meta content="initial-scale=1, maximum-scale=1, width=device-width" name="viewport">
  <title>Welcome</title>
</head>
  <body>
    <h1>Welcome to Todo List App!</h1>
  </body>
</html>

login.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta http-equiv="Cache-Control" content="no-siteapp">
  <meta content="initial-scale=1, maximum-scale=1, width=device-width" name="viewport">
  <title>Welcome</title>
</head>
  <body>
    <h1>Login</h1>
    <form method="POST" action="/login">
      <label for="username">User Name</label>
      <input type="text" name="username" />
      <label for="pwd">Password</label>
      <input type="text" name="pwd" />
      <input type="submit" value="submit" />
    </form>
  </body>
</html>

join.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta http-equiv="Cache-Control" content="no-siteapp">
  <meta content="initial-scale=1, maximum-scale=1, width=device-width" name="viewport">
  <title>Join</title>
</head>
  <body>
    <h1>Join</h1>
    <form method="POST" action="/join">
      <label for="username">User Name</label>
      <input type="text" name="username" />
      <label for="pwd">Password</label>
      <input type="text" name="pwd" />
      <input type="submit" value="submit" />
    </form>
  </body>
</html>

user.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta http-equiv="Cache-Control" content="no-siteapp">
  <meta content="initial-scale=1, maximum-scale=1, width=device-width" name="viewport">
  <title>User</title>
</head>
  <body>
    <h1>User</h1>
  </body>
</html>

然后在model/modelList.js中建立数据类型列表:

const list = [
  {
    name: 'user',
    struct: {
      _id: {type: String, unique: true},
      pwd: {type: String}
    }
  },
  {
    name: 'session',
    struct: {
      _id: {type: String},
      username: {type: String}
    },
    type: 'simple'
  }
]
module.exports = list

打开api/server.js,进行编辑:

const fs = require('fs')
const querystring = require('querystring')
//引入自定义模块
const app = require('./lib/core')()
const Cookie = require('./../api/lib/cookie')
const session = require('./../api/lib/session')
const Model = require('./model/model')
const modelList = require('./model/modelList')
//服务端配置
const SERVER_CONFIG = {
  host: process.env.IP || 'localhost',
  port: process.env.PORT || 3000
}
//加载数据库
const model = new Model()
model.setConfig({
  user: 'fsn',
  pwd: '0000',
  db: 'fsn'
})
model.loadModelFromList(modelList)
const db = model.init()
//加载cookie和session中间件
app.use('/', (req, res, next) => {
  new Cookie(req, res)
  next()
})
app.use('/', (req, res, next) => {
  session.filter(req, db.session, next)
})
//页面
//访问主页时,依据是否登陆展示不同内容
app.get('/', (req, res) => {
  if(req.username) {
    res.writeHead(200, {'Content-Type': 'text/html'})
    res.end(fs.readFileSync('view/user.html'))
  } else {
    res.writeHead(200, {'Content-Type': 'text/html'})
    res.end(fs.readFileSync('view/welcome.html'))
  }
})
//访问登陆页
app.get('/login', (req, res) => {
  res.writeHead(200, {'Content-Type': 'text/html'})
  res.end(fs.readFileSync('view/login.html'))
})
//提交登陆信息
app.post('/login', (req, res) => {
  req.on('data', (chunk) => {
    const data = querystring.parse(chunk.toString())
    db.user.findOne({
      _id: data.username
    }, (doc) => {
      if(doc.pwd === data.pwd) {
        session.login(req, db.session, doc._id, () => {
          res.writeHead(301, {'Location': '/'})
          res.end()
        })
      } else {
        res.writeHead(200, {'Content-Type': 'text/html'})
        res.write('密码错误')
        res.end(fs.readFileSync('view/login.html'))
      }
    }, () => {
      res.writeHead(200, {'Content-Type': 'text/html'})
      res.write('用户不存在')
      res.end(fs.readFileSync('view/login.html'))
    })
  })
})
//访问注册页
app.get('/join', (req, res) => {
  res.writeHead(200, {'Content-Type': 'text/html'})
  res.end(fs.readFileSync('view/join.html'))
})
//提交注册信息
app.post('/join', (req, res) => {
  req.on('data', (chunk) => {
    const data = querystring.parse(chunk.toString())
    db.user.add({
      _id: data.username,
      pwd: data.pwd
    }, (doc) => {
      //TODO: 注册成功
      session.login(req, db.session, doc._id, () => {
        res.writeHead(301, {'Location': '/'})
        res.end()
      })
    }, () => {
      //TODO: 注册失败
      res.writeHead(200, {'Content-Type': 'text/html'})
      res.write('用户已存在')
      res.end(fs.readFileSync('view/join.html'))
    })
  })
})
app.listen(SERVER_CONFIG.port, SERVER_CONFIG.host) 

这样我们就跑通了注册登陆功能,运行服务器,分别检查注册、登陆、记住登陆状态等信息。