MERN-技术栈高级教程-九-

129 阅读36分钟

MERN 技术栈高级教程(九)

原文:Pro MERN Stack

协议:CC BY-NC-SA 4.0

十四、认证

大多数应用需要识别和验证用户。我们将集成一个社交登录,而不是创建一个自定义的注册和验证机制。我们将只实现一个(谷歌网站登录)。这将为其他集成提供一个很好的例子,因为它使用了 OAuth2 机制,大多数其他认证集成也使用这种机制。

我们将使用户无需登录即可查看所有信息,但为了进行任何更改,他们必须登录。我们将使用一个模态对话框,让用户从应用的任何地方登录 Google。一旦登录,应用将让用户停留在相同的页面,以便他们可以在登录后执行编辑功能。

在所有这些中,我们不会忽略服务器渲染。我们将确保整个页面可以在 UI 服务器上呈现,即使它们是经过身份验证的页面。

登录用户界面

让我们首先为登录用户构建必要的用户界面。虽然我们不会在这一节中做任何认证,但是我们将确保 UI 方面的所有基础工作都准备就绪,以便在后面的部分中添加它。

在导航栏的右侧,有一个标签为“登录”的项目。单击这个按钮,让我们显示一个模态对话框,让用户使用一个标记为“登录”的按钮进行登录。对于问题跟踪应用,我们只有一个登录按钮,但这种方法允许你添加多个登录选项,如脸书、GitHub 等。成功登录后,让我们显示用户名而不是登录菜单项,并显示一个下拉菜单让用户退出。

为了实现这一切,让我们创建一个名为SignInNavItem的类似于IssueAddNavItem的新组件,它可以放在导航栏中。该组件的完整代码如清单 14-1 所示,我将在这里讨论几个重要的片段。

状态变量和一些显示模态的方法与组件IssueAddNavItem非常相似:showModalhideModal方法使用一个名为showing的变量来控制模态的可见状态。此外,让我们用一个名为user的状态变量对象来保存登录状态(signedIn)和用户名(givenName)。如果状态变量表明用户已经登录,那么render()方法只返回一个下拉菜单和一个菜单项来注销用户。

...
    if (user.signedIn) {
      return (
        <NavDropdown title={user.givenName} id="user">
          <MenuItem onClick={this.signOut}>Sign out</MenuItem>
        </NavDropdown>
      );
...

如果用户没有登录,render()方法返回用于登录的菜单项以及显示登录按钮的模态对话框。

...
        <NavItem onClick={this.showModal}>
          Sign in
        </NavItem>
        <Modal keyboard show={showing} onHide={this.hideModal} bsSize="sm">
          ...
          <Modal.Body>
            <Button block bsStyle="primary" onClick={this.signIn}>
              Sign In
            </Button>
          </Modal.Body>
          ...
        </Modal>
...

单击模式中的 Sign In 按钮,我们要做的就是将用户名设置为“User1”,将登录状态设置为true。注销时,我们将撤销此操作。对于这些,我们在组件中有处理程序signInsignOut。最后,我们需要添加一个bind(this)。清单 14-1 显示了这个组件的最终源代码。

import React from 'react';
import {
  NavItem, Modal, Button, NavDropdown, MenuItem,
} from 'react-bootstrap';

export default class SigninNavItem extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      showing: false,
      user: { signedIn: false, givenName: '' },
    };
    this.showModal = this.showModal.bind(this);
    this.hideModal = this.hideModal.bind(this);
    this.signOut = this.signOut.bind(this);
    this.signIn = this.signIn.bind(this);
  }

  signIn() {
    this.hideModal();
    this.setState({ user: { signedIn: true, givenName: 'User1' } });
  }

  signOut() {
    this.setState({ user: { signedIn: false, givenName: '' } });
  }

  showModal() {
    this.setState({ showing: true });

  }

  hideModal() {
    this.setState({ showing: false });
  }

  render() {
    const { user } = this.state;
    if (user.signedIn) {
      return (
        <NavDropdown title={user.givenName} id="user">
          <MenuItem onClick={this.signOut}>Sign out</MenuItem>
        </NavDropdown>
      );
    }

    const { showing } = this.state;
    return (
      <>
        <NavItem onClick={this.showModal}>
          Sign in
        </NavItem>
        <Modal keyboard show={showing} onHide={this.hideModal} bsSize="sm">
          <Modal.Header closeButton>
            <Modal.Title>Sign in</Modal.Title>
          </Modal.Header>
          <Modal.Body>
            <Button block bsStyle="primary" onClick={this.signIn}>
              Sign In
            </Button>
          </Modal.Body>
          <Modal.Footer>
            <Button bsStyle="link" onClick={this.hideModal}>Cancel</Button>

          </Modal.Footer>
        </Modal>
      </>
    );
  }
}

Listing 14-1ui/src/SignInNavItem.jsx: New Component for Signing In

让我们更改导航栏,在IssueAddNavItem组件之后插入这个新的导航项目。清单 14-2 中显示了对Page.jsx的更改。

...

import SignInNavItem from './SignInNavItem.jsx';

...
      <Nav pullRight>
        <IssueAddNavItem />
        <SignInNavItem />
        ...
      </Nav>
...

Listing  14-2ui/src/Page.jsx: Inclusion of the Sign In Menu in the Navigation Bar

有了这些改变,你会发现点击登录会显示一个带有单个按钮的模态对话框,如图 14-1 中的截图所示。单击该按钮会用标题为“用户 1”的下拉菜单替换菜单项。在单击“注销”时,UI 应该返回到初始状态,并显示菜单项“登录”。

img/426054_2_En_14_Chapter/426054_2_En_14_Fig1_HTML.jpg

图 14-1

登录模式对话框

Google 登录

现在我们已经准备好了大部分的 UI,让我们用一个使用 Google 登录的按钮来代替登录按钮。登录后,我们将使用 Google 检索并显示用户名,而不是硬编码的“User1”。

https://developers.google.com/identity/sign-in/web/sign-in 的“指南”部分列出了与 Google Sign-In 集成的各种选项。作为准备措施,我们需要一个控制台项目和一个客户机 ID 来标识应用。按照指南中的说明创建您自己的项目和客户 ID。至于源 URI,使用http://localhost:8000,这是到目前为止问题跟踪器应用所在的位置。完成后,将客户端 ID 保存在 UI 服务器目录下的.env文件中;这将需要在初始化谷歌图书馆的用户界面代码。清单 14-3 中显示了sample.env中的一个示例条目。你必须使用你自己的客户 ID 来代替YOUR_CLIENT_ID

注意

指南中的按钮会在 API 控制台中自动创建一个名为 My Project 的项目。如果您想要更好地控制将要使用的名称,可以在 API 控制台的 https://console.cloud.google.com/apis/credentials 创建一个 OAuth2 客户端 ID。

...
# ENABLE_HMR=true

GOOGLE_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com

...

Listing 14-3ui/src/sample.env: Configuration for the Google Client ID

在指南中列出的推荐集成方法中,库本身呈现按钮并处理其启用和登录状态。不幸的是,这并不能很好地与 React 一起工作,因为 Google 库需要一个按钮的句柄,并且需要它是永久的。如果您试图在 React-Bootstrap 模式中使用该按钮,Google library 会抛出错误。这是因为,在关闭模式时,按钮被销毁,当模式再次打开时,按钮被重新创建。图书馆显然不喜欢这样。因此,我们必须按照名为“自定义登录按钮”的指南来显示按钮。

让我们从包括谷歌图书馆开始。我们将在template.js中做这件事,以及 UI 需要的所有其他脚本。我们将使用的库是允许我们定制登录按钮的库,它在“构建定制图形”下的代码清单中指定。清单 14-4 中显示了对此的更改。

...
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <script src="https://apis.google.com/js/api:client.js"></script>

  <style>
...

Listing 14-4ui/server/template.js: Changes for Including Google Library

初始化库时,我们需要使用 Google 客户端 ID。为了能够在 UI 代码中访问它,我们需要像传递配置变量UI_API_ENDPOINT一样传递它,使用对/env.js的请求。让我们称这个新的配置变量为GOOGLE_CLIENT_ID。清单 14-5 显示了对 UI 服务器的更改,允许 UI 代码访问这个新变量。

...
app.get('/env.js', (req, res) => {
  const env = {
    UI_API_ENDPOINT: process.env.UI_API_ENDPOINT,
    GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
  };
  res.send(`window.ENV = ${JSON.stringify(env)}`);
});
...

Listing 14-5ui/server/uiserver.js: Changes to Send Google Client ID to the UI

现在我们已经准备好使用这个库,并在SignInNavItem内实现 Google 登录。这个组件的完整变更可以在清单 14-6 中找到,我将在下面讨论其中的一些片段。

让我们从初始化库开始。这可以在组件SignInNavItemcomponentDidMount内完成。这个组件将只被调用一次,因为它在标题中并且总是可见的。这方面的代码摘自本指南的“构建自定义图形”一节。我们将在成功初始化库时设置一个状态变量disabled(初始化为true)。只有在成功初始化之后,我们才会使用这个状态来启用登录按钮。

...
  componentDidMount() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    if (!clientId) return;
    window.gapi.load('auth2', () => {
      if (!window.gapi.auth2.getAuthInstance()) {
        window.gapi.auth2.init({ client_id: clientId }).then(() => {
          this.setState({ disabled: false });
        });
      }
    });
  }
...

在模态对话框中,让我们用一个遵循 Google 品牌指南的按钮来替换纯文本按钮(这在集成指南中有描述)。这对于生产中的任何应用都是必须的。不过,为了测试,你可以只使用纯文本按钮。我把一个公开图片的 URL(https://developers.google.com/identiimg/btn_google_signin_light_normal_web.png)缩短为 https://goo.gl/4yjp6B 并使用了它

...
              <img src="https://goo.gl/4yjp6B" alt="Sign In" />
...

如果缺少客户机 ID(如果部署的.env或环境没有这个变量,这是可能的),让我们在单击 Sign In 菜单项时显示一条错误消息。否则,让我们继续显示模态对话框。为了能够使用 Toast 消息显示错误,我们需要在导出组件之前使用withToast来包装它,我们将很快添加这一功能。

...
  showModal() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    const { showError } = this.props;
    if (!clientId) {
      showError('Missing environment variable GOOGLE_CLIENT_ID');
      return;
    }
    this.setState({ showing: true });
  }
...

最后,在signIn处理程序中,让我们调用auth2.signin()方法。本指南中没有描述这种方法,但是您可以在“参考”一节中找到描述。成功登录后,我们将从登录结果获得的配置文件中设置用户名。另外,由于内部的await调用,signIn处理程序现在需要成为一个async函数。

...
      const auth2 = window.gapi.auth2.getAuthInstance();
      const googleUser = await auth2.signIn();
      const givenName = googleUser.getBasicProfile().getGivenName();
      this.setState({ user: { signedIn: true, givenName } });
...

清单 14-6 显示了完整的变更集,包括错误处理和与 Toast 相关的变更。

...

import withToast from './withToast.jsx';

export default class SigninNavItem extends React.Component {
  constructor(props) {
    ...
    this.state = {
      showing: false,
      disabled: true,
      ...
    };
    ...
  }
...

  componentDidMount() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    if (!clientId) return;
    window.gapi.load('auth2', () => {
      if (!window.gapi.auth2.getAuthInstance()) {
        window.gapi.auth2.init({ client_id: clientId }).then(() => {
          this.setState({ disabled: false });
        });
      }
    });
  }

  async signIn() {
    this.hideModal();
    this.setState({ user: { signedIn: true, givenName: 'User1' } });
    const { showError } = this.props;
    try {
      const auth2 = window.gapi.auth2.getAuthInstance();
      const googleUser = await auth2.signIn();
      const givenName = googleUser.getBasicProfile().getGivenName();
      this.setState({ user: { signedIn: true, givenName } });
    } catch (error) {
      showError(`Error authenticating with Google: ${error.error}`);
    }
  }
...

  showModal() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    const { showError } = this.props;
    if (!clientId) {
      showError('Missing environment variable GOOGLE_CLIENT_ID');
      return;
    }
    this.setState({ showing: true });

  }
...

  render() {
    ...
    const { showing, disabled } = this.state;
    ...
            <Button
              block
              disabled={disabled}
              bsStyle="primary"
              onClick={this.signIn}
            >
              <img src="https://goo.gl/4yjp6B" alt="Sign In" />
            </Button>
    ...
  }
...

export default withToast(SigninNavItem);

...

Listing 14-6ui/src/SignInNavItem.jsx: Changes for Google Sign-In

由于对.env文件的更改,UI 服务器需要使用npm run dev-all重新启动。一旦你这样做并点击登录菜单项,你会在模态对话框中找到谷歌按钮。点击它会弹出一个新窗口,由谷歌控制。这将允许您登录您自己的任何 Google 帐户。图 14-2 显示了带有 Google 登录按钮的模态对话框截图。

img/426054_2_En_14_Chapter/426054_2_En_14_Fig2_HTML.jpg

图 14-2

使用 Google 按钮的登录模式对话框

一旦你登录了 Google,你会发现这个菜单项会被你的名字所取代,点击它,会出现一个注销菜单项,让你注销。其截图如图 14-3 所示。

img/426054_2_En_14_Chapter/426054_2_En_14_Fig3_HTML.jpg

图 14-3

登录后的应用

练习:Google 登录

  1. 假设我们想在登录后显示用户的个人资料图片。你认为这能做到吗?怎么做?提示:在谷歌开发者网站 https://developers.google.com/identity/sign-in/web/people 查找获取个人资料信息的指南。

本章末尾有答案。

验证 Google 令牌

仅仅向谷歌认证是不够的;我们需要做一些认证工作。在本节中,我们将确保凭据在后端得到验证。我们还将从后端获取用户名,以验证我们只使用经过验证的身份验证信息。稍后,我们将为登录用户建立一个会话,并在浏览器刷新过程中保持它的持久性。

作为一种安全措施,需要在后端验证令牌。这是因为后端不能信任 UI 已经完成了身份验证,因为它是公共的,并且还可以响应任何 HTTP 请求,而不仅仅是来自问题跟踪器 UI 的请求。在 https://developers.google.com/identity/sign-in/web/backend-auth 的指南“向后端服务器认证”中描述了实现这一点的技术。本质上,客户端身份验证库在成功的身份验证时返回一个令牌,这可以在后端使用 Google 的 Node.js 身份验证库进行验证。

让我们在 API 服务器中创建一个名为auth.js的新文件来保存所有与认证相关的函数。另外,我们不要使用 GraphQL 来实现登录 API。一个原因是在 GraphQL 解析器中设置和访问 cookie 并不简单,我们将在后面的章节中使用 cookie 来维护会话。另一个原因是为了保持实现的灵活性,如果需要,可以使用第三方库,比如 Passport,它直接连接到 Express 而不是 GraphQL。

因此,在auth.js中,我们将实现一系列端点作为快速路由。我们将导出这些路线,稍后我们会将它们装载到主 Express 应用中。这个文件的完整代码如清单 14-7 所示,我将讨论其中的一些片段。

因为我们需要访问 POST 请求的主体,所以我们必须安装一个解析器来允许我们这样做并在路由中使用它。此外,我们需要按照谷歌登录指南中的建议安装谷歌认证库。

$ cd api
$ npm install body-parser@1
$ npm install google-auth-library@2

auth.js中,我们需要做的第一件事是构建一个要导出的路由。让我们也在其中安装body-parser中间件。我们将在端点中只接受 JSON 文档。为此,可以使用bodyParser.json(),通过req.body访问 JSON 文档。

...
const Router = require('express');
const bodyParser = require('body-parser');

const routes = new Router();

routes.use(bodyParser.json());
...

module.exports = { routes };
...

在本节中,我们将只实现一个路由,'/signin'。在这个路由实现中,我们将从请求体中检索提供的 Google 令牌,并使用 Google 身份验证库对其进行验证。

...
const { OAuth2Client } = require('google-auth-library');
...

routes.post('/signin', async (req, res) => {
  const googleToken = req.body.google_token;
  ...

  const client = new OAuth2Client();
  let payload;
  try {
    const ticket = await client.verifyIdToken({ idToken: googleToken });
    payload = ticket.getPayload();
  } catch (error) {
    res.status(403).send('Invalid credentials');
  }

  ...
});
...

一旦我们获得了基于经过验证的令牌的有效载荷,我们就可以从有效载荷中提取各种字段,比如姓名和电子邮件。让我们提取这些内容,并用一个包含这些内容的 JSON 对象以及一个指示登录成功的布尔值来响应。清单 14-7 中显示了auth.js的完整代码,包括响应。

const Router = require('express');
const bodyParser = require('body-parser');
const { OAuth2Client } = require('google-auth-library');

const routes = new Router();

routes.use(bodyParser.json());

routes.post('/signin', async (req, res) => {
  const googleToken = req.body.google_token;
  if (!googleToken) {
    res.status(400).send({ code: 400, message: 'Missing Token' });
    return;
  }

  const client = new OAuth2Client();
  let payload;
  try {
    const ticket = await client.verifyIdToken({ idToken: googleToken });
    payload = ticket.getPayload();
  } catch (error) {
    res.status(403).send('Invalid credentials');
  }

  const { given_name: givenName, name, email } = payload;
  const credentials = {
    signedIn: true, givenName, name, email,
  };
  res.json(credentials);
});

module.exports = { routes };

Listing 14-7api/auth.js: New File for Auth-Related Code and Routes

现在,要在主应用中使用这组新路线,我们需要在主应用中挂载这些路线。让我们在路径/auth处这样做,以将名称空间与/graphql分开。因此,要访问signin端点,要使用的完整路径将是/auth/signin。这个变化是在server.js中进行的,如清单 14-8 所示。

...

const auth = require('./auth.js');

const app = express();

app.use('/auth', auth.routes);

installHandler(app);
...

Listing 14-8api/server.js: Changes for Mounting Auth Routes

此时,可以测试 API,但是这样做不太方便。您必须在变量googleUserSignInNavItem中初始化之后添加一个断点。成功登录后,执行将在此断点处停止。现在,在 JavaScript 控制台中,您可以执行以下代码来提取令牌:

> googleUser.getAuthResponse().id_token;

这将打印出一个很长的令牌,你可以复制。如果您正在使用 bash,那么您可以使用这个令牌通过粘贴来初始化一个名为token的环境变量,例如:

$ token="eyJhbGciOiJSUzI1NiI...."

现在,您可以通过在 MacOS 和 Linux 中执行下面的curl命令来测试新的signin API:

$ curl http://localhost:3000/auth/signin -X POST \
  --data "{ \"google_token\": \"$token\" }" \
  -H "Content-Type: application/json"

在输出中,您应该可以看到概要文件的详细信息,例如:

{"signedIn":true,"givenName":"Vasan","name":"Vasan Subramanian","email":"vasan.XXXXX@gmail.com"}

现在,由于我们有了一个新的端点前缀/auth,我们将需要一个新的配置变量,以便 UI 可以向它发送请求。这需要从服务器传递到 UI,就像 API 端点配置变量UI_API_ENDPOINT一样。让我们称新的配置变量为UI_AUTH_ENDPOINT。另外,在代理配置的情况下,除了/graphql端点前缀之外,我们还需要代理这个新的端点前缀。这两个添加在uiserver.js中,如清单 14-9 所示。

...
if (apiProxyTarget) {
  app.use('/graphql', proxy({ target: apiProxyTarget }));
  app.use('/auth', proxy({ target: apiProxyTarget }));
}
...

if (!process.env.UI_AUTH_ENDPOINT) {

  process.env.UI_AUTH_ENDPOINT = 'http://localhost:3000/auth';

}

app.get('/env.js', (req, res) => {
  ...
    UI_API_ENDPOINT: process.env.UI_API_ENDPOINT,
    UI_AUTH_ENDPOINT: process.env.UI_AUTH_ENDPOINT,
    GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
  ...
});
...

Listing 14-9ui/server/uiserver.js: Changes for New /Auth Endpoint Prefix

此更改将需要重启和刷新浏览器(因为对uiserver.js的更改不由 HMR 处理)。现在我们准备将 Google 令牌发送给新的 API。令牌本身可以通过调用googleUser.getAuthResponse().id_token获得,正如我们在手动提取令牌时看到的。然后,我们需要将这个令牌传递给signin API,收集其结果 JSON,并从那里使用givenName字段来设置状态变量givenName

这些变化都在组件SignInNavItem中,如清单 14-10 所示。

...
  async signIn() {
    ...
    let googleToken;
    try {
      ...
      const givenName = googleUser.getBasicProfile().getGivenName();
      this.setState({ user: { signedIn: true, givenName } });
      googleToken = googleUser.getAuthResponse().id_token;
    } catch (error) {
    ...

    try {
      const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
      const response = await fetch(`${apiEndpoint}/signin`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ google_token: googleToken }),
      });
      const body = await response.text();
      const result = JSON.parse(body);
      const { signedIn, givenName } = result;

      this.setState({ user: { signedIn, givenName } });
    } catch (error) {
      showError(`Error signing into the app: ${error}`);
    }
  }
...

Listing 14-10ui/src/SignInNavItem: UI Changes for Verifying Google Token at the Back-End

现在,如果您尝试登录,假设您没有在代理模式下运行,您将得到以下错误。

Access to fetch at 'http://localhost:3000/auth/signin' from origin 'http://localhost:8000' has been blocked by CORS policy:

正如我们在第七章中所讨论的,所有的 GraphQL APIs 都成功执行了,因为 Apollo Server 支持 CORS。但是,这仅适用于端点前缀/graphql。对于新的端点前缀/auth,我们需要单独处理它。但这还不是全部。由于我们将在下一节设置一个 cookie,因此我们需要一个更复杂的配置来完成这项工作。

与其现在做所有这些,不如让我们切换到代理操作模式,因为在这种模式下工作更简单。完成所有与身份验证和授权相关的更改后,我们将切换回非代理模式,并在后面的小节中正确配置 CORS。

要切换到代理模式,您必须在您的.env文件中进行更改(或者手动设置环境变量)。修改后的sample.env文件如清单 14-11 所示,可以用来复制粘贴行。因为我们将在代理和非代理模式之间来回切换,所以最好将两种配置都放在手边,但是注释掉。

...
UI_SERVER_PORT=8000

UI_API_ENDPOINT=http://localhost:3000/graphql

# UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql

# API_PROXY_TARGET=http://localhost:3000

# ENABLE_HMR=true
GOOGLE_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com

# Regular config

# UI_API_ENDPOINT=http://localhost:3000/graphql

# UI_AUTH_ENDPOINT=http://localhost:3000/auth

# Proxy Config

UI_API_ENDPOINT=http://localhost:8000/graphql

UI_AUTH_ENDPOINT=http://localhost:8000/auth

API_PROXY_TARGET=http://localhost:3000

UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql

...

Listing 14-11ui/sample.env: Switching to Proxy Mode

这种改变也需要重启 UI 服务器。重启后,如果您测试登录过程,您会发现对/auth/signin的 API 调用现在成功了(使用开发人员控制台中的 Network 选项卡来验证这一点),您会发现您的名字(基于您用来登录的 Google 用户)反映在导航栏中,如前一节所述。但不同的是,名字现在是验证过的,也可以在后端使用。

JSON Web 令牌

尽管我们从后端验证了令牌并使用了名称,但是我们没有持久化信息。这意味着在浏览器刷新时,关于登录的信息会消失,迫使用户重新登录。此外,对其他 API 的任何调用都不会携带任何身份验证信息,从而阻止 API 应用任何授权限制。

保存身份验证信息的一种方法是在后端创建一个由 cookie 标识的会话。这可以通过使用中间件express-session轻松完成,它在请求中添加了一个名为req.session的属性。在此会话中,可以设置和检索变量,例如用户的 ID 和电子邮件。中间件在内存中维护会话变量和 cookie 之间的映射,该映射也由中间件自动发送到浏览器。

但是使用这样的内存会话被认为是不可伸缩的,原因有几个:

  • 如果服务器实例不是单个的(出于可伸缩性或高可用性的原因),会话信息将不会在实例之间共享,需要用户分别登录到所有实例。

  • 会话信息是不透明编码的,不能在不同的服务之间共享,尤其是那些用不同语言编写或使用不同技术的服务。

  • 服务器重新启动将会丢失登录。

JSON Web 令牌(JWT)通过对需要存储在令牌中的所有会话信息进行编码来解决这个问题。这与我们通过谷歌认证后收到的谷歌令牌非常相似。令牌字符串本身包含所有信息,即用户名、电子邮件 ID 等。但是信息是加密的,所以它不能被窥探或模仿。

为什么不用谷歌令牌本身呢?为什么我们需要自己生成一个?原因是,如果您需要引入其他形式的身份验证,最好有一个单一的令牌,统一地向 Issue Tracker 应用标识用户。此外,创建我们自己的令牌允许我们添加更多的变量,例如,可以将角色标识符添加到会话信息中,并且可以快速检索该标识符以应用授权规则。

在这一节中,我们将建立一个即使在服务器重新启动后仍然存在的会话。我们将使用 JWT 生成一个令牌,并将其发送回浏览器。在 UI 进行的每个 API 调用中,都需要包含这个令牌,以标识登录的用户。

有多种方法可以保存令牌并在 API 调用时将其发送回后端:

  • UI 可以将它保存在内存,并在每个 API 调用请求中附加一个令牌作为头。用于此目的的通用标题是Authorization:标题。只要用户将应用用作 SPA,并且不刷新浏览器,这将非常有效。但是在浏览器刷新时,由于页面被重新加载,所有的 JavaScript 内存将被重新初始化,使得令牌不可用。

  • 令牌可以保存在浏览器的本地存储或会话存储中,而不是内存中,这样它就被持久化了。但是,如果应用中存在跨站点脚本(XSS)漏洞,这可能是不安全的。你可以在 OWASP 网站上了解更多信息: https://www.owasp.org/index.php/Cross-site_Scripting_(XSS) 。从本质上讲,XSS 漏洞是通过在生成页面时忘记对 HTML 特殊字符进行转义而产生的,这使得恶意用户能够将代码注入到应用中。因为它没有被转义,所以代码可以被执行而不是显示,从而允许以编程方式访问本地存储数据。

    大多数现代的 UI 框架,包括 React,都通过让程序员很难不编码就生成 HTML 来避免 XSS。但是,我们已经包括了一些第三方 UI 库,如 React-Bootstrap 和 React-Select,我们不能确定这些库在抵御 XSS 攻击方面做得有多好。

  • 令牌可以作为 *cookie、*发送,为了避免 XSS,我们可以通过设置 cookie 上的HttpOnly标志来防止 cookie 被编程读取。缺点是可以保存在 cookie 中的信息量被限制在 4KB。此外,浏览器对跨站点 cookies 有许多限制。由于问题跟踪器 UI 和 API 服务器是不同的,如果这两个服务器在不同的域下,几乎不可能使 cookies 工作。

    您也可能在互联网上读到 cookies 会使您的应用面临跨站点请求伪造(XSRF ),并且您需要为每个请求提供一个 XSRF 令牌来避免这种情况。但是这只适用于传统的 HTML 表单。

这些选择的总结如表 14-1 所示。

表 14-1

JWT 储存方法的比较

|

存储方式

|

优点

|

cons

| | --- | --- | --- | | 内存中 | 安全;没有大小限制 | 会话不是持久的;必须以编程方式管理在所有请求中包含令牌 | | 局部存储器 | 没有大小限制 | 可能容易受到 XSS 的攻击;存储和令牌包含必须以编程方式管理 | | 饼干 | 易于实施 | 数据的大小限制;跨域限制;容易受到 HTML 表单中 XSRF 的攻击 |

如果存储在 JWT 中的信息足够小,并且 UI 和 API 服务器属于同一个域,那么使用 cookies 来存储 JWT 似乎是最好的选择。此外,我们需要存储非常少的信息(只有姓名和电子邮件 ID,可能还有将来的角色),并且大多数应用部署的 UI 和 API 是主域的子域。此外,由于 Issue Tracker 应用没有传统的 HTML 表单,并且 GraphQL API 不接受除了application/json以外的任何内容作为 POST 请求中的内容类型,因此它不容易受到 XSRF 的攻击。但是它允许使用 GET 方法进行 API 调用,这很容易受到 XSRF 的攻击。我们需要禁用这种访问方法,因为 UI 不使用它。

让我们从在signin API 中生成 JWT 开始。jsonwebtoken包有一个方便的方法来做到这一点,所以让我们安装它。此外,因为我们将使用 cookie,所以让我们也安装一个 cookie 解析器。

$ cd api
$ npm install jsonwebtoken@8
$ npm install cookie-parser@1

我们不仅需要在auth.js中设置和检索 cookiess,还需要在 GraphQL 解析器中设置和检索 cookie(将来,当我们实现授权时)。因此,让我们为所有路由全局安装 cookie 解析器。清单 14-12 中显示了对server.js的更改。

...
const express = require('express');

const cookieParser = require('cookie-parser');

...

app.use(cookieParser());

app.use('/auth', auth.routes);
...

Listing 14-12api/server.js: Include Cookie Parser in All Routes

我们将对auth.js进行多处修改,这些都显示在清单 14-13 中。首先要做的是在signin API 中生成一个 JWT,并将其设置为 cookie。jsonwebtoken包提供了一个名为sign的简单函数,它接收一个 JavaScript 对象,并使用一个密钥对其进行加密。然后我们将设置一个名为jwt的 cookie,其值作为签名令牌。

...

const jwt = require('jsonwebtoken');

...
routes.post('/signin', async (req, res) => {
  ...
  const token = jwt.sign(credentials, JWT_SECRET);
  res.cookie('jwt', token, { httpOnly: true });

  res.json(credentials);
}
...

接下来,让我们创建一个新的 API 来获取当前的登录状态。这将完成验证 JWT 和提取用户名等工作。让我们将这个 API 端点称为路由集/auth下的/user。在这个过程中,我们将从 cookie 中检索 JWT 并调用jwt.verify(),这与sign相反:检索凭证。我们还将凭证的检索分离到一个单独的函数中,因为将来当我们实现授权时,我们需要对每个请求都这样做。

...

function getUser(req) {

  const token = req.cookies.jwt;
  if (!token) return { signedIn: false };

  try {
    const credentials = jwt.verify(token, JWT_SECRET);
    return credentials;
  } catch (error) {
    return { signedIn: false };
  }

}

...

routes.post('/user', (req, res) => {

  res.send(getUser(req));

});

...

至于JWT_SECRET变量,我们在环境中需要一个配置变量。为此,我们使用一个名为JWT_SECRET的环境变量。您应该生成自己的随机字符串作为该变量的值,尤其是在生产环境中部署应用时。

在没有这个变量的情况下,让我们只使用一个默认值,但只是在开发模式下。在生产中,如果密钥丢失,我们将禁用身份验证。包括此次变更在内,对auth.js的完整变更如清单 14-13 所示。

...
const { OAuth2Client } = require('google-auth-library');

const jwt = require('jsonwebtoken');

let { JWT_SECRET } = process.env;

if (!JWT_SECRET) {

  if (process.env.NODE_ENV !== 'production') {
    JWT_SECRET = 'tempjwtsecretfordevonly';
    console.log('Missing env var JWT_SECRET. Using unsafe dev secret');
  } else {
    console.log('Missing env var JWT_SECRET. Authentication disabled');
  }

}

const routes = new Router();

routes.use(bodyParser.json());

function getUser(req) {

  const token = req.cookies.jwt;
  if (!token) return { signedIn: false };

  try {
    const credentials = jwt.verify(token, JWT_SECRET);

    return credentials;
  } catch (error) {
    return { signedIn: false };
  }

}

routes.post('/signin', async (req, res) => {
  if (!JWT_SECRET) {
    res.status(500).send('Missing JWT_SECRET. Refusing to authenticate');
  }
  ...

  const credentials = {
    ...
  };

  const token = jwt.sign(credentials, JWT_SECRET);
  res.cookie('jwt', token, { httpOnly: true });

  res.json(credentials);
});

routes.post('/user', (req, res) => {

  res.send(getUser(req));

});

...

Listing 14-13api/auth.js: Changes for JWT Generation and Verification

为了确保在浏览器刷新时我们继续保持登录状态,让我们使用/auth/user端点 API 获取认证信息。到目前为止,唯一使用用户信息的组件是SignInNavItem组件。因此,让我们将这些数据加载到组件的componentDidMount()方法中,并在其状态中设置用户信息。

我们将使用在componentDidMount()中调用的loadData()函数的常见模式。在这个函数中,我们将调用/auth/user API,检索用户信息,并设置状态。注意,我们必须使用fetch() API,而不是 GraphQL API,因为还没有 GraphQL API 来获取当前的用户信息。对此的更改如清单 14-14 所示。

...
  async componentDidMount() {
    const clientId = window.ENV.GOOGLE_CLIENT_ID;
    if (!clientId) return;
    window.gapi.load('auth2', () => {
      if (!window.gapi.auth2.getAuthInstance()) {
        window.gapi.auth2.init({ client_id: clientId }).then(() => {
          this.setState({ disabled: false });
        });
      }
    });
    await this.loadData();
  }

  async loadData() {
    const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
    const response = await fetch(`${apiEndpoint}/user`, {
      method: 'POST',
    });
    const body = await response.text();
    const result = JSON.parse(body);
    const { signedIn, givenName } = result;
    this.setState({ user: { signedIn, givenName } });
  }
...

Listing 14-14ui/src/SignInNavItem.jsx: Changes to Use Persist and Use Authentication Info from the Back-End

现在,您可以测试应用,您会发现登录信息在浏览器刷新后仍然存在。检查 JWT 是否也在每个 GraphQL 请求上发送也是一个好主意。为此,您可以导航到不同的页面,并使用开发人员控制台来检查网络流量。您应该看到名为jwt的 cookie 在每次请求时都被发送到服务器。同样在浏览器刷新时,您应该看到一个请求发送到/auth/user,导航栏中的菜单项从“登录”变为用户的名字。

如果您单击“注销”,菜单项将变回“登录”,但是在浏览器刷新时,您会发现用户名又回到了菜单项上。这是因为 cookie 仍然是活动的,这表明处于登录状态。

注销

注销需要两件事:浏览器中的 JWT cookie 需要被清除,Google 认证需要被忘记。

因为我们已经在 cookie 中设置了HttpOnly标志,所以不能从前端代码以编程方式访问它。要清除它,我们必须依靠服务器。为此,让我们在/auth下实现另一个 API 来注销,这实质上只是清除 cookie。清单 14-15 中显示了对auth.js的更改。

...

routes.post('/signout', async (req, res) => {

  res.clearCookie('jwt');
  res.json({ status: 'ok' });

});

routes.post('/user', (req, res) => {
  ...
});
...

Listing 14-15api/auth.js: Sign-Out API

让我们从 UI 调用这个 API,并用对它的调用替换组件SignInNavItem中的普通signOut()函数。此外,让我们也调用谷歌认证 API 的signOut()函数,它是authInstance的一部分。清单 14-16 显示了SignInNavItem组件的变更。

...
  async signOut() {
    const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
    const { showError } = this.props;
    try {
      await fetch(`${apiEndpoint}/signout`, {
        method: 'POST',
      });
      const auth2 = window.gapi.auth2.getAuthInstance();
      await auth2.signOut();
      this.setState({ user: { signedIn: false, givenName: '' } });
    } catch (error) {
      showError(`Error signing out: ${error}`);
    }
  }
...

Listing 14-16ui/src/SignInNavItem.jsx: Changes for Signing Out from the Back-End and Google

经过这一系列更改后,即使在浏览器刷新时,您也会发现注销状态并没有改变。要确认这一点,您可以检查网络流量,以确保没有根据任何请求发送 cookie。确认这一点的另一种方法是查看浏览器中的 cookie 数据,并确保网站localhost在注销后没有jwt cookie。

批准

现在,我们已经确定了一个正在访问问题跟踪器应用的用户,让我们使用这些信息。典型的企业应用会有角色和属于角色的用户。角色将指定允许用户进行哪些操作。我们不需要实现所有这些,我们只需要实现一个简单的授权规则,它足以演示如何实现这一点。

我们将实现的规则是这样的:如果一个用户登录了,这个用户被允许进行修改。未经验证的用户只能阅读问题。因此,我们将防止未授权用户访问mutation下的任何 API。我们还需要更改 UI 来禁用不可用的操作,但是让我们在下一节讨论这个问题。在这一节中,我们将只确保后端 API 是安全的,并防止未经授权的修改。当试图进行未经授权的操作时,API 将报告错误。

Apollo Server 提供了一种机制,通过这种机制可以将上下文传递给所有解析器。到目前为止,我们只在任何解析器中使用前两个参数,例如:

...
async function add(_, { issue }) {
...

事实上,GraphQL 库传递了第三个参数,即上下文,可以根据应用的需求进行定制。

...
async function add(_, { issue }, context) {
...

可以将上下文设置为可以从请求中导出的任何值。我们可以将用户信息设置为上下文,并让每个解析器检查凭证是否足以满足请求。给定我们的简单规则,这可能看起来像:

...
async function add(_, { issue }, user) {
  if (!user || !user.signedIn) {
    throw new AuthenticationError('You must be signed in');
  }
  ...
}
...

让我们首先创建保存用户信息的上下文,并作为第三个参数传递给每个解析器。这样做的地方是在 Apollo 服务器初始化期间。除了typedef和解析器,我们还需要指定一个函数,它接受一个对象,将req作为一个属性,并返回将提供给所有解析器的上下文。

...
const server = new ApolloServer({
  typeDefs: 
...
  resolvers,
  context: getContext,
});
...

由于我们已经有了一个函数,它返回给定请求对象的用户凭证,作为auth.js的一部分,使用它来实现getContext()将会非常简单。经过这样的修改,对api_handler.js的最终修改如清单 14-17 所示。

...
const issue = require('./issue.js');

const auth = require('./auth.js');

...

function getContext({ req }) {

  const user = auth.getUser(req);
  return { user };

}

const server = new ApolloServer({
  typeDefs: ...
  resolvers,
  context: getContext,
  ...
});
...

Listing 14-17api/api_hander.js: Set the User as Context in All Resolver Calls

现在,与其在每个解析器函数中包含上下文并在所有函数中检查有效用户,不如让我们尝试重用这些代码。我们需要的是接收一个现有的解析器,并返回一个在执行解析器之前进行检查的函数。让我们在auth.js中创建这样一个函数并导出它。让我们也导出getUser,因为在getContext()内的api_handler中需要它。这些变化如清单 14-18 所示。

...
const jwt = require('jsonwebtoken');

const { AuthenticationError } = require('apollo-server-express');

...

function mustBeSignedIn(resolver) {

  return (root, args, { user }) => {
    if (!user || !user.signedIn) {
      throw new AuthenticationError('You must be signed in');
    }
    return resolver(root, args, { user });
  };

}

module.exports = { routes, getUser, mustBeSignedIn };
...

Listing 14-18api/auth.js: Common Function for Simplistic Authorization Check

现在,为了防止未经身份验证的用户调用受保护的 API,让我们用一个mustBeSignedIn包装的函数替换它们的导出。保护setAboutMessage的变化如清单 14-19 所示。

...

const { mustBeSignedIn } = require('./auth.js');

let aboutMessage = 'Issue Tracker API v1.0';
...

module.exports = { getMessage, setMessage: mustBeSignedIn(setMessage) };
...

Listing 14-19api/about.js: Prevent Unauthenticated Access to setAboutMessage

清单 14-20 中显示了所有与问题相关的 API 的一组类似的更改。

...
const { getDb, getNextSequence } = require('./db.js');

const { mustBeSignedIn } = require('./auth.js');

...

module.exports = {
  list,
  add: mustBeSignedIn(add),
  get,
  update: mustBeSignedIn(update),
  delete: mustBeSignedIn(remove),
  restore: mustBeSignedIn(restore),
  counts,
};
...

Listing 14-20api/issue.js: Prevent Unauthenticated Access to Issue Methods

现在,如果您尝试从调用这些 API 的 UI 中访问任何功能,您应该会发现它们会失败,并出现一个错误,"UNAUTHENTICATED: You must be signed in."例如,单击+按钮创建一个问题,然后在模式对话框中单击 Submit,将会导致出现一个错误消息。

练习:授权

  1. 如果我们需要防止未经认证的用户访问任何 ?? 功能,也就是说,整个应用需要有受保护的访问,那么 GraphQL APIs 应该如何改变呢?忽略允许用户登录所需的 UI 更改。只关注 API。提示:在 https://www.apollographql.com/docs/apollo-server/features/authentication.html 查找阿波罗认证文档。

本章末尾有答案。

支持授权的用户界面

阻止用户执行未授权的操作是很好的,但是在 UI 上阻止对这些操作的访问比只在后端检查它们更好。在这一节和下一节中,我们将让 UI 知道登录状态。

我们将使用两种机制来做到这一点。在本节中,我们将通过常规方式禁用导航栏中的“创建问题”按钮。我们将把状态提升到一个共同的祖先,并让状态向下流动,作为可以在子 Node 中使用的道具。在下一节中,我们将使用一种不同的技术,这种技术更适合于将道具传递给层次结构中非常深的组件。

让我们选择Page组件作为用户登录状态所在的层次结构中的组件。为此,我们必须将组件转换成常规组件,也就是说,不是无状态组件。然后,我们将状态变量userSignInNavItem移到Page。与其使用不同的方法来登录和退出,不如使用一个叫做onUserChange的方法,因为这样更容易将其作为道具传递下去。我们将把这个方法和user变量向下传递到导航栏(稍后,再向下传递)。

我们还需要通过调用 API /auth/user来加载componentDidMount()中的状态。这段代码可以从SignInNavItem复制过来。清单 14-21 中显示了对Page组件的更改。

...
export default function class Page () extends React.Component {
  constructor(props) {
    super(props);
    this.state = { user: { signedIn: false } };

    this.onUserChange = this.onUserChange.bind(this);
  }

  async componentDidMount() {
    const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
    const response = await fetch(`${apiEndpoint}/user`, {
      method: 'POST',
    });
    const body = await response.text();
    const result = JSON.parse(body);
    const { signedIn, givenName } = result;
    this.setState({ user: { signedIn, givenName } });
  }

  onUserChange(user) {
    this.setState({ user });
  }

  render() {
    const { user } = this.state;
    return (
      <div>
        <NavBar user={user} onUserChange={this.onUserChange} />
        <Grid fluid>
          <Contents />
        </Grid>
        <Footer />
      </div>
    );
  }

}

...

Listing 14-21ui/src/Page.jsx: Lift User State Up to Page

既然导航栏已经为登录用户提供了一个适当的变量,让我们将它传递给需要它们的NavItems:创建问题和登录菜单项。此外,登录菜单项需要在登录状态发生变化时调用onUserChange属性。所以让我们也通过它。这些变化如清单 14-22 所示。

...
function NavBar({ user, onUserChange }) {
  ...
      <Nav pullRight>
        <IssueAddNavItem user={user} />
        <SignInNavItem user={user} onUserChange={onUserChange} />
        ...
      </Nav>
  ...
}
...

Listing 14-22ui/src/Page.jsx: Passing Through User Properties to Navigation Items

显示处于禁用状态的 Create Issue 导航项很简单:我们将检查signedIn标志,如果是false,则禁用NavItem。清单 14-23 中显示了对IssueAddInNavItem组件的更改。

...
  render() {
    const { showing } = this.state;
    const { user: { signedIn } } = this.props;
    return (
      <React.Fragment>
        <NavItem disabled={!signedIn} onClick={this.showModal}>
      ...
    );
  }
...

Listing 14-23ui/src/IssueAddNavItem.jsx: Disable the Item When Not Signed In

SignInNavItem组件中,我们将为用户移除状态变量,并使用传入的新道具。此外,我们不再需要在组件挂载上加载数据,因为这是与Page组件中的状态一起完成的。这些变化如清单 14-24 所示。

...
  constructor(props) {
    this.state = {
      ...
      user: { signedIn: false, givenName: '' },
    };
  }
...

  async componentDidMount() {
    ...
    await this.loadData();
  }

  async loadData() {
    ...
  }

  async signIn() {
    ...
      this.setState({ user: { signedIn, givenName } });
      const { onUserChange } = this.props;
      onUserChange({ signedIn, givenName });
    ...
  }

  async signOut() {
    ...
      this.setState({ user: { signedIn: false, givenName: '' } });
      const { onUserChange } = this.props;
      onUserChange({ signedIn: false, givenName: ' ' });
    ...
  }
...

  render() {
    const { user } = this.state props;
    ...
  }
...

Listing 14-24ui/src/SignInNavItem.jsx: Moving User State Out

现在,在测试中,你会发现SignInNavItem保留了它在登录时显示用户名,在未登录时显示登录标签的行为。此外,当用户未登录时,“创建问题”按钮会被禁用。

尽管这看起来像是做了很多工作却收效甚微,但是当兄弟组件之间共享公共属性时,这就变得很有必要了。注意,我们可以将状态移到导航栏组件NavBar,但是因为我们需要将用户状态传递给不在NavBar下的其他组件,所以将它移到Page组件会更方便。

React 上下文

在本节中,我们将让其他组件知道身份验证状态。我们将禁用问题表中的关闭和删除按钮,然后禁用编辑页面中的提交按钮。

但是,以与导航菜单项相同的方式这样做不仅会使它变得乏味(通过层次结构中的许多组件传递用户属性),而且还会带来挑战。在Contents组件中,我们从一个数组中生成路线,不清楚如何将道具传递给将要呈现的组件。

因此,在这一节中,我将介绍 React Context API,它可用于跨组件层次结构传递属性,而不会让中间组件知道它。React 上下文旨在共享被认为是全局的数据。经过身份验证的用户确实属于全局类别,因此为此使用上下文不失为一个好主意。

要开始使用上下文,我们首先需要使用React.createContext()方法创建一个上下文。这需要一个参数,即上下文的缺省值,它将被传递给所有需要它的组件。因为我们需要传递一个用户对象,所以让我们将它用作上下文变量,并将其默认值设置为用户未登录的初始状态。我们还需要跨组件共享上下文,包括设置值和使用值的位置。因此,让我们为此创建一个名为UserContext的独立 JavaScript 模块。这个新模块如清单 14-25 所示。

import React from 'react';

const UserContext = React.createContext({
  signedIn: false,
});

export default UserContext;

Listing 14-25ui/src/UserContext.js: A React Context for Storing User State

创建的上下文在其下公开了一个名为Provider的组件,该组件需要包装在任何需要该上下文的组件层次结构中。该组件接受一个名为value的属性,该属性需要被设置为上下文将在所有派生组件中设置的值。例如,请看下面的代码:

<UserContext.Provider value={{ givenName: 'User1' }}>
  <IssueList />
</UserContext>

这将使UserContextthis.context的形式提供给IssueList组件,以及IssueList的所有后代。但是为了明确需要消费的是UserContext,我们必须将上下文类型指定为任何希望消费上下文的后代中的组件的静态变量。因此,要在IssueTable组件中使用用户上下文,我们需要做以下事情:

class IssueTable extends React.Component {
  render() {
    const user = this.context;
    ...
  }
}

IssueTable.contextType = UserContext;

注意,由于IssueTableIssueList的子 Node,它将接收用户上下文,而不必通过IssueList组件显式传递。此外,我们不必将提供者中的上下文值设置为静态值,如图所示。它可以被设置为状态变量的值,这样当状态改变时,就可以用上下文中的新值重新呈现所有子组件。

因此,让我们将用户状态设置为提供者中的值,并将提供者包装在Page中的Contents组件周围。对此的更改如清单 14-26 所示。

...
import Search from './Search.jsx';

import UserContext from './UserContext.js';

...

  render() {
    const { user } = this.state;
    return (
      ...
          <UserContext.Provider value={user}>
            <Contents />
          </UserContext.Provider>
      ...
    );
  }

Listing 14-26ui/src/Page.jsx: Providing the User Context

现在,Contents的所有后代都可以访问用户上下文。我们先在IssueEdit组件里消费一下。如前所述,我们需要定义静态变量contextType,并将其设置为对象UserContext。然后,用户将作为this.context可用,我们将使用它来获取signedIn属性,并基于此禁用提交按钮。这些变化如清单 14-27 所示。

...

import UserContext from './UserContext.js';

class IssueEdit extends React.Component {
  render() {
    ...
    const user = this.context;

    return (
      ...
                  <Button
                    disabled={!user.signedIn}
                    bsStyle="primary"
                    type="submit"
                  >
      ...
    );
  }
}

IssueEdit.contextType = UserContext;

...

Listing 14-27ui/src/IssueEdit.jsx: Changes to Disable the Submit Button Based on User Context

这一更改可以单独测试—在您登录之前,您应该能够看到编辑页面中的提交按钮被禁用。

下一个需要修改以包含用户上下文的组件是IssueRow组件。但是不幸的是,无状态组件没有一个this变量,因此,无法通过this获得上下文。React 的早期版本将上下文作为附加参数传递给功能组件,这可以使用遗留上下文 API 来完成。但是不建议这样做,因为旧的 API 已经过时了。

另一种选择是使用最近的非无状态组件的父组件的上下文,并将变量作为 props 向下传递。但是这违背了创建上下文的初衷。因此,让我们将IssueRow组件转换成常规的(也就是说,而不是无状态的)组件,并使用上下文。

还有另一个复杂性:我们使用withRouter包装组件。这导致被包装的组件从内部组件(被包装的组件)继承静态变量contextType,从而导致开发人员控制台出错。这是因为被包装的组件恰好是一个无状态组件。

为了防止它抛出这个错误,我们需要删除包装组件中的contextType静态变量,将它单独留在内部组件中。在撰写本书时,React Router 中就存在这个问题,但在您阅读本书并试用它时,这个问题可能已经解决了。更多信息请参见 https://stackoverflow.com/questions/53240058/use-hoist-non-react-statics-with-withrouter 本期详细内容。

要做到这一切,我们需要为内部组件创建一个不同的名称,以便可以独立地访问它。姑且称之为IssueRowPlain,和之前一样,用路由包装的组件为IssueRow。清单 14-28 显示了转换为常规组件以及消费上下文的变化。为了简洁起见,没有显示缩进的变化。

...
import UserContext from './UserContext.js';

const IssueRow = withRouter(({

  issue, location: { search }, closeIssue, deleteIssue, index,

}) => {

// eslint-disable-next-line react/prefer-stateless-function

class IssueRowPlain extends React.Component {

  render() {
    const {
      issue, location: { search }, closeIssue, deleteIssue, index,
    } = this.props;
    const user = this.context;
    const disabled = !user.signedIn;

    const selectLocation = { pathname: `/issues/${issue.id}`, search };
    ...
            <Button disabled={disabled} bsSize="xsmall" onClick={onClose}>
          ...
            <Button disabled={disabled} bsSize="xsmall" onClick={onDelete}>
    ...

  }
}

IssueRowPlain.contextType = UserContext;

const IssueRow = withRouter(IssueRowPlain);

delete IssueRow.contextType;

...

Listing 14-28ui/src/IssueTable.jsx: IssueRow Converted to Regular Component for Consuming User Context

通过这一更改,您应该能够看到关闭和删除按钮在默认情况下是禁用的,直到您登录。如果您愿意,您可以恢复对导航项目的更改,以使用上下文,而不是通过NavBar组件将用户作为道具传递。

练习:对上下文做出 React

  1. 我们避免将道具传递给由<Route> s 构造的组件。在user上下文的情况下,我们可以这样做,因为用户信息确实可以被认为是一个全局变量。如果您需要将某些东西传递给被路由的组件,但它不是一个全局变量,该怎么办?换句话说,如何将属性传递给路由组件?提示:查一下这篇博文: https://tylermcginnis.com/react-router-pass-props-to-components/

本章末尾有答案。

有证书的 CORS

当我们添加对 Google token 的验证时,我们不得不切换到代理操作模式,因为 CORS 阻止了向/auth/signin发送请求。在这一节中,我们将看到如何通过放松 CORS 选项让应用在非代理模式下工作,同时保持安全性。

请求被阻止的原因是,当应用的来源(起始页面/index.html的来源)与任何 XHR 调用的目标不同时,浏览器会认为它不安全而阻止它。在问题跟踪器应用中,起始页是从http://localhost:8000获取的,但是 API 调用是对http://localhost:3000进行的。在这一点上,阅读第 7 “架构和 ESLint”中标题为“基于代理的架构”的部分会对你有所帮助,作为对 CORS 的一个总结。

让我们首先切换回非代理模式。如果您使用基于sample.env.env,您可以做出如清单 14-29 所示的更改。常规配置中的行被取消注释,代理配置中的行被注释掉以影响此更改。

...
# Regular config
# UI_API_ENDPOINT=http://localhost:3000/graphql
# UI_AUTH_ENDPOINT=http://localhost:3000/auth

# Proxy Config
# UI_API_ENDPOINT=http://localhost:8000/graphql
# UI_AUTH_ENDPOINT=http://localhost:8000/auth
# API_PROXY_TARGET=http://localhost:3000
# UI_SERVER_API_ENDPOINT=http://localhost:3000/graphql
...

Listing 14-29ui/sample.env: Switch Back to Non-Proxy Mode

需要重新启动服务器才能读入新的环境变量。此时,如果您尝试登录,您会发现登录失败,并在开发人员控制台中显示以下错误消息:

Access to fetch at 'http://localhost:3000/auth/user' from origin 'http://localhost:8000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Apollo 服务器的默认配置启用了 CORS 并允许对/graphql的请求。但是由于在/auth上没有做,所以被屏蔽了。让我们尝试启用 CORS 的/auth路线。cors包让我们很容易做到这一点。我们需要做的就是在路由中安装一个中间件,它将处理在对 API 服务器的飞行前请求中设置必要的报头。让我们首先在api目录下安装包。

$ cd api
$ npm install cors@2

然后,我们需要导入这个包并在auth.js中添加一个中间件。

...

const cors = require('cors');

...
routes.use(bodyParser.json());

routes.use(cors());

...

现在,登录似乎成功了,您将能够看到 Sign In 菜单项发生了变化,以反映给定的名称。因此,添加默认中间件确实有效,浏览器也向/auth routes 发送 API 请求。但是有一个警告。如果刷新浏览器,你会发现认证信息已经消失了!

如果您检查开发人员工具的 Network 选项卡,您会发现jwt cookie 没有在进一步的请求中被发送到服务器,因此对/auth/user的请求会将signedIn标志返回为false。但是你可以看到 cookie 确实是在对/auth/signin的回应中设置的。

因此,默认的 CORS 配置似乎允许请求,但是不允许为跨源请求发送 cookies。这是一个安全的默认设置,因为任何公共资源都应该是可访问的;只有经过身份验证的具有任何用户凭据的请求才必须被阻止。

要让凭证也传递到跨来源,必须执行以下操作:

  1. 所有 XHR 调用(即使用 API fetch()的调用)都必须包含头credentials: 'include'。否则,cookies 将从这些调用中删除。

  2. Including credentials using this method is forbidden if cross-origin requests from a wildcard origin are allowed by the server. If you noticed in the request headers, there is a header Access-Control-Allow-Origin: *. This is the default CORS configuration, and for good reason, including credentials from any origin should be disallowed. We’ll need to change this to allow requests with origin only from the UI server. Thus, the CORS middleware should include an origin as a configuration option, for example:

    ...
    routes.use(cors({ origin: 'http://localhost:8000’ }));
    ...
    
    

    如果没有这样做,您会在开发人员控制台中发现如下有用消息:

  3. 如果浏览器被指示发送凭证,这是不够的;服务器也必须明确地允许它。这由另一个名为credentials的 CORS 配置选项完成,该选项必须设置为true

Access to fetch at 'http://localhost:3000/auth/user' from origin 'http://localhost:8000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

让我们首先对auth.js进行更改,以设置这些 CORS 配置选项。同样,与其硬编码原点,不如使用一个名为UI_SERVER_ORIGIN的环境变量。您可以将它保存在您的.env文件中,类似于清单 14-30 中所示的对sample.env的更改。

...
# ENABLE_CORS=false

UI_SERVER_ORIGIN=http://localhost:8000

...

Listing 14-30api/sample.env: Option for UI Server’s Origin

auth.js中,让我们使用这个环境变量并设置 CORS 选项来包含来源和凭证。清单 14-31 中显示了对auth.js的最后一组更改。

...

const cors = require('cors');

...
routes.use(bodyParser.json());

const origin = process.env.UI_SERVER_ORIGIN || 'http://localhost:8000';

routes.use(cors({ origin, credentials: true }));

...

Listing 14-31api/auth.js: Changes to Include CORS with Correct Configuration Options

我们还需要在所有对/auth的 API 调用中包含凭证。先在SignInNavItem内做这个吧。如清单 14-32 所示。

...
  async signIn() {
    ...
      const response = await fetch(`${apiEndpoint}/signin`, {
        method: 'POST',
        credentials: 'include',
        ...
      });
    ...
  }

  async signOut() {
    ...
      await fetch(`${apiEndpoint}/signout`, {
        method: 'POST',
        credentials: 'include',
      });
    ...
  }
...

Listing 14-32ui/src/SignInNavItem.jsx: Include Credentials in Fetch Calls

然后,在Page.jsx中,当我们获取用户凭证时,我们必须包含这些凭证。这一变化如清单 14-33 所示。

...
  async componentDidMount() {
    ...
    const response = await fetch(`${apiEndpoint}/user`, {
      method: 'POST',
      credentials: 'include',
    });
...

Listing 14-33ui/src/Page.jsx: Include Credentials in Fetch Calls

现在,在测试中,您会发现浏览器刷新确实会保持登录状态。但是所有的 GraphQL API 调用都使用原始的 CORS 配置,它还没有凭证。这将导致 API 拒绝来自应用的任何修改。例如,即使您已登录,创建问题也会失败,提示您需要登录。

为了让 GraphQL API 调用允许这些,我们还需要在 Apollo 服务器中设置 CORS 配置选项。除了truefalse值,创建 Apollo 服务器时的 CORS 选项还可以接受 CORS 配置本身。这是我们将用来设置 CORS 配置。

除了只允许已配置的源,我们还将限制只允许 POST 的方法。这是因为 Apollo 服务器默认允许 GET 请求,正如“JSON Web 令牌”一节中所讨论的,这可能是 XSRF 漏洞的一个原因。应用本身不使用 POST 之外的任何方法,因此添加此限制是安全的。清单 14-34 显示了 GraphQL API 中 CORS 配置的变化。

...
function installHandler(app) {
  ...
  console.log('CORS setting:', enableCors);
  let cors;
  if (enableCors) {
    const origin = process.env.UI_SERVER_ORIGIN || 'http://localhost:8000';
    const methods = 'POST';
    cors = { origin, methods, credentials: true };
  } else {
    cors = 'false';
  }
  server.applyMiddleware({ app, path: '/graphql', cors: enableCors });
}
...

Listing 14-34api/api_handler.js: CORS Configuration for Apollo Server

因为所有的 GraphQL API 调用都是通过graphQLFetch函数路由的,所以我们唯一需要添加credentials: 'include'头的地方是在graphQLFetch.js中。这一变化如清单 14-35 所示。

...
    const response = await fetch(apiEndpoint, {
      method: 'POST',
      credentials: 'include',
      ...
    });
...

Listing 14-35ui/src/graphQLFetch.js: Include Credentials in Fetch Calls

通过所有这些更改,您会发现所有 GraphQL 调用的凭证也被发送到服务器。在这一点上,应用应该像上一节结束时在代理操作模式下那样工作。您还应该查看开发人员工具中的 Network 选项卡,并验证 cookie 是否被设置在身份验证响应上,并被发送到 API 服务器用于/auth/graphql调用。

使用凭据的服务器呈现

到目前为止,我们还没有处理包含认证用户信息的页面的服务器呈现。这样做的效果是,在浏览器刷新时,页面被加载,就好像用户没有登录一样。菜单项上写着“签到”。然后,在异步获取用户凭证并更新用户状态后,菜单会更改为登录用户的给定名称。

实际上,至少对于搜索引擎机器人来说,这是完全没问题的。这是因为搜索引擎不可能以用户身份登录并抓取网站页面。机器人抓取的只是公开可用的页面集。

但是,如果您不喜欢从未登录状态转换到已登录状态的(稍微)笨拙的闪烁,或者,如果您希望无论用户是否登录,所有页面的行为都保持一致,那么您可能希望考虑在包含凭证的服务器上呈现页面。

使用凭证的服务器呈现本质上有三个挑战,这不同于我们用于路由视图的服务器呈现的常规模式。

  • 为用户凭证获取的初始数据将被发送到/auth端点,而不是/graphql端点。服务器渲染依赖于这样一个事实,即所有的数据获取调用都要经过graphQLFetch(),在那里我们根据调用是来自浏览器还是 UI 服务器来做出关键的决定。

  • 当获取用户数据时,UI 服务器获取数据的 API 调用必须包含 cookie。当从 UI 调用时,浏览器会自动添加这个 cookie。但是在 UI 服务器中,我们需要手动包含 cookie。否则,呼叫将表现为用户未登录。

  • 在视图中使用fetchData()函数渲染之前,除了提取的任何其他数据之外,提取的初始数据还需要*。此外,这个数据不是由作为路由一部分的任何视图获取的:它是在一个高得多的级别上获取的,在Page组件中。*

为了解决第一个挑战,让我们为经过身份验证的用户凭证引入一个新的 GraphQL API。我们使用了/auth组路由,因为 GraphQL 解析器不能设置 cookies,因为它们不能访问 Express response 对象。但是获取登录用户的凭证只需要访问 Express request 对象,正如我们在前面的授权部分看到的,这应该是可行的。

要开始实现这个 API,让我们改变模式。这一变化如清单 14-36 所示。

...
type IssueListWithPages {
  ...
}

type User {

  signedIn: Boolean!
  givenName: String
  name: String
  email: String

}

...

type Query {
  about: String!
  user: User!
  ...
}
...

Listing 14-36api/schema.graphql: User Credentials API

至于解析器,让我们引入一个解析器函数作为auth.js的一部分,它只返回上下文。您还记得,我们将所有解析器的 GraphQL 上下文都设置为 user 对象,所以这就是我们所需要的返回值。对auth.js的更改如清单 14-37 所示。

...

function resolveUser(_, args, { user }) {

  return user;

}

module.exports = {
  routes, getUser, mustBeSignedIn, resolveUser,
};
...

Listing 14-37api/auth.js: GraphQL Resolver for Returning User Credentials

让我们将这个新的解析器绑定到 API 处理程序中。这一变化如清单 14-38 所示。列表

...
onst resolvers = {
  Query: {
    about: about.getMessage,
    user: auth.resolveUser,
    ...
  },
  ...
};

...

14-38api/api_handler.js: Add Resolver for User API

要测试 API,您可以使用操场。但是现在我们有了受保护的 API,默认情况下 Playground 不会为这些 API 工作。您需要确保在 API 查询中发送 cookies。操场上有一个设置允许这样做,叫做request.credentials.,默认值是"omit",你需要把它改成"include"。然后,所有请求都将包含 cookies。因此,在登录到应用后,下面的查询应该返回登录用户的给定名称。

query { user { givenName } }

下一个挑战是让用户凭证通过 UI 服务器传递给 API 调用。您还记得,当浏览器向路由的 URL(比如到 UI 服务器的/issues/about)发出请求时,会返回一个服务器呈现的页面。然后,UI 服务器对 API 服务器进行 API 调用,并使用数据预填充页面。因此,UI 服务器在第一步中接收到的 cookie 需要在对 API 服务器的调用中进行复制。

本质上,作为呈现的一部分,从服务器发出的任何对graphQLFetch()的调用都需要包含在发起请求时接收到的 JWT cookie。我们可以做到这一点的方法是让所有的fetchData()静态函数接收一个可选参数cookie,当任何函数调用graphQLFetch()时都可以传递这个参数。

现在让我们将graphQLFetch.js改为能够在 API 请求中包含 cookie。这一变化如清单 14-39 所示。

...
export default async function
graphQLFetch(query, variables = {}, showError = null, cookie = null) {
  const apiEndpoint = (__isBrowser__) // eslint-disable-line no-undef
    ? window.ENV.UI_API_ENDPOINT
    : process.env.UI_API_ENDPOINT;
  try {
    const headers = { 'Content-Type': 'application/json' };
    if (cookie) headers.Cookie = cookie;
    const response = await fetch(apiEndpoint, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
...

Listing 14-39ui/src/graphQLFetch.js: Changes to Pass Through Cookies

由于这个函数既在服务器上也在浏览器上使用,我们必须确保对于来自服务器渲染例程的调用,即来自render.jsx的调用,cookie 参数只传递给*。对于来自浏览器的调用,浏览器将自动包含 cookie。*

使用这个,让我们改变Page组件来加载数据,作为静态fetchData()函数的一部分,就像我们对其他路由组件所做的那样。我们也将用新的 GraphQL API 替换对/auth/user的调用。这是新的静态函数,cookie 是可选的。我们需要为服务器端请求传入 cookie,因此该函数需要将 cookie 作为参数。

...
  static async fetchData(cookie) {
    const query = `query { user {
      signedIn givenName
    }}`;
    const data = await graphQLFetch(query, null, null, cookie);
    return data;
  }
...

在构造函数中,让我们检查包含用户凭证值的全局存储,就像我们对路由组件所做的那样,如果它存在,就使用它。我们将通过在状态变量中存储一个null值来表示它不存在,这个值componentDidMount()可以检查并加载数据。我们将需要使用不同的变量,而不是使用store.initialData,因为用户数据是加上渲染路由组件所需的其他数据。为此,让我们使用一个名为userData的新变量。

...
  constructor(props) {
    ...
    const user = store.userData ? store.userData.user : null;
    delete store.userData;
    this.state = { user };
    ...
  }
...

注意,由于Page组件总是被渲染,所以不可能出现服务器不将用户数据作为渲染的一部分的情况。但是为了保持一致,并允许通过导航将该组件安装到服务器上,让我们确保数据被加载到componentDidMount()中,就像我们对视图所做的那样。这个调用不需要包含任何 cookies,因为浏览器会自动附加它们。

...
  async componentDidMount() {
    const { user } = this.state;
    if (user == null) {
      const data = await Page.fetchData();
      this.setState({ user: data.user });
    }
  }
...

最后,在render()方法中,让我们检查用户状态变量是否为 null 并返回null,以保持它与其他视图一致,即使这种情况不会发生。清单 14-40 中显示了该变更以及对Page.jsx的其他变更。

...
import UserContext from './UserContext.js';

import graphQLFetch from './graphQLFetch.js';

import store from './store.js';

...

export default class Page extends React.Component {
  static async fetchData(cookie) {
    const query = `query { user {
      signedIn givenName
    }}`;
    const data = await graphQLFetch(query, null, null, cookie);
    return data;
  }

  constructor(props) {
    super(props);
    const user = store.userData ? store.userData.user : null;
    delete store.userData;
    this.state = { user: { signedIn: false } };

    ...
  }

  async componentDidMount() {
    const apiEndpoint = window.ENV.UI_AUTH_ENDPOINT;
    ...
    this.setState({ user: { signedIn, givenName } });
    const { user } = this.state;
    if (user == null) {
      const data = await Page.fetchData();
      this.setState({ user: data.user });
    }
  }
...

  render() {
    const { user } = this.state;
    if (user == null) return null;
    ...
  }
...

Listing 14-40ui/src/Page.jsx: Changes for fetchData Separation, Using graphQLFetch and Global Store

我们真的不需要改变所有其他的fetchData()调用,比如IssueList.fetchData来包含 cookie。这是因为 cookie 的存在对这些公开可用的 API 的结果没有影响。可能在某些情况下需要这样做,例如,如果返回的问题列表取决于当前登录的用户。在这种情况下,相关的fetchData()函数也需要修改,以便能够通过任何 cookies。

为了解决获取用户数据以及路由视图所需的其他数据的下一个挑战,让我们决定所有的全局数据获取(比如用户凭证)都必须在服务器上呈现时进行硬编码。我们不会像对布线元件那样通用和灵活。因此,当在服务器上呈现时,让我们显式地调用Page.fetchData()来获取全局数据并将其包含在存储中。

我们需要做的第一个改变是在模板中,我们将为用户数据创建一个新变量,就像我们为初始数据所做的那样。我们还将为初始数据重命名参数,使其更加明确。这显示在清单 14-41 中。

...
export default function template(body, data initialData, userData) {
 ...
  <script>
    window.__INITIAL_DATA__ = ${serialize(data initialData)}
    window.__USER_DATA__ = ${serialize(userData)}
  </script>
  ...
}
...

Listing 14-41ui/server/template.js: Include User Data

并且,在浏览器中,为了将这些数据传输到商店,我们需要更改App.jsx。变更如清单 14-42 所示。

...
import store from '../src/store.js';

// eslint-disable-next-line no-underscore-dangle

/* eslint-disable no-underscore-dangle */

store.initialData = window.__INITIAL_DATA__;

store.userData = window.__USER_DATA__;

...

Listing 14-42ui/browser/App.jsx: Transfer User Data to Store

接下来,在服务器呈现调用过程中,让我们获取用户数据,并将其提供给模板,以构建浏览器中可用的用户数据变量。在这样做的时候,我们必须将 cookie 从请求头传递到fetchData()调用。服务器渲染的变化如清单 14-43 所示。

...
  if (activeRoute && activeRoute.component.fetchData) {
    ...
    initialData = await activeRoute.component
    .fetchData(match, search, req.headers.cookie);
  }

  const userData = await Page.fetchData(req.headers.cookie);

  store.initialData = initialData;
  store.userData = userData;
...

    res.send(template(body, initialData, userData));
...

Listing 14-43ui/server/render.jsx

注意,我们将 cookie 包含在所有 fetchData()请求的中,包括活动路由组件的fetchData()调用。如前所述,这不是必需的,因为这些调用不会受到凭证存在的影响。然而,为了保持一致性,并让将来可能使用凭证的视图的修改以不同的方式显示内容,让我们保持这一点。

现在,如果您测试应用,您会发现菜单项的闪烁不再存在。虽然这不是一个很好的用例,可以用引入的所有复杂性来解决,但它可以作为一种模式,用于在服务器呈现时包含真正的全局数据。

Cookie 域

在前两节中,我介绍了 cookies 的一个重要方面,当从浏览器直接访问 API 时,它就会发挥作用。因为我们使用localhost作为访问应用的域,所以它可以无缝地工作。

需要注意的是,当涉及到跨站点请求时,cookies 和 CORS 的工作方式略有不同。CORS 甚至会将端口差异视为不同的来源,而服务器设置的 cookie 与绑定在一起,浏览器对同一域的所有请求都将包含 cookie。例如,由localhost:3000设置的 cookie 也将被发送到localhost:8000。cookie 策略忽略端口的差异。

如果您在开发人员工具中检查网络流量,您会发现 JWT cookie 被传递给对localhost:8000(UI 服务器)的请求,即使它是由端口 3000 上的 API 服务器通过signin API 设置的。这使得我们能够在服务器呈现期间通过 UI 服务器将凭证传递给 API 服务器。

为了测试这是否也适用于真实的域,您需要创建一个域和两个子域,它们都指向本地主机。这可以通过编辑hosts文件并添加以下行来完成:

...
127.0.0.1 api.promernstack.com ui.promernstack.com
...

(在 MacOS 和 Linux 上,这个文件可以在/etc/hosts找到,在 Windows PC 上可以在c:\Windows\System32\Drivers\etc\hosts找到。)

现在,您可以设置环境变量并配置您的 UI 服务器,以便 API 端点基于api.promernstack.com:3000,然后使用ui.promernstack.com:8000访问应用。清单 14-44 显示了ui目录中sample.env的变化。

...
# Regular config
# UI_API_ENDPOINT=http://localhost:3000/graphql
# UI_AUTH_ENDPOINT=http://localhost:3000/auth

# Regular config with domains

UI_API_ENDPOINT=http://api.promernstack.com:3000/graphql

UI_AUTH_ENDPOINT=http://api.promernstack.com:3000/auth

...

Listing 14-44ui/sample.env: Changes for Using Domain Names

您还需要在 Google 开发者控制台的允许来源列表中添加 UI 服务器的 URL ui.promernstack.com来测试这一点;否则,您将无法使用 Google 登录。此外,API 服务器需要将 CORS 原点设置为 UI 的新 URL:ui.promernstack.com:8000

...
UI_SERVER_ORIGIN=http://ui.promernstack.com:8000
...

在此之后,您会发现认证和授权仍然不起作用。特别是,登录可以工作,但是浏览器刷新会丢失凭证。

那是因为 cookie 域的默认域设置是 URL 的主机部分,也就是api.promernstack.com。cookie 将而不是作为对ui.promernstack.com的调用中的请求的一部分发送(如您在开发工具网络选项卡中所见)。为了让两个应用共享 cookie,我们需要将 cookie 的域设置为基本域名,也就是说,不包含子域apiui。让我们在响应/signinres.cookie()调用中这样做。让我们从名为COOKIE_DOMAIN的环境变量中获取域。这显示在清单 14-45 中。

...
res.cookie('jwt', token, { httpOnly: true, domain: process.env.COOKIE_DOMAIN });
...

Listing 14-45api/auth.js: Changes for Setting Cookie Domain

至于设置变量本身,你应该在你的.env文件中完成。清单 14-46 中显示了一个示例更改,以及我们之前对UI_SERVER_ORIGIN所做的更改。

...

UI_SERVER_ORIGIN=http://localhost:8000

UI_SERVER_ORIGIN=http://ui.promernstack.com:8000

COOKIE_DOMAIN=promernstack.com

...

Listing 14-46api/sample.env: New Variable for COOKIE_DOMAIN and Setting UI_SERVER_ORIGIN

现在(在重启服务器之后,因为环境变量已经改变了),您会发现身份验证工作正常,因为 API 调用中设置的 cookies 被用于对 UI 服务器的请求。

但是,如果您需要不同的域,例如,如果您需要在 API 处于api.promernstack.com:3000时使用localhost:8000访问应用,您会发现凭证是而不是发送到 API 服务器的。这是因为域是不同的(localhostpromernstack.com)。要求两台服务器位于两个不同的域(而不是子域)的情况非常少见。但是如果真的需要,最好的选择是使用代理操作模式,在这种模式下,浏览器只能看到 API 和 UI 的一个域。

摘要

有许多方法来验证用户,不同的应用有不同的需求。对于问题跟踪器应用,我们使用 Google 来验证用户。注册和认证用户需要不同的方法,比如使用用户 ID,但是一旦通过认证,你在本章中学到的其他概念仍然适用。

您看到了如何使用 JWT 以无状态、安全的方式持久保存会话信息。然后,您看到了授权如何与 GraphQL APIs 一起工作,以及如何扩展它以根据应用的需求执行不同的授权检查。您还看到了当浏览器直接访问 API 时,浏览器上的 CORS 和 cookie 处理限制是如何发挥作用的。

所有这些都是在您自己的计算机上运行应用时完成的。为了使应用对其他用户可用,它需要托管在外部服务器上。在下一章中,我们将看看如何在云上使用平台即服务(PaaS)来实现这一点,Heroku。

练习答案

练习:Google 登录

  1. 是的,个人资料图片是可用的,可以通过调用基本个人资料的getImageUrl()函数获得。

练习:授权

  1. As the section titled “Schema Authorization” explains, a simple way to achieve this is to throw an exception in the getContext() function itself, if the user is not signed in.

    ...
    function getContext({ req }) {
      const user = auth.getUser(req);
      if (!user || !user.signedIn) throw new AuthenticationError('you must be logged in');
      return { user };
    },
    ...
    
    

    这样,调用甚至不会到达任何解析器函数。

练习:对上下文做出 React

  1. 正如博文中所建议的,您将需要使用Route组件的render属性,而不是component属性。属性接受一个以props作为参数的函数。使用提供的道具,您可以通过传入这些道具以及您需要传入的任何其他附加道具来构造一个组件。因此,如果您必须将user作为道具传递,这就是Context.jsx中的代码看起来的样子。

    ...
          {routes.map(attrs => (
            <Route path={attrs.path} key={attrs.path}
              render={(props) => <attrs.component {...props} user={user} />}
            />
          ))}
    ...