[NextJs] middleware

Darcy Daeseok YU ·2026년 4월 2일
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import type { MenuListResponse } from '@/api/menu/types';
import type { UserResponse } from '@/api/auth/types';
import { getCachedMenuPaths, setCachedMenuPaths } from '@/lib/menuCache';
import { buildMenuFullPaths } from '@/utils/menu';

import {
  AUTH_ONLY_PATHS,
  PERMISSION_REQUIRED_PATHS,
  PUBLIC_PATHS,
} from '@/constants/authMiddleware';

export const isPublicPath = (path: string) =>
  PUBLIC_PATHS.has(path) ||
  path.includes('.') ||
  path.startsWith('/.well-known');

export const normalizePath = (path: string): string =>
  path.replace(/\/+$/, '') || '/';

const fetchMenuItems = async (
  token: string
): Promise<MenuListResponse | null> => {
  const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
  if (!baseUrl) return null;
  try {
    const response = await fetch(`${baseUrl}/api/menus`, {
      method: 'GET',
      headers: {
        Accept: 'application/json',
        Authorization: `Bearer ${token}`,
      },
      cache: 'no-store',
    });
    const json = await response.json();
    return json.data as MenuListResponse;
  } catch (error) {
    console.error('Failed to fetch menus:', error);
    return null;
  }
};

const fetchUserInfo = async (
  token: string
): Promise<UserResponse | null> => {
  const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
  if (!baseUrl) return null;
  try {
    const response = await fetch(`${baseUrl}/api/users/me`, {
      method: 'GET',
      headers: {
        Accept: 'application/json',
        Authorization: `Bearer ${token}`,
      },
      cache: 'no-store',
    });
    const json = await response.json();
    return json.data as UserResponse;
  } catch (error) {
    console.error('Failed to fetch user info:', error);
    return null;
  }
};

/**
 * Redirect authenticated users away from login/signup pages
 */
const handleAuthenticatedAuthPageRedirect = (
  path: string,
  token: string | undefined,
  request: NextRequest
) => {
  if ((path === '/login' || path === '/signup') && token) {
    return NextResponse.redirect(new URL('/', request.url));
  }
  return null;
};

/**
 * Redirect unauthenticated users to login
 */
const handleMissingTokenRedirect = (
  token: string | undefined,
  request: NextRequest
) => {
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return null;
};

/**
 * Check permission-required paths
 */
const checkPermissionRequiredPath = async (
  path: string,
  token: string
): Promise<NextResponse | null> => {
  const requiredPermission = PERMISSION_REQUIRED_PATHS.get(path);
  if (!requiredPermission) {
    return null;
  }

  const userInfo = await fetchUserInfo(token);
  if (!userInfo || !userInfo.roles.includes(requiredPermission)) {
    return NextResponse.redirect(new URL('/404', undefined));
  }

  return null;
};

/**
 * Get menu paths from cache or fetch from API
 */
const getOrFetchMenuPaths = async (
  token: string,
  request: NextRequest
): Promise<Set<string> | NextResponse> => {
  let menuPaths = getCachedMenuPaths(token);
  if (menuPaths) {
    return menuPaths;
  }

  const menuResponse = await fetchMenuItems(token);
  if (!menuResponse) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  menuPaths = buildMenuFullPaths(menuResponse.items);

  setCachedMenuPaths(token, menuPaths);
  return menuPaths;
};

/**
 * Check if the user has access to the requested menu path
 */
const checkMenuPathAccess = (
  normalizedPath: string,
  menuPaths: Set<string>,
  request: NextRequest
): NextResponse | null => {
  if (!menuPaths.has(normalizedPath) && !AUTH_ONLY_PATHS.has(normalizedPath)) {
    return NextResponse.redirect(new URL('/404', request.url));
  }

  return null;
};

export async function middleware(request: NextRequest) {
  const path = request.nextUrl.pathname;
  const token = request.cookies.get('session_token')?.value;

  // 1. Redirect authenticated users away from auth pages
  const authPageRedirect = handleAuthenticatedAuthPageRedirect(
    path,
    token,
    request
  );
  if (authPageRedirect) return authPageRedirect;

  // 2. Allow public paths
  if (isPublicPath(path)) {
    return NextResponse.next();
  }

  try {
    // 3. Redirect to login if no token
    const missingTokenRedirect = handleMissingTokenRedirect(token, request);
    if (missingTokenRedirect) return missingTokenRedirect;

    const normalizedPath = normalizePath(path);

    // 4. Check permission-required paths
    const permissionError = await checkPermissionRequiredPath(
      normalizedPath,
      token!
    );
    if (permissionError) return permissionError;

    // 5. Fetch menu paths
    const menuPathsOrRedirect = await getOrFetchMenuPaths(token!, request);
    if (menuPathsOrRedirect instanceof NextResponse) {
      return menuPathsOrRedirect;
    }
    const menuPaths = menuPathsOrRedirect;

    // 6. Check menu path access
    const menuAccessError = checkMenuPathAccess(
      normalizedPath,
      menuPaths,
      request
    );
    if (menuAccessError) return menuAccessError;
  } catch (error) {
    console.error('Auth check failed:', error);
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico|assets|.*\\..*|$).*)',
  ],
};
profile
React, React-Native https://darcyu83.netlify.app/

0개의 댓글