簡體   English   中英

在 Next.js 中構建儀表板:使用 JWT 身份驗證將頁面設為私有且不“閃爍”的最佳實踐?

[英]Building a dashboard in Next.js : best practices to make pages private with roles, without "flickering", using JWT authentication?

考慮到我們有:

  • 使用 JWT 模式和自制 RBAC 進行身份驗證的后端(不在 Next 中)
  • 4 個私人頁面僅供未經身份驗證的人使用(登錄、注冊、忘記密碼、重置密碼)
  • 約 25 個以上的儀表板私人頁面
  • 1 個用於儀表板演示的公共頁面
  • 數百個儀表板相關組件(+ 數千個設計系統組件)

用戶應該:

  • 在訪問儀表板之前登錄
  • 如果未經身份驗證並訪問私有路由,應重定向到/login而不會閃爍
  • 如果通過身份驗證並訪問未經身份驗證的用戶的路由,則應重定向到/profile而不會閃爍)

我現在處理 JWT 的邏輯:

// lib/axios.js

import Axios from 'axios';
import { getCookie, removeCookies } from 'cookies-next';
import qs from 'qs';

export const axios = Axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  paramsSerializer: (params) =>
    qs.stringify(params, { arrayFormat: 'brackets' }),
  withCredentials: true,
});

axios.interceptors.request.use(
  (config) => {
    const token = getCookie('access_token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
      config.headers.Accept = 'application/json';
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response.status === 401) {
      // we need to implement refresh pattern
      // const refresh = await refreshToken();
      removeCookies('access_token');
    }
    return Promise.reject(error);
  }
);

// For SWR
export const fetcher = async (...args) => await axios.get(...args).data;

我一直在積累這方面的研究,我發現了很多不同的答案。 到目前為止,我發現:

  • 提供者將帶有硬編碼私有路由的_app.js放入數組中
  • 每個頁面內都有不同的 HoC 函數,例如withPrivatewithPublic
  • 使用帶有重定向的getServerSideProps在每個頁面內登錄
  • nextAuth但我不確定,因為它似乎正在構建一個后端,而我們已經有了一個
  • _middleware顯然可以進行重定向
  • 似乎可以使用SWRSuspenseError Boundaries但我不確定它是否適用於這種情況......

關於我應該怎么做的任何線索?

如果您不使用靜態生成並且從不打算getServerSideProps工作。

如果您打算使用 Vercel 進行托管, _middleware也可能是一個不錯的選擇,但是,第 3 方托管支持滯后。

由於您已經擁有自己的身份驗證, nextAuth似乎不是一個好的選擇。

我建議使用 Auth 上下文 - 不再需要用 HOC 包裝每個頁面。

您可以使用加載屏幕或微調器來防止閃爍。


這是一個身份驗證上下文的示例。

import { useEffect, useState, useCallback, createContext } from "react";
import { useRouter } from "next/router";

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const { asPath, push } = useRouter();
  const [user, setUser] = useState(null);
  // if you use trailing slash you'd need to add them to each route
  const isAuthRoute = ["/login", "/signup", "/forgot", "/reset"].includes(asPath);

  const redirectToLogin = useCallback(async () => {
    try {
      setUser(null);
      await push('/login');
    } catch (e) {
      console.error("Could not redirect to login");
    }
  }, [push]);

  const signOut = async () => {
    try {
      await authServiceSignout();
      await redirectToLogin();
    } catch {
      window.location.reload();
    }
  };

  const goHome = useCallback(() => push('/'), [push]);

  // check if user is logged in on every route changes and
  // redirect accordingly
  useEffect(() => {
    const getUser = async () => {
      const user = await getUserOrJWT();
      if (user) {
        setUser(user); // user details
        if (isAuthRoute) await goHome();
      } else if (!isAuthRoute) {
        await redirectToLogin();
      }
    };
    getUser();
  }, [asPath, goHome, isAuthRoute, redirectToLogin]);

  if (!user && isAuthRoute) return <>{children}</>;
  if (!user) return <>Loading or loading spinner</>;

  return (
    <AuthContext.Provider value={{ user, signOut }}>
      {children}
    </AuthContext.Provider>
  );
};

我刪除了一堆代碼,所以它可能無法直接復制和粘貼,但你可以明白。

isAuthRoute只需要返回 true/false,因此如果您的儀表板路由都以/dashboard開頭,則另一個不錯的選擇可以使用asPath.startsWith('/dashboard')代替。

我們有條件地渲染了 auth 上下文,因為它在公共頁面上不需要,它還會在注銷時清除用戶上下文,因此我們不必擔心通過上下文泄露舊的用戶數據。

我們還使用本地存儲和窗口的廣播通道來監聽注銷調用。 這允許我們在每個選項卡和窗口中注銷用戶。

經過大量不同技術的測試后,我決定使用 Next.js 新的中間件令人難以置信的功能。

如果有人像我一樣在這個話題上苦苦掙扎,這是我的代碼:

// _middleware.js in /pages, works also with Typescript .ts
import { NextResponse } from 'next/server';
import { isAuthValid } from '@/lib/auth'

export function middleware(req) {
  if (
    req.nextUrl.pathname.startsWith('/login') ||
    req.nextUrl.pathname.startsWith('/signup') ||
    req.nextUrl.pathname.startsWith('/forgot') ||
    req.nextUrl.pathname.startsWith('/reset')
  ) {
    if (isAuthValid(req)) {
      return NextResponse.redirect(new URL('/profile', req.url));
    }
    return NextResponse.next();
  }

  // All other routes
  if (isAuthValid(req)) {
    return NextResponse.next();
  }
  return NextResponse.redirect(
    new URL(`/login?from=${req.nextUrl.pathname}`, req.url)
  );
}

請注意,因為此文件名和位置將在新的 Next.js 12.2版本中更改,在您的根文件夾中的middleware.js (或.ts )的名稱下,無論是根目錄還是src取決於您的配置

感謝您圍繞該主題展開討論。 我的用例幾乎與 OP 的示例完全相同。 我也在使用 SSR(主要是)。

在讓它工作時,我也經歷了許多陷阱和挑戰,但由於 'jsonwebtoken' lib 在中間件中存在 eval 錯誤,構建失敗。 從那里我使用了“cookies-next”庫,但是 Next.js v12.2.0 在 NextRequest 中集成了 cookies.get()(cookies:NextCookies),我設法使用它。

我用:

  • cookie lib 來“解析”和“序列化”,但很快可能會使用 Next.js 內部函數將其切換出來。
  • jose for 'SignJWT', 'jwtVerify' - 最新版本不使用 Native Node.js API(適用於中間件)。

在我的_app > getInitialProps

// _app.tsx

MyApp.getInitialProps = async (appContext: AppContext) => {
    const appProps = await App.getInitialProps(appContext);
    let customerData = {};
    if (!!appContext.ctx.req) {
        /*
         * Expose the cookie to the page props of all the pages
         * for client-side auth datafetching
         */
        const cookie = parse(appContext.ctx.req.headers.cookie || '');
        appProps.pageProps[JWT_COOKIE_NAME] = cookie[JWT_COOKIE_NAME];
        /*
         * If the user is logged in, we'll verify the JWT and get the user data
         */
        const userJWT = await verifyAuthCookieOnReload(appContext.ctx);
        if (userJWT) {
            const { id, token } = userJWT;
            customerData = await UserService.getCustomer(id, token);
        }
    }

    const props = { ...appProps, customerData };

    return props;
};

我有一個幫助文件:

// eslint-disable-next-line @next/next/no-server-import-in-page
import type { NextRequest } from 'next/server';
import type { NextPageContext } from 'next';
import { nanoid } from 'nanoid';
import { parse, serialize } from 'cookie';
import { SignJWT, jwtVerify, JWTPayload } from 'jose';
import { JWT_SECRET, JWT_COOKIE_NAME } from '@constants/env';

export type UserJWTPayload = JWTPayload & JwtUserData & {
    jti: string;
    iat: number;
};

export type JwtUserData = {
    id: string;
    token: string;
};

export const setUserJWTCookie = async (userData: JwtUserData | {}, logoutTime?: number) => {
    const time = Math.floor(Date.now() / 1000);
    let expireTime = 60 * 60 * 24 * 14; // 14 days
    if (logoutTime) {
        expireTime = logoutTime; // override for logout "-1"
    }
    const token = await new SignJWT(userData)
        .setProtectedHeader({ alg: 'HS256' })
        .setJti(nanoid())
        .setIssuedAt()
        .setExpirationTime(time + expireTime)
        .sign(new TextEncoder().encode(JWT_SECRET));

    return serialize(JWT_COOKIE_NAME, token, {
        httpOnly: true,
        secure: process.env.NODE_ENV !== 'development',
        sameSite: 'strict',
        maxAge: expireTime,
        path: '/'
    });
};

export const verifyAuthCookie = async (request: NextRequest): Promise<UserJWTPayload | undefined> => {
    const token = request.cookies.get(JWT_COOKIE_NAME);
    return await verify(token);
}

export const verifyAuthCookieOnReload = async (context: NextPageContext): Promise<UserJWTPayload | undefined> => {
    const cookie = parse(context?.req?.headers.cookie || '');
    const token = cookie[JWT_COOKIE_NAME];
    return await verify(token);
};

export const verify = async (token: string | undefined) => {
    if (!token) {
        return undefined;
    }
    try {
        const { payload } = await jwtVerify(token, new TextEncoder().encode(JWT_SECRET));
        return payload as UserJWTPayload;
    } catch (err) {
        return undefined;
    }
}

我的用例是在能夠瀏覽網站之前“強制”用戶登錄。 我只是在努力管理我的中間件中的邏輯,即使使用本文中給出的示例也是如此。 我認為我仍然沒有正確處理數據流,因為它不會阻止任何導航或重定向。

我希望這個示例對您當前的用例以及如何處理它有一些信息:)

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM