Day16 - 在 Next.js 做 JWT 验证,使用既有的 Backend API - PART 2

在页面中串接验证 API

在前一篇文章中,我们建立了一个 JWT JSON server,用来练习如何在 Next.js 中串接 JWT 验证,这个 JSON server 提供 POST /auth/login 的登入 API,以及 GET /productsGET /products/:id 两个能够取得产品资料的 API,而两个产品资料 API 都必须在 header 中带上 accessToken ,否则 JSON server 会回传 HTTP 401,阻挡使用者获得产品资讯。

JWT JSON server 的 repo: https://github.com/leochiu-a/fake-api-jwt-json-server

在 Next.js 中设定验证逻辑主要是放在 API routes 中,使用 NextAuth 建立客制化的验证流程,当使用者呼叫 NextAuth 提供的 signIn 时便会出发验证流程,验证成功後会得到一个 accessToken ,这个 accessToken 必须在打产品资料 API 时带上。

现在我们已经实作完了 API routes 的部分,接下来要继续实作在页面中的逻辑。我们接着要实作的逻辑很单纯,目标是使用者登入後可以成功浏览「产品列表页面」与「产品详细页面」,这两个页面在前面的章节中已经用了很多次,这次同样也是拿这两个页面来练习。

登入页面

LoginForm 的样式 https://gist.github.com/leochiu-a/62d2e9dce4d1a8b09905f35ca8bf4a8a

首先,我们要来撰写一个登入的页面 pages/login/index.tsx ,在这个页面中会有一个 form 表单,表单中包含了使用者的 emailpassword 两个栏位,以及一个登入按钮,点击之後会触发 NextAuth 的验证流程,验证成功後转址到产品列表页面 /products

import { useState, SyntheticEvent } from "react";
import { useRouter } from "next/router";
import { signIn } from "next-auth/client";

import {
  AuthSection,
  Login,
  ControlItem,
  ControlLable,
  ControlInput,
  SubmitButtonWrapper,
  SubmitButton,
} from "./index.style";

const LoginForm = () => {
  // 使用者的状态与登入逻辑...

  return (
    <AuthSection>
      <Login>Login</Login>
      <form onSubmit={handleSubmit}>
        <ControlItem>
          <ControlLable htmlFor="email">Your Email</ControlLable>
          <ControlInput
            type="email"
            id="email"
            required
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </ControlItem>
        <ControlItem>
          <ControlLable htmlFor="password">Your Password</ControlLable>
          <ControlInput
            type="password"
            id="password"
            required
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
        </ControlItem>
        <SubmitButtonWrapper>
          <SubmitButton type="submit">Login</SubmitButton>
        </SubmitButtonWrapper>
      </form>
    </AuthSection>
  );
};

export default LoginForm;

<LoginForm /> 中管理使用者的状态与登入的逻辑也很单纯,使用两个 useState 分别储存使用者的 emailpassword ,建立一个 onSubmit 的 callback function,在这个 function 中会呼叫 NextAuth 提供的 signIn() ,并指定是客制化的验证流程 credentials ,匹配的是 NextAuth 在 API routes 设定的 Providers.Credentials

在验证成功後,使用 useRouter() 转址到产品列表页面 /products

import { useState, SyntheticEvent } from "react";
import { useRouter } from "next/router";
import { signIn } from "next-auth/client";

import {
  AuthSection,
  Login,
  ControlItem,
  ControlLable,
  ControlInput,
  SubmitButtonWrapper,
  SubmitButton,
} from "./index.style";

const LoginForm = () => {
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");

  const router = useRouter();

  const handleSubmit = async (event: SyntheticEvent) => {
    event.preventDefault();

    const result = await signIn("credentials", {
      redirect: false,
      email,
      password,
    });

    if (result?.ok) {
      router.push("/products");
    }
  };

  // return component
};

export default LoginForm;

在产品列表页面使用 SSR + JWT 取得资料

在这个页面中的逻辑与在前面章节中看到的大同小异,比较不一样的是在 getServerSideProps 中的逻辑。在使用者登入後,如果想要取得 accessToken 则可以使用 NextAuth 提供的 getSession()这个 function 必须带入由 getServerSideProps 的参数 ctx ,这样才能取得使用者的验证讯息。

然後在取得 accessToken 後,要在 fetchheaders 中设定 JWT 的验证讯息,否则打产品列表 API 服务器会回传 HTTP 401,禁止我们取得资料。最後,成功取得资料後,把 products 当作 props 传入到 component 中,现在应该可以顺利看到产品列表页面。

import { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/client";

import ProductCard from "../../components/ProductCard";
import { Product } from "../../fake-data";
import { PageTitle, ProductGallery } from "./index.style";

interface HomeProps {
  products: Product[];
}

const Home = ({ products }: HomeProps) => {
  return (
    <>
      <PageTitle>商品列表</PageTitle>
      <ProductGallery>
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </ProductGallery>
    </>
  );
};

export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  const session = await getSession(ctx);

  const res = await fetch(`http://localhost:8000/products`, {
    headers: {
      Authorization: `Bearer ${session?.accessToken}`,
    },
  });

  const products = await res.json();

  return {
    props: {
      products,
      session,
    },
  };
}

export default Home;

使用 Context 优化取得 session 的方式

根据 NextAuth 的说明,我们可以在 /pages/__app.ts 中新增 context provider,这样做可以提升取得 session 的效率,例如从 useSession 的原始码中就可以看到,它会先尝试从 context 中取得 session,如果没有 context 再走类似 getSession 的流程,会自动打 /session API 从服务器中取的验证资讯。

由此可知,没有 Provider 的设定是会在切换页面时,如果页面中刚好有 useSession ,会让页面多打很多次 /session API,造成每次使用者都必须等待一段时间後才看得到内容。

import { AppProps } from "next/app";
import { Provider } from "next-auth/client";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <Provider session={pageProps.session}>
      <Component {...pageProps} />
    </Provider>
  );
}

export default MyApp;

但是,实际上 Provider 有作用的前提是一个页面是必须是 SSR,也就是使用 Next.js 的 getServerSidePropsgetInitialProps ,读者也可以从上述中看到 session 是透过 pageProps.session 取得,所以如果一个页面不是 SSR,就不能得到 context API 的帮助, useSession 还是会照常打 API。

export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  return { session: await getSession(ctx) };
}

// 或者
Page.getInitialProps = async (ctx) => {
  return { session: await getSession(ctx) };
};

在产品列表页面使用 SWR + JWT 取得资料

最後,我们要来实作「产品详细页面」中的逻辑,在这个页面中我们使用的是 client-side rendering,并且使用到 useSWR 这个 API。

基本上逻辑与前面提到 CSR 的实作差不多,只差在我们会用到 useSession 取得使用者验证资讯,将 accessToken 带入到 fetch 的 header 中。

这边要特别注意的是,我们传入到 fetcher 中的数值变成是一个物件,这个物件包含 idaccessToken ,而 useSWR 触发 fetcher 的时机是看传入的第一个参数 key 是否改变,而如果每次渲染时 params 的记忆体位置都不同,将会导致 key 改变,最终导致 API 被无限次呼叫。

所以,为了解决这个问题要使用 useMemoparams 记忆起来,再重新渲染时不会造成 useSWR 传入的第一次参数记忆体位置改变。

import { useMemo } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { useSession } from "next-auth/client";
import useSWR from "swr";

import { Product as ProductType } from "../../fake-data";
import ProductCard from "../../components/ProductCard";
import { PageTitle, ProductContainer, BackLink } from "./[id].style";

type Params = {
  id: string;
  accessToken: string;
};

const fetcher = (url: string, { id, accessToken }: Params) => {
  return fetch(`http://localhost:8000${url}/${id}`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  }).then((res) => res.json());
};

const Product = () => {
  const router = useRouter();
  const { id } = router.query;
  const [session, loading] = useSession();

  const params = useMemo(
    () => ({ id, accessToken: session?.accessToken }),
    [id, session]
  );

  const { data: product, error } = useSWR<ProductType>(
    id && !loading ? ["/products", params] : null,
    fetcher
  );

  if (!product || error) return <div>loading</div>;

  return (
    <>
      <PageTitle>商品详细页面</PageTitle>
      <BackLink>
        <Link href="/products">回产品列表</Link>
      </BackLink>
      <ProductContainer>
        <ProductCard product={product} all />
      </ProductContainer>
    </>
  );
};

export default Product;

Reference


<<:  【Day20】[资料结构]-图Graph-实作

>>:  网路是怎样连接的(六)TCP的交互(上)

【Day 04】String Methods

前言 今天要来介绍 string Methods,可以把 string 进行各种处理,来做出你想做的...

OpenStack 介绍 2

本系列文章同步发布於笔者网站 前一篇文章以比较非技术角度介绍了 OpenStack 这个专案。今天开...

#17 No-code 之旅 — 专案架构

今天先建立专案,还有开始慢慢地定架构,装 dependencies ~ Setup 这专案想要用 N...

Day26 Android - datepicker+timepicker(日期+时间选择器)

在某些应用来说,有时会看的到这些的工具,像是预约、或是创立帐号时要输入的生日等,都算是好用的一个小功...

卡夫卡的藏书阁【Book13】- KafkaJS 生产者 1

“Slept, awoke, slept, awoke, miserable life.” ― f...