手写nodejs服务端中cookie+session登录

184 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第24天,点击查看活动详情

什么是cookie?

  • 存储在浏览器中的一段字符串,最大为4kb;
  • 跨域不共享;
  • 格式如k1=v1;k2=v2;因此可以存储结构化数据;
  • 每次发送http请求,会将请求域的cookie发送给server端,比如在淘宝的页面请求百度的文件,那么就会发送百度的cookie,而不是淘宝的cookie;
  • server端可以修改cookie并返回给浏览器;
  • 浏览器中也可以通过js来修改cookie,但是是有限制的;

image.png

如果在淘宝页面访问百度,只会把百度的cookie带过去,不会把淘宝的cookie带过去。跨站请求伪造(CSRF) 的原理就是当你登录了一个目标网站,然后在浏览器中保留了目标网站的cookie信息,然后黑客诱惑你访问了钓鱼网站,然后在钓鱼网站向目标网站发送请求,这个请求就会带上目标网站的cookie信息,如果目标网站的服务端没有验证访问来源,那么这个请求就是伪造的了。

cookie如何做登录验证呢?

登录

客户端发送登录请求,服务端验证密码和用户名,如果都正确服务端就会设置cookie,key-valueusername=zhangsan

const getCookieExpires = () => {
  const d = new Date()
  d.setTime(d.getTime() + (24 * 60 * 60 * 1000))
  return d.toGMTString()
}
res.setHeader('Set-Cookie', `username=zhangsan; path=/; httpOnly; expires=${getCookieExpires()}`)

浏览器获得响应头之后就会在浏览器中存储cookie信息,下次请求的时候就会带上存储的cookie,服务端获得请求头就开始解析cookie信息:

req.cookie = {}
const cookieStr = req.headers.cookie || ''
cookieStr.split(';').forEach(item => {
  if (!item) {
    return
  }
  const arr = item.split('=')
  req.cookie[arr[0]] = arr[1]
})
console.log('req.headers', req.cookie)

解析完之后,就进行登录验证,验证很简单,就是看cookie中是否有username,如果有说明就已经登录过。这就是cookie是如何完成登录的过程。

什么是session?

上面cookie中有个致命的问题:暴露username等关键信息,很危险。解决方法是cookie中存储userid,服务端存储对应的username,那么服务端存储的username等信息就是session

验证用户是否登录,就是拿着这个userid到session中去找对应的信息,如果找到了那么说明登录成功,如果没有那么就要去登录。

下面不适用第三方库,手写实现一个session功能:

  • 在全局维护一个存储session的对象SESSION_DATA,这样在整个进程中都能访问;
  • 如果请求头没有带userid,说明是一次访问,就让用户去登录;
  • 如果请求头带有userid,那么就去SESSION_DATA获取对应的用户信息,如果没有获取到让用户去登录;
  • 登录成功后把这个用户的信息写入到session对象SESSION_DATA中;
// session数据 在全局维护一个session_data
const SESSION_DATA = {}

// 当用户发送请求的时候,解析session
// 解析session
let needSetCookie = false
let userId = req.cookie.userId
if (userId) {
  if (!SESSION_DATA[userId]) {
    SESSION_DATA[userId] = {}
  }
} else {
  userId = `${Date.now()}_${Math.random()}`
  SESSION_DATA[userId] = {}
  // 如果没有userid,说明是第一次登录,那么就去设置cookie
  needSetCookie = true
}
req.session = SESSION_DATA[userId]

// 在登录接口,往session_data里面写数据
// 登录
if (method === 'POST' && req.path === '/api/user/login') {
  const { username, password } = req.body
  const result = login(username, password)
  return result.then(data => {
    if (data.username) {
      // 设置session数据
      req.session.username = data.username
      req.session.realname = data.realname

      return new SuccessModel('登录成功')
    }
    return new ErrorModel('登录失败')
  })
}

image.png

从图中可以知道,session就是一个存储username等信息的变量

但是会有两个问题:

  • 既然是变量,就是在内存中的,但是对每个进程分配的内存大小是有限制的,当信息很多时,就会存不下

image.png

操作系统会为每个进程分配一个内存空间,如上图比如说是从0x1000开始到0x8000结束,上面是栈内存,下面是堆内存,我们的session就放在堆内存中Heap,如果当用户很多的时候那么Heap就会越来越多,把整个内存都占满了,那么整个进程就崩溃了。

  • 在线上是多进程部署的,进程和进程之间是无法访问的,所以B进程是无法访问到A进程里面的session的。

image.png

现在的计算机都是多核的,为了充分利用计算机的资源,一个应用往往会启用多个进程。如果每个进程都有session的话,进程之间的session是不能共享的。当你第一次进来,命中了第一个进程中的session,但是第二次进来命中了第二个进程中的session,但是这个session没有你的信息,就登录不了了。这是由于后面因为负载均衡导致的,它看哪个进程比较闲就分配哪个进程。

解决方案: redis

redis特点

  • web server 最常用的缓存数据库,数据放在内存中;
  • 相比于mysql访问速度快(内存和硬盘不是一个数量级);
  • 但是成本更高,可存储的数据量更小(内存的硬伤);

现在web服务常见模型如图所示:

image.png

  • 将web server、mysql、redis 拆分为两个单独的服务
  • 双方都是独立的,都是可扩展的(都可以扩展为集群)

这个时候web server和redis就是两个服务了,session再多,也不关web server的事情了。就好比MySQL再大和web server 也没撒关系了,两者只不过是连接起来的。

现在web server 不管开几个进程,都是访问redis的数据,都是同一份数据。所以第二个问题也解决了。

session为何适用redis?

session 访问频繁,对性能要求极高

session访问频繁,因为我们在每个请求的时候都要验证是否登录,是一个访问的前置操作,所以就需要访问非常快,如果session都访问很慢后面的操作也就变慢,从而导致请求时间较长,因此对性能要求极高。

session 可不考虑断电数据丢失的问题(内存的硬伤)

session 如果断电数据丢失了,你再登录一次(再登录一次就再在session里面写一次,把用户相关的信息写进去,用户下次访问的时候会先去redis中获取数据,拿到数据后,再进行接下来的业务逻辑)就可以了,登录后请求其他接口就可以通过userid到session里面查,如果有就说明已经登录过了。

session 数据量不会太大(相比于mysql中存储的数据)

利用redis实现session

1. 安装redis并封装接口

const redis = require('redis')
const { REDIS_CONF } = require('../conf/db')

// 创建客户端
const redisClient = redis.createClient(REDIS_CONF.port, REDIS_CONF.host)
redisClient.on('error', err => {
  console.error(err)
})

function set(key, val) {
  if (typeof val === 'object') {
    val = JSON.stringify(val)
  }
  redisClient.set(key, val, redis.print)
}

function get(key) {
  return new Promise((resolve, reject) => {
    redisClient.get(key, (err, val) => {
      if (err) {
        reject(err)
        return
      }
      if (val == null) {
        resolve(null)
        return
      }
      try {
        resolve(JSON.parse(val))
      } catch (ex) {
        // 说明val不是一个对象,那么就直接返回
        resolve(ex)
      }
    })
  })
}
module.exports = { set, get}

2. app.js

let needSetCookie = false
let userId = req.cookie.userId
  // 如果cookie中没有userid,说明还没有set-cookie
  if (!userId) {
    // 接口里面要设置cookie
    needSetCookie = true
    // 放在cookie中的userid就是一个随机数字
    userId = `${Date.now()}_${Math.random()}`
    // 初始化redis中session
    set(userId, {})
  }
  // 在redis中获取session,如果为空,初始化一个{}
  req.sessionId = userId
  get(req.sessionId).then(sessionData => {
    if (sessionData == null) {
      // 初始化redis中session
      set(req.sessionId, {})
      // 设置session
      req.session = {}
    } else {
      // 设置session
      req.session = sessionData
    }
})

3. 登录成功往redis中写数据

const handleUserRouter = (req, res) => {
  const method = req.method
  // 登录
  if (method === 'POST' && req.path === '/api/user/login') {
    const { username, password } = req.body
    const result = login(username, password)
    return result.then(data => {
      if (data.username) {
        // 设置session
        req.session.username = data.username
        req.session.realname = data.realname
        // 同步到redis中
        set(req.sessionId, req.session)
        return new SuccessModel()
      } else {
        return new ErrorModel('登录失败')
      }
    })
  }
}

4. 校验sessionid

const loginCheck = req => {
  if (!req.session.username) {
    return Promise.resolve(new ErrorModel('尚未登录'))
  }
}