在一个 Vue SSR 应用中,如何处理 `Cookie`、`Session` 和用户身份验证,并确保服务器端和客户端的状态一致性?

各位观众老爷,大家好!今天咱们聊聊 Vue SSR 应用中那些磨人的小妖精:Cookie、Session 和用户身份验证。别怕,听我慢慢道来,保证让你从一脸懵圈到自信满满。

开场白:SSR 里的状态管理,可不是闹着玩的!

SSR (Server-Side Rendering) 听起来很美好,但实际操作起来,状态管理绝对是个头疼的问题。在传统的 SPA (Single-Page Application) 里,状态都在浏览器里,爱咋折腾咋折腾。但 SSR 就不一样了,服务器要先渲染页面,然后客户端再接管。如果服务器和客户端的状态不一致,那画面简直太美不敢看。

想象一下,用户在服务器端已经登录了,结果客户端一接管,又变成未登录状态了,这用户不得骂娘?所以,保持服务器和客户端的状态一致性,是 SSR 应用的重中之重。

第一幕:Cookie 的那些事儿

Cookie,这玩意儿大家都不陌生,它就像浏览器的小便签,用来存储一些小数据。在 SSR 里,Cookie 的处理稍微复杂一点,因为服务器端和客户端都可以设置和读取 Cookie。

  • 服务器端设置 Cookie:

    在 Vue SSR 应用中,我们通常使用 express 或者 koa 作为服务器。以 express 为例,我们可以这样设置 Cookie:

    const express = require('express');
    const app = express();
    
    app.get('/set-cookie', (req, res) => {
      res.cookie('username', 'zhangsan', { maxAge: 900000, httpOnly: true }); // 设置一个名为 username 的 Cookie
      res.send('Cookie 已设置');
    });
    
    app.listen(3000, () => {
      console.log('Server is running on port 3000');
    });

    这段代码的意思是,当访问 /set-cookie 路由时,服务器会设置一个名为 username,值为 zhangsan 的 Cookie。maxAge 表示 Cookie 的有效期,单位是毫秒。httpOnly 表示该 Cookie 只能通过 HTTP 协议访问,不能被 JavaScript 脚本访问,这样可以提高安全性。

  • 客户端读取 Cookie:

    在客户端,我们可以使用 js-cookie 这样的库来读取 Cookie:

    import Cookies from 'js-cookie';
    
    const username = Cookies.get('username'); // 获取名为 username 的 Cookie
    
    if (username) {
      console.log('Username:', username);
    } else {
      console.log('Cookie username 不存在');
    }

    这段代码很简单,就是使用 Cookies.get() 方法来获取指定名称的 Cookie。

  • SSR 中的 Cookie 同步:

    重点来了,如何在 SSR 中同步 Cookie 呢?关键在于在服务器端渲染之前,将客户端的 Cookie 传递给服务器,并在服务器端设置相应的 Cookie。

    1. 客户端发送 Cookie:

      在客户端,我们需要在每次请求时,将 Cookie 通过 HTTP Header 发送给服务器。可以使用 axios 这样的 HTTP 客户端库:

      import axios from 'axios';
      import Cookies from 'js-cookie';
      
      const instance = axios.create({
        baseURL: '/', // 你的 API 地址
        withCredentials: true, // 允许携带 Cookie
        headers: {
          'X-CSRF-TOKEN': Cookies.get('XSRF-TOKEN') // 携带CSRF token
        }
      });
      
      instance.get('/api/data')
        .then(response => {
          console.log(response.data);
        });

      withCredentials: true 这个配置非常重要,它告诉浏览器允许携带 Cookie。

    2. 服务器端接收 Cookie:

      在服务器端,我们需要使用 cookie-parser 中间件来解析 Cookie:

      const express = require('express');
      const cookieParser = require('cookie-parser');
      const app = express();
      
      app.use(cookieParser()); // 使用 cookie-parser 中间件
      
      app.get('/api/data', (req, res) => {
        const username = req.cookies.username; // 从请求中获取 Cookie
        if (username) {
          res.send(`Hello, ${username}!`);
        } else {
          res.send('Hello, Guest!');
        }
      });
      
      app.listen(3000, () => {
        console.log('Server is running on port 3000');
      });

      req.cookies 对象包含了客户端发送过来的所有 Cookie。

    3. 传递Cookie给Vue实例:

      entry-server.js中,我们需要读取请求中的cookie,然后传递给Vue实例。

      // entry-server.js
      import { createApp } from './app'
      
      export default context => {
        return new Promise((resolve, reject) => {
          const { app, router, store } = createApp()
      
          // 设置服务器端 router 的位置
          router.push(context.url)
      
          router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
      
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!matchedComponents.length) {
              return reject({ code: 404 })
            }
      
            // 对所有匹配的路由组件调用 `asyncData()`
            Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
              store,
              route: router.currentRoute,
              cookies: context.req.headers.cookie // 传递cookie
            }))).then(() => {
              // 在所有预取钩子(preFetch hook) resolve 后,
              // 我们的 store 现在已经填充入渲染应用程序所需的状态。
              // 当我们将状态附加到上下文,
              // 并且 `template` 选项用于 renderer 时,
              // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
              context.state = store.state
      
              resolve(app)
            }).catch(reject)
          }, reject)
        })
      }

      在Vue组件中,我们就可以通过this.$ssrContext.cookies访问到cookie了。

      <template>
        <div>
          Welcome, {{ username }}!
        </div>
      </template>
      
      <script>
      export default {
        computed: {
          username() {
            if (this.$ssrContext && this.$ssrContext.cookies) {
              const cookies = this.$ssrContext.cookies.split(';');
              for (let i = 0; i < cookies.length; i++) {
                const cookie = cookies[i].trim();
                // Does this cookie string begin with the name we want?
                if (cookie.startsWith('username=')) {
                  return cookie.substring('username='.length, cookie.length);
                }
              }
            }
            return 'Guest';
          }
        }
      }
      </script>

      注意: 这种方式需要手动解析cookie字符串。更优雅的方式是使用cookie-parser中间件在服务器端解析cookie,然后将解析后的cookie对象传递给Vue实例。

第二幕:Session 的管理艺术

Session 比 Cookie 更高级一点,它把用户的数据存储在服务器端,然后在客户端只存储一个 Session ID。这样可以提高安全性,因为用户的数据不会暴露在客户端。

  • 服务器端创建 Session:

    使用 express-session 中间件可以很方便地管理 Session:

    const express = require('express');
    const session = require('express-session');
    const app = express();
    
    app.use(session({
      secret: 'your secret key', // 用于加密 Session ID 的密钥
      resave: false,
      saveUninitialized: true,
      cookie: { secure: false } // 在 HTTPS 环境下设置为 true
    }));
    
    app.get('/login', (req, res) => {
      req.session.username = 'zhangsan'; // 将 username 存储到 Session 中
      res.send('登录成功');
    });
    
    app.get('/get-session', (req, res) => {
      const username = req.session.username; // 从 Session 中获取 username
      if (username) {
        res.send(`Hello, ${username}!`);
      } else {
        res.send('未登录');
      }
    });
    
    app.listen(3000, () => {
      console.log('Server is running on port 3000');
    });

    secret 是一个非常重要的配置,它用于加密 Session ID。一定要设置一个足够复杂的 secret,否则 Session 很容易被破解。resavesaveUninitialized 是一些高级配置,可以参考 express-session 的文档。cookie.secure 用于指定 Session Cookie 是否只能在 HTTPS 环境下传输。

  • 客户端访问 Session:

    客户端不需要直接访问 Session 的内容,只需要在每次请求时,将 Session ID 通过 Cookie 发送给服务器。express-session 会自动处理 Session ID 的设置和读取。

  • SSR 中的 Session 同步:

    SSR 中 Session 的同步和 Cookie 的同步类似,都需要在服务器端渲染之前,将客户端的 Cookie 传递给服务器。

    1. 客户端发送 Cookie:

      和 Cookie 的处理方式一样,使用 axios 设置 withCredentials: true

    2. 服务器端接收 Cookie:

      express-session 会自动从请求中读取 Session ID,并加载对应的 Session 数据。

    3. 传递Session给Vue实例:

      与Cookie类似,在entry-server.js中,我们需要读取请求中的cookie,然后传递给Vue实例。但是,我们不应该直接将整个session对象传递给客户端,这存在安全风险。更好的做法是,只传递Vue组件需要的部分session数据。

      // entry-server.js
      import { createApp } from './app'
      
      export default context => {
        return new Promise((resolve, reject) => {
          const { app, router, store } = createApp()
      
          // 设置服务器端 router 的位置
          router.push(context.url)
      
          router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
      
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!matchedComponents.length) {
              return reject({ code: 404 })
            }
      
            // 对所有匹配的路由组件调用 `asyncData()`
            Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
              store,
              route: router.currentRoute,
              session: context.req.session // 传递session
            }))).then(() => {
              // 在所有预取钩子(preFetch hook) resolve 后,
              // 我们的 store 现在已经填充入渲染应用程序所需的状态。
              // 当我们将状态附加到上下文,
              // 并且 `template` 选项用于 renderer 时,
              // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
              context.state = store.state
      
              resolve(app)
            }).catch(reject)
          }, reject)
        })
      }

      在Vue组件中,我们就可以通过this.$ssrContext.session访问到session了。

      <template>
        <div>
          Welcome, {{ username }}!
        </div>
      </template>
      
      <script>
      export default {
        computed: {
          username() {
            return this.$ssrContext && this.$ssrContext.session ? this.$ssrContext.session.username : 'Guest';
          }
        }
      }
      </script>

第三幕:用户身份验证的正确姿势

用户身份验证是 Web 应用的基石。在 SSR 应用中,我们需要确保用户在服务器端和客户端都处于正确的登录状态。

  • 传统的身份验证流程:

    1. 用户提交用户名和密码。
    2. 服务器验证用户名和密码。
    3. 如果验证成功,服务器创建一个 Session,并将用户的信息存储到 Session 中。
    4. 服务器设置一个 Session ID 的 Cookie。
    5. 客户端在后续的请求中,携带 Session ID 的 Cookie。
    6. 服务器根据 Session ID 查找对应的 Session 数据,从而判断用户的登录状态。
  • JWT (JSON Web Token) 的应用:

    JWT 是一种轻量级的身份验证方案。它使用 JSON 对象来表示用户信息,并使用数字签名来保证信息的完整性。

    1. 服务器端生成 JWT:

      const jwt = require('jsonwebtoken');
      
      app.post('/login', (req, res) => {
        const { username, password } = req.body;
      
        // 验证用户名和密码
        if (username === 'zhangsan' && password === '123456') {
          // 生成 JWT
          const token = jwt.sign({ username: username }, 'your secret key', { expiresIn: '1h' });
      
          // 将 JWT 存储到 Cookie 中
          res.cookie('token', token, { httpOnly: true });
          res.send('登录成功');
        } else {
          res.status(401).send('用户名或密码错误');
        }
      });

      jwt.sign() 方法用于生成 JWT。第一个参数是包含用户信息的 JSON 对象,第二个参数是用于签名的密钥,第三个参数是一些配置选项,例如 expiresIn 用于指定 JWT 的有效期。

    2. 客户端存储 JWT:

      客户端可以将 JWT 存储到 Cookie 或者 Local Storage 中。推荐使用 Cookie,并设置 httpOnly 属性,这样可以提高安全性。

    3. 服务器端验证 JWT:

      app.get('/api/data', (req, res) => {
        const token = req.cookies.token;
      
        if (!token) {
          return res.status(401).send('未登录');
        }
      
        jwt.verify(token, 'your secret key', (err, decoded) => {
          if (err) {
            return res.status(401).send('Token 无效');
          }
      
          const username = decoded.username;
          res.send(`Hello, ${username}!`);
        });
      });

      jwt.verify() 方法用于验证 JWT 的有效性。如果 JWT 有效,decoded 对象会包含 JWT 中存储的用户信息。

    4. SSR 中 JWT 的同步:

      SSR 中 JWT 的同步和 Cookie 的同步类似,都需要在服务器端渲染之前,将客户端的 Cookie 传递给服务器。

      (1) 客户端发送 Cookie:

      和 Cookie 的处理方式一样,使用 axios 设置 withCredentials: true

      (2) 服务器端接收 Cookie:

      服务器端从请求中读取 JWT,并验证其有效性。

      (3) 传递用户信息给Vue实例:

      // entry-server.js
      import { createApp } from './app'
      import jwt from 'jsonwebtoken'
      
      export default context => {
        return new Promise((resolve, reject) => {
          const { app, router, store } = createApp()
      
          // 设置服务器端 router 的位置
          router.push(context.url)
      
          router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
      
            // 匹配不到的路由,执行 reject 函数,并返回 404
            if (!matchedComponents.length) {
              return reject({ code: 404 })
            }
      
            let userInfo = null;
            if (context.req.headers.cookie) {
              const token = context.req.headers.cookie.split(';').find(cookie => cookie.trim().startsWith('token=')).split('=')[1];
              try {
                const decoded = jwt.verify(token, 'your secret key');
                userInfo = decoded;
              } catch (err) {
                // Token 无效,可以进行一些处理,例如清除客户端的 Cookie
                console.error('Token 无效:', err);
              }
            }
      
            Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
              store,
              route: router.currentRoute,
              userInfo: userInfo // 传递用户信息
            }))).then(() => {
              // 在所有预取钩子(preFetch hook) resolve 后,
              // 我们的 store 现在已经填充入渲染应用程序所需的状态。
              // 当我们将状态附加到上下文,
              // 并且 `template` 选项用于 renderer 时,
              // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
              context.state = store.state
      
              resolve(app)
            }).catch(reject)
          }, reject)
        })
      }
      <template>
        <div>
          Welcome, {{ username }}!
        </div>
      </template>
      
      <script>
      export default {
        computed: {
          username() {
            return this.$ssrContext && this.$ssrContext.userInfo ? this.$ssrContext.userInfo.username : 'Guest';
          }
        }
      }
      </script>

第四幕:状态持久化与同步

仅仅传递数据是不够的,我们需要确保服务器渲染的状态能够同步到客户端,避免出现闪烁或者数据不一致的情况。Vue SSR 官方推荐使用 window.__INITIAL_STATE__ 来进行状态持久化。

  • 服务器端设置 __INITIAL_STATE__:

    entry-server.js 中,将 Vuex store 的状态序列化为 JSON,并赋值给 context.state

    // entry-server.js
    export default context => {
      // ...
      context.state = store.state; // 重要:将 store 的状态赋值给 context.state
      // ...
    }
  • 客户端读取 __INITIAL_STATE__:

    在客户端,我们需要在 Vue 应用创建之前,从 window.__INITIAL_STATE__ 中读取状态,并将其替换到 Vuex store 中:

    // entry-client.js
    import { createApp } from './app'
    
    const { app, router, store } = createApp()
    
    if (window.__INITIAL_STATE__) {
      store.replaceState(window.__INITIAL_STATE__) // 重要:替换 store 的状态
    }
    
    router.onReady(() => {
      app.$mount('#app')
    })

    这样,客户端就可以使用服务器端渲染的状态了。

总结:SSR 状态管理的葵花宝典

技术点 描述 注意事项
Cookie 小型的文本文件,用于存储少量数据。 注意设置 httpOnly 属性,提高安全性。
Session 将用户数据存储在服务器端,客户端只存储 Session ID。 使用 express-session 管理 Session,设置复杂的 secret。 不要将敏感数据存储在 Session 中。
JWT 使用 JSON 对象来表示用户信息,并使用数字签名来保证信息的完整性。 使用安全的密钥,并设置 JWT 的有效期。
状态持久化 使用 window.__INITIAL_STATE__ 将服务器端渲染的状态同步到客户端。 确保序列化和反序列化的过程正确无误。
CSRF 防护 在使用 Cookie 认证时,需要进行 CSRF (Cross-Site Request Forgery) 防护。 客户端和服务器端都需要进行相应的配置。

彩蛋:一些实用的小技巧

  • 使用 HTTPS: 在生产环境中,一定要使用 HTTPS,这样可以保证数据的安全性。
  • 设置 Cookie 的 Domain 和 Path: 合理设置 Cookie 的 Domain 和 Path 可以限制 Cookie 的作用范围,提高安全性。
  • 定期清理 Session: 定期清理过期的 Session 可以释放服务器资源。
  • 监控和日志: 监控和日志可以帮助你及时发现和解决问题。

好了,今天的讲座就到这里。希望大家能够掌握 Vue SSR 中 Cookie、Session 和用户身份验证的处理技巧,写出更加健壮和安全的 Web 应用。 感谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注