简体   繁体   English

在 Next.js 中构建仪表板:使用 JWT 身份验证将页面设为私有且不“闪烁”的最佳实践?

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

Considering that we have:考虑到我们有:

  • A backend already ready (not in Next) with authentication using JWT pattern and a home-made RBAC使用 JWT 模式和自制 RBAC 进行身份验证的后端(不在 Next 中)
  • 4 private pages only for unauthenticated people (login, signup, forgot password, reset password) 4 个私人页面仅供未经身份验证的人使用(登录、注册、忘记密码、重置密码)
  • ~25+ private pages for the dashboard约 25 个以上的仪表板私人页面
  • 1 public page for dashboard presentation 1 个用于仪表板演示的公共页面
  • Hundreds of dashboard related components (+ thousands of design system components)数百个仪表板相关组件(+ 数千个设计系统组件)

Users should:用户应该:

  • login before accessing the dashboard在访问仪表板之前登录
  • if unauthenticated and accessing private route, should be redirected to /login without flickering如果未经身份验证并访问私有路由,应重定向到/login而不会闪烁
  • if authenticated and accessing routes for unauthenticated users, should be redirected to /profile without flickering)如果通过身份验证并访问未经身份验证的用户的路由,则应重定向到/profile而不会闪烁)

My logic right now for dealing with JWT:我现在处理 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;

I've been accumulating researches about this and I found so many different answers.我一直在积累这方面的研究,我发现了很多不同的答案。 So far I found:到目前为止,我发现:

  • Provider to put in _app.js with hard-coded private routes in an array提供者将带有硬编码私有路由的_app.js放入数组中
  • Different HoC functions inside every page like withPrivate or withPublic每个页面内都有不同的 HoC 函数,例如withPrivatewithPublic
  • Using getServerSideProps with redirection to login inside every page使用带有重定向的getServerSideProps在每个页面内登录
  • nextAuth but I'm not sure because it seems like it's building a backend and we've got one already nextAuth但我不确定,因为它似乎正在构建一个后端,而我们已经有了一个
  • _middleware that can do the redirection apparently _middleware显然可以进行重定向
  • It seems like it's possible to use SWR , Suspense and Error Boundaries but I'm not sure if it's adapted for this kind of cases...似乎可以使用SWRSuspenseError Boundaries但我不确定它是否适用于这种情况......

Any clue about how I should do ?关于我应该怎么做的任何线索?

If you don't use static generation and never plan to getServerSideProps works.如果您不使用静态生成并且从不打算getServerSideProps工作。

If you plan to use Vercel to host, _middleware could be a good option too, however, 3rd party hosting support is lagging.如果您打算使用 Vercel 进行托管, _middleware也可能是一个不错的选择,但是,第 3 方托管支持滞后。

Since you already have your own auth, nextAuth doesn't seem to be a good choice.由于您已经拥有自己的身份验证, nextAuth似乎不是一个好的选择。

I'd recommend using an Auth context - wrapping every page with an HOC is isn't necessary anymore.我建议使用 Auth 上下文 - 不再需要用 HOC 包装每个页面。

You can prevent the flickering by using a loading screen or spinner.您可以使用加载屏幕或微调器来防止闪烁。


Here is an example of an auth context.这是一个身份验证上下文的示例。

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>
  );
};

I deleted a bunch of code so it probably doesn't work direct copy and paste but you can get the idea.我删除了一堆代码,所以它可能无法直接复制和粘贴,但你可以明白。

isAuthRoute only needs to return true/false so another good options if your dashboard routes all start with /dashboard you can use asPath.startsWith('/dashboard') instead. isAuthRoute只需要返回 true/false,因此如果您的仪表板路由都以/dashboard开头,则另一个不错的选择可以使用asPath.startsWith('/dashboard')代替。

We conditionally rendered the auth context because it's not needed on public pages and it also clears the user context on logout so we don't need to worry about leaking old user data via context.我们有条件地渲染了 auth 上下文,因为它在公共页面上不需要,它还会在注销时清除用户上下文,因此我们不必担心通过上下文泄露旧的用户数据。

We also use local storage and the window's broadcast channel to listen for logout calls.我们还使用本地存储和窗口的广播通道来监听注销调用。 This allows us to log the user out in every tab and window.这允许我们在每个选项卡和窗口中注销用户。

After a lot of tests with different techniques, I decided to go for Next.js new middleware incredible feature.经过大量不同技术的测试后,我决定使用 Next.js 新的中间件令人难以置信的功能。

If anybody struggles with this topic like I did, here is my code:如果有人像我一样在这个话题上苦苦挣扎,这是我的代码:

// _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)
  );
}

Be careful though as this file name and location will change in new Next.js 12.2 version, under the name of middleware.js (or .ts ) in your root folder, whether it's root or src depending on your configuration请注意,因为此文件名和位置将在新的 Next.js 12.2版本中更改,在您的根文件夹中的middleware.js (或.ts )的名称下,无论是根目录还是src取决于您的配置

Thank you for opening the discussion around this topic.感谢您围绕该主题展开讨论。 My use-case is almost exactly like the OP's example.我的用例几乎与 OP 的示例完全相同。 I'm also using SSR (primarily).我也在使用 SSR(主要是)。

I have also gone through numerous pitfalls and challenges in getting it to work, but the build failed due to 'jsonwebtoken' lib having the eval error in middleware.在让它工作时,我也经历了许多陷阱和挑战,但由于 'jsonwebtoken' lib 在中间件中存在 eval 错误,构建失败。 From there I used the 'cookies-next' lib, but with Next.js v12.2.0 having integrated the cookies.get() (cookies: NextCookies) in the NextRequest, I managed to work with that rather.从那里我使用了“cookies-next”库,但是 Next.js v12.2.0 在 NextRequest 中集成了 cookies.get()(cookies:NextCookies),我设法使用它。

I use:我用:

  • cookie lib to 'parse' and 'serialize', but might switch this out with Next.js internal functions soon. cookie lib 来“解析”和“序列化”,但很快可能会使用 Next.js 内部函数将其切换出来。
  • jose for 'SignJWT', 'jwtVerify' - latest version don't use Native Node.js API (good for middleware). jose for 'SignJWT', 'jwtVerify' - 最新版本不使用 Native Node.js API(适用于中间件)。

In my _app > getInitialProps :在我的_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;
};

I have a helper file:我有一个帮助文件:

// 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;
    }
}

My use-case is to "force" a user login, before being able to navigate the site.我的用例是在能够浏览网站之前“强制”用户登录。 I'm just struggling to manage the logic in my middleware, even when using the examples given in this post.我只是在努力管理我的中间件中的逻辑,即使使用本文中给出的示例也是如此。 I think I'm still not handling the data flow correctly as it does not prevent any navigation or redirect as wanted.我认为我仍然没有正确处理数据流,因为它不会阻止任何导航或重定向。

I hope this example has some information to your current use-case and how to approach it :)我希望这个示例对您当前的用例以及如何处理它有一些信息:)

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM