当前位置: 首页 > 資訊 >

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