简体   繁体   中英

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
  • 4 private pages only for unauthenticated people (login, signup, forgot password, reset password)
  • ~25+ private pages for the dashboard
  • 1 public page for dashboard presentation
  • 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
  • if authenticated and accessing routes for unauthenticated users, should be redirected to /profile without flickering)

My logic right now for dealing with 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
  • Different HoC functions inside every page like withPrivate or withPublic
  • Using getServerSideProps with redirection to login inside every page
  • nextAuth but I'm not sure because it seems like it's building a backend and we've got one already
  • _middleware that can do the redirection apparently
  • 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...

Any clue about how I should do ?

If you don't use static generation and never plan to getServerSideProps works.

If you plan to use Vercel to host, _middleware could be a good option too, however, 3rd party hosting support is lagging.

Since you already have your own auth, nextAuth doesn't seem to be a good choice.

I'd recommend using an Auth context - wrapping every page with an HOC is isn't necessary anymore.

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.

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.

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.

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

Thank you for opening the discussion around this topic. My use-case is almost exactly like the OP's example. I'm also using SSR (primarily).

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. 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.

I use:

  • cookie lib to 'parse' and 'serialize', but might switch this out with Next.js internal functions soon.
  • jose for 'SignJWT', 'jwtVerify' - latest version don't use Native Node.js API (good for middleware).

In my _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 :)

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

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