繁体   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