对于服务端渲染和前后端分离两种开发模式,分别有着不同的身份认证方案:

  • 服务端渲染推荐使用 Session 认证机制
  • 前后端分离推荐使用 JWT 认证机制 (使用依据:Session 认证需要配合 Cookie,而 Cookie 默认不支持跨域访问)

# 一、普通认证方式

  常规的认证方式:简单的判断登录条件然后跳转。

  • app.js
var userRouter = require('./routes/users')
var loginRouter = require('./routes/login')
var backendRouter = require('./routes/backend')

app.use('/api', userRouter) // api路由
app.use('/login', loginRouter) // 登录页面
app.use('/backend', backendRouter) // 后台页面

# 1、页面显示:

① 登录页面

  • routes / login.js(普通路由)
router.get('/', function (req, res, next) {
  res.render('login')
})
  • views / login.html
<!-- 略(登录页面、发起fetch请求:'/api/login') -->

<script>
  login.onclick = () => {
    fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({
        username: username.value,
        password: password.value
      }),
      headers: {
        'Content-Type': 'application/json'
      }
    })
      .then((res) => res.json())
      .then((res) => {
        // console.log(res)
        // 页面✨跳转
        if (res.ok === 1) {
          location.href = '/backend'
        } else {
          alert('用户名密码不匹配!')
        }
      })
  }
</script>

② 后台页面

  • routes / backend.js(普通路由)
router.get('/backend', function (req, res, next) {
  res.render('layout')
})
  • view / layout.html
……

# 2、登录跳转:

  • routes / users.js(api 路由)
// 增删改查
……

// 登录校验
router.post('/login', userController.login)
  • controller / userController.js
const UserController = {
  addUser: async (req, res) => {
    const { username, password } = req.body

    const data = await userService.login(username, password)

    // 简单✨校验
    if (data.length === 0) {
      res.send({
        ok: 0
      })
    } else {
      res.send({
        ok: 1
      })
    }
  }
}
  • services / userService.js
const UserService = {
  login: () => {
    return UserModel.find({
      username,
      password
    })
  }
}

# 3、问题

  上面我们费了老大的劲最后是为了进入 backend.html 页面,结果用户不登录直接输入后台地址,仍然可以直接 🤔 进入 backend.html 页面。

为了解决上面的问题,我们可以使用 登录鉴权 解决问题。

# 二、Session 认证机制

# 1、HTTP 的无状态性

  由于 HTTP 协议的无状态性(客户端的每次 HTTP 请求都是独立的,服务器不会主动保留 HTTP 请求的状态)。

# 2、Cookie

  为了区分不同的请求,在 Web 中有我们通常会接触到 Cookie,通过它实现的认证方式类似于超市、商场中的会员卡

① 简介

  Cookie 是存储在浏览器中的不超过 4kb 的字符串。由 内容(键值对) + 其他可选属性组成(有效期、安全性、使用范围)

router.get('/', (req, res) => {
  // 1、设置Cookie
  res.cookie('username', 'lencamo')

  // 2、读取Cookie
  console.log(req.cookies)
  res.send()
})

② 问题

  由于浏览器也提供了读写 Cookie 的 API,所以 Cookie 很容易被伪造(安全性有待考量)。

// 1、设置Cookie
document.cookie = 'username=lencamo'

// 2、读取Cookie
javascript: alert(document.cookie)
// 【当然还可以通过 开发者工具的Application、浏览器地址前的图标查看】

# 3、Session

  单独使用 Cookie 时,由于浏览器而造成一些安全问题。对应的解决方案为:会员卡 + 刷卡机认证

  相当于:客户端的 cookie(sessionid)是一把钥匙,服务端的 session 是门

重点

  • Cookie 存储在客户端
  • Session 存储在服务端的内存中(当然也可以存在数据库中)

由于 session 认证过程复杂,我们可以借助 express-session 包辅助开发。

① 下载

npm i express-session
# npm i connect-mongo

  使用 express-session 包后,相关的验证、设置 cookie 等操作将自进行,不用自己编写。浏览器会自动带有 cookie 给后端,后端会自动的对其进行校验。

② 配置

  • app.js
// 导入session中间件
const session = require('express-session')
// const MongoStore = require('connect-mongo')

// 注册session中间件
app.use(
  session({
    // 1、session基础配置(secret、resave、saveUninitialized必写)
    name:'test-session'
    secret: 'keyboard cat',
    resave: true, // 若重新设置session后🤔,会自动重新计算过期时间
    saveUninitialized: true, //是否初始一个cookie
    cookie: {
      maxAge:1000*60*60 //过期时间
      secure: fase //为true时,只有https协议才能访问cookie(浏览器)
    }
    // 2、配置存储✨session的数据库
    // store: MongoStore.create({
    //   mongourl: 'mongodb://127.0.0.1/ren_Session',
    //   ttl: 1000*60*10 //过期时间
    // })
  })
)

有 express-session 包中的配置项 secure,我们可以得知,此时是无法通过 document.cookie 的方式直接从本地获取的

# 4、示例

  下面以前面普通认证方式的案例为例进行代码升级:

① 登录时在后台设置 session

  • controller / userController.js
const UserController = {
  addUser: async (req, res) => {
    const { username, password } = req.body

    const data = await userService.login(username, password)

    // 简单✨校验
    if (data.length === 0) {
      res.send({
        ok: 0
      })
    } else {
      // 设置session🚩对象{}
      // (简单的说:就是通过为当前session对象添加一个起标识作用字段,来间接生成session对象)
      req.session.user = data[0]

      res.send({
        ok: 1
      })
    }
  }
}

② 页面显示前提

  • routes / backend.js(普通路由)
router.get('/backend', function (req, res, next) {
  // 判断是否处于登录状态
  if (req.session.user) {
    res.render('layout')
  } else {
    res.redirect('/login')
  }
})

注意:在后台设置的 session 对象默认存在内存中,一旦服务器重启,现有 session 数据会丢失,这时我们就有必要了解 connect-mongo 包。

# 5、示例升级

  在进入后台 layout 页面后,在 cookie 过期后(在不刷新的前提下),仍然能操作后台页面的接口路由。

① 问题分析:

  仅仅在 routes / backend.js 中做过期校验是行不通的。

② 解决问题:

  直接写一个总的 cookie 校验中间件。

  • app.js
app.use((req, res, next) => {
  // 1、白名单放行
  if (req.url.includes('login')) {
    next()
    return
  }
  // 2、session 过期校验
  if (req.session.user) {
    // 重置session🤔(让express-session 包的resave: true配置生效)
    req.session.date = Date.now()
    next()
  } else {
    // ① 是接口路由:对api请求返回错误码(前端使用axios拦截器处理即可)
    // ② 不是接口路由:模块渲染(重定向)
    req.url.includes('api') ? res.status(401).send({ ok: 0 }) : res.redirect('/login')

    // 【上面是由于案例的特殊性(采用两掺方式)而做出的操作🚩:
    //  - 若纯后端前提模板——重定向即可,
    //  - 若纯前后端分离——返回错误码即可】
  }
})

  若发起的是 api 请求,后端是无法操作页面跳转的(由于浏览器限制,虽然得到了重定向到 login 页面请求,但不会跳转),需要前端进行跳转操作。

  • views / layout.html
<body>
  <h2>后台系统-用户管理业务</h2>
  ……
  <button id="exit">退出登录</button>

  <!-- ✨方式一:像最前面的登录校验跳转一样,对fetch请求响应进行判断 -->
  <script>

    //所有的请求都要加if后内容🤔
    ……

    exit.onclick = () => {
      fetch('/api/logout')
        .then((res) => res.json())
        .then((res) => {
          if (res.ok === 1) {
            location.href = '/login'
          }
        })
    }
  </script>

  <!-- ✨方式二:直接使用axios拦截器 -->
  <script>
    reqAxios.interceptors.response.use(
      function (response) {
        return response
      },
      function (error) {
        if (error.response.status === 401) {
          $router.push('/login')

          // 弹窗提示
          Message.error('用户身份以过期!!')
        }
        return Promise.reject(error)
      }
    )
  </script>
</body>

③ 退出登录功能

  • routes/users.js
//销毁✨session(看不习惯,可以向前面一样进行封装)
router.post('/logout', (req, res) => {
  req.session.destroy(() => {
    res.send({ ok: 1 })
  })
})

# 6、session 存储

# 三、JWT 认证机制

图 1:

图 2:

  当然上面的 Session 认证机制也不是万能的,也有自己的缺点:

  • 占用内存/空间
  • 跨站请求伪造(CSRF 攻击)
  • ……

① 跨站请求伪造:

  登录 a 网站成功后会自动携带 cookie,在没有退出登录的情况下关闭网站,此时 cookie 仍然有效;若 b 网站此前挂载了一个携带 cookie 的 a 网站的链接,那 😢……

② 解决问题:

  既然浏览器自动携带 Cookies 存储不安全,那:

直接将信息手动存到 Application 中的 Local Storage 中

并且在要求后端对信息进行 HMAC-SHA256 加密处理。

# 1、JWT

  JWT(JSON Web Token)是目前最流行的跨域认证解决方案。由它的全称可以知道:它能将各部分的 JSON 生成 Token 字符串。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

① 简介:

  JWT 字符串通常由三部分组成:header(头部,标识,加密方式)、payload(负载,就是用户数据)、secret(密钥)

② 缺点:

  • 签名(token)可以直接在浏览器的 Application 中的 Local Storage 中直接拿到 🤔。
  • 由于信息存储在浏览器,相应的会占有更多的带宽(流量)。
  • token 无法在服务端注销,对应的遇到劫持问题时会相当棘手。

# 2、代码演示

① 下载

npm i jsonwebtoken

② 封装一个模块

  • util / JWT.js
const jwt = require('jsonwebtoken')

const secret = '<秘钥>'
const JWT = {
  // 1、生成token字符串
  generate(value, expires) {
    // 使用sign函数
    return jwt.sign(
      {
        // data: '<数据>'
        data: value
      },
      secret,
      {
        // expiresIn: '<有效期>'
        expiresIn: expires
      }
    )
  },

  // 2、解密数据
  verify(token) {
    try {
      return jwt.verify(token, secret)
    } catch (error) {
      return false
    }
  }
}

module.exports = JWT

③ 使用尝试

  • routes / demo.js
const token = JWT.generate({ name: 'lencamo' }, '10s')

// 尝试在8秒后拿取数据
setTimeout(() => {
  var decoded = JWT.verify(token)
  console.log(decoded)
}, 8000)

# 3、示例

① 登录时在后台生成、发送 token

  • controller / userController.js
const UserController = {
  addUser: async (req, res) => {
    const { username, password } = req.body

    const data = await userService.login(username, password)

    // 简单✨校验
    if (data.length === 0) {
      res.send({
        ok: 0
      })
    } else {
      // 改为:🚩设置token签名
      // console.log(data[0])  // 要确保payload应该是一个普通对象

      // 1、生成token
      const token = JWT.generate(
        {
          _id: data[0]._id,
          username: data[0].username
        },
        '2h'
      )
      // 2、发送token到客户端(header方式)
      res.header('Authorization', token)

      res.send({
        ok: 1
      })
    }
  }
}

Chrome 高阶调试:在页面跳转时,若要保留 Network、Console 面板的信息,只需要勾选 Chrome 提供 的 Preserve Log 选项即可,

② 服务端登录过期校验

  • app.js
app.use((req, res, next) => {
  // 1、白名单放行
  if (req.url.includes('login')) {
    next()
    return
  }

  // 2、token 过期校验
  // 思考?ES6语法
  // const token = req.headers['authorization'] && req.headers['authorization'].split(" ")[1]
  const token = req.headers['authorization']?.split(' ')[1]

  // 是否有token
  if (token) {
    const payload = JWT.verify(token)
    // 负载是否有效
    if (payload) {
      // 重新生成(计算)token✨过期时间
      const newToken = JWT.generate(
        {
          _id: payload._id,
          username: payload.username
        },
        '2h'
      )
      res.header('Authorization', newToken)

      next()
    } else {
      res.status(401).send({ errCode: -1, errInfo: 'token过期' })
    }
  } else {
    next()
  }
})

同理:浏览器操作页面跳转(下面直接使用 axios 拦截器演示)

③ 客户端页面跳转

  • views / login.html 或者 views / layout.html
<head>
  <script>
    axios.interceptors.request.use(
      function (config) {
        // 发送token✨(用于服务端校验)
        const token = localStorage.getItem('token')
        config.headers.Authorization = `Bearer ${token}`

        return config
      },
      function (error) {
        return Promise.reject(error)
      }
    )

    axios.interceptors.response.use(
      function (response) {
        // 客户端存储🚩token
        // console.log(response.headers)

        const { authorization } = response.headers
        authorization && localStorage.setItem('token', authorization)

        return response
      },
      function (error) {
        // token过期✨处理
        if (err.response.status === 401) {
          localStorage.removeItem('token')
          localtion.href = '/login'
        }
        return Promise.reject(error)
      }
    )
  </script>
</head>
<body>
  ……
  <script>
    login.onclick = () => {
      aixos
        .post('/api/login', {
          username: username.value,
          password: password.value
        })
        .then((res) => {
          // console.log(res) ——> config、data、headers
          if (res.data.ok === 1) {
            // 存储token(使用🍗axios统一存储即可)

            location.href = '/backend'
          } else {
            alert('用户名密码不匹配!!')
          }
        })
    }
  </script>
</body>

④ 退出功能

  • views/backend.html
<body>
  <h2>后台系统-用户管理业务</h2>
  ……
  <button id="exit">退出登录</button>

  <script>
    // 移除✨token
    exit.onclick = () => {
      localStorage.removeItem('token')
      localtion.href = '/login'
    }
  </script>
</body>

# 4、拓展

  不使用 jsonwebtoken 包的 verify()方法进行 token 过期验证,使用 express-jwt 方式:

  • 下载
npm i express-jwt@5.3.3  #(注意:最新版本有部分改变,建议安装当前指定版本)
  • app.js
const expressJWT = require('express-jwt')
const jwtConfig = require('./config/jwt.config')

// 1、注册解析token的中间件(token过期校验、白名单放行)
app.use(expressJWT({ secret: jwtConfig.jwtSecretKey }).unless({ path: [/^\/api\//] }))

// ……

// 错误级别中间件
app.use(function (err, req, res, next) {
  // 2、express-jwt:捕获身份认证失败的错误
  if (err.name === 'UnauthorizedError')
    return res.send({
      status: 401,
      message: '无效的token!'
    })

  // 其他未知错误
  res.send({
    status: 500,
    message: '未知错误!'
  })
})
更新于 : 8/7/2024, 2:16:31 PM