Next.js Middleware: від простого захисту до гнучкої системи доступу

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт! Мене звати Костянтин Клюхін, я Frontend-розробник у бізнесі FORMA, що входить до групи компаній Universe Group. За понад три роки у веброзробці я пройшов шлях від класичного фронтенду на React до повноцінного фулстеку, заглибившись у створення складних рішень та оптимізацію продуктивності застосунків.

Окрему увагу приділяв Next.js: будував архітектуру проєктів, проводив лекції для колег і допомагав розв’язувати нетривіальні технічні задачі.

Сьогодні хочу поділитися практикою, яка суттєво спростила роботу як мені, так і команді. Вона дозволяє швидко й гнучко змінювати логіку доступу до роутів, покращує UX і легко адаптується під нестандартні вимоги замовників.

Що таке middleware в Next.js

Давайте розберемо основи middleware в Next.js:

  • Middleware — це просто функція.
  • Middleware виконується на edge-серверах, ближче до користувача. Тут потрібно зазначити, що це працює при хостингу на vercel і там це може потребувати додаткових грошових витрат, а в інших хостинг-сервісах потребує додаткового налаштування.
  • Виконується для вказаних маршрутів — ви вирішуєте, для яких маршрутів виконується middleware та задаєте це за допомогою конфігурації.
  • Виконується до завантаження сторінки — middleware виконується до того, як користувач отримує сторінку.
  • Приймає запит — він приймає об’єкт GET-запиту на ресурс як параметр.
  • Не впливає на спосіб рендерингу — найголовніше, middleware не впливає на вид рендерингу сторінки.

Наступна схема наочно показує, як працює middleware на мережевому рівні та для чого потрібно встановлювати його саме на edge-сервери:

Проблема

Уявіть, що у вас є сторінка «Мій профіль», яка повинна бути доступною лише для автентифікованих користувачів. Якщо хтось не ввійшов у систему, ми хочемо перенаправити його на сторінку входу.

Як ми вирішимо це? Перевірка токена в локальному сховищі або сесії NextAuth на стороні клієнта може здатися простим варіантом, але ось у чому проблема:

  • Погіршення досвіду користувача — неавторизованому користувачу потрібно чекати, поки сторінка завантажиться і виконається код на клієнті, перш ніж відбудеться перенаправлення. Під час цієї затримки він бачить стан завантаження, що є неприємним. Навіть авторизовані користувачі можуть бачити цей стан завантаження без причини, що призводить до неприємного досвіду.
  • Проблеми з бандлом та ефективністю — використання перевірок на стороні клієнта означає додавання коду на всі приватні сторінки. Цей підхід може працювати в простих застосунках, але стає неефективним, якщо необхідні додаткові бібліотеки, що збільшує розмір бандлу.

Альтернативно ви можете подумати про обробку перевірки сесії на серверній стороні клієнта. Хоча це може спростити процес, це змусить кожну захищену сторінку стати динамічною, тобто змушуватиме сервер рендерити сторінку наново на кожен запит цієї сторінки. Якщо це не ваша мета, це може стати проблемою.

Тепер уявіть, якщо був би спосіб виявити автентифікацію користувача, як тільки він запитує сторінку, і перенаправити його на сторінку входу без впливу на вид рендерингу. Ось де з’являється ефективне рішення!

Реалізація

Як тільки я відкрив для себе можливості middleware, я одразу інтегрував його у свій проєкт. Але перед тим, як зануритися в код, уточнімо кілька важливих моментів. Бібліотека NextAuth зручно поміщає всю релевантну інформацію з токена безпосередньо в об’єкт req, що дозволяє нам без проблем отримати доступ до нього через req.nextauth.token.

Але якщо ви не використовуєте цю бібліотеку, то ви так само можете самі дістати токен з хедерів або кукі і розпарсити його. Це означає, що ми можемо отримати не тільки дані автентифікації, а й ролі користувачів і статуси підписки.

Тепер подивимося на мою першу реалізацію такого middleware:

import { withAuth } from  "next-auth/middleware"; 

const authRoutes = [

  AppRoutes.signIn, 

  AppRoutes.signUp, 

  AppRoutes.forgotPassword

]; 

export  default  withAuth( 

  async  function  middleware(req) { 

    const user = req.nextauth.token?.user;

    const isSigned = Boolean(user);   

    const isResetPasswordRoute = req.nextUrl.pathname.startsWith(

      AppRoutes.resetPassword("")

    );

    const isAuthRoute = authRoutes.includes(req.nextUrl.pathname) || isResetPasswordRoute; 

    const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");

    const isSubscriptionPlansRoute = req.nextUrl.pathname === AppRoutes.subscriptionPlans;

    const isAdminUser = req.nextauth.token?.user.role === USER_ROLE.ADMIN;  

    const accountStatus = req.nextauth.token?.user.accountStatus ?? "";  

    const isPaid = accountStatusesWithAppAccess.includes(accountStatus) || isAdminUser; 

    const hasSubscription =

      accountStatusesThatHasSubscription.includes(accountStatus) || isAdminUser; 

    const isMustGoPlansRoute = !hasSubscription && isSigned; 

    const isRenewSubscriptionRoute = req.nextUrl.pathname === AppRoutes.renewSubscription;  

    const mustGoRenewSubscription = hasSubscription && !isPaid && isSigned;

     

    if (mustGoRenewSubscription && !isRenewSubscriptionRoute) { 

      return  NextResponse.redirect(new  URL(AppRoutes.renewSubscription, req.url));

    } 

    

    if (!mustGoRenewSubscription && isRenewSubscriptionRoute) { 

      const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn;   

      

      return  NextResponse.redirect(new  URL(redirectRoute, req.url));

    } 

    

    if (!isSubscriptionPlansRoute && isMustGoPlansRoute) { 

      return  NextResponse.redirect(new  URL(AppRoutes.subscriptionPlans, req.url));

    } 

    

    if (isSubscriptionPlansRoute && !isMustGoPlansRoute) { 

      const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn; 

      

      return  NextResponse.redirect(new  URL(redirectRoute, req.url));

    } 

    if (isAuthRoute && isSigned && isPaid) { 

      return  NextResponse.redirect(new  URL(AppRoutes.main, req.url));

    } 

    if (!isSigned && !isAuthRoute) { 

      return  NextResponse.redirect(new  URL(AppRoutes.signIn, req.url));

    }

    

    if (isAdminRoute && !isAdminUser) {

     const redirectRoute = isSigned ? AppRoutes.main : AppRoutes.signIn;  

     return  NextResponse.redirect(new  URL(redirectRoute, req.url))

    }

  },

  { 

    secret: envServer.NEXTAUTH_SECRET, 

    callbacks: { 

      authorized: () =>  true,

    },

  }

)

О, виглядає не дуже...

Я це, звісно, розумію, і бачу, що потрібне поліпшення.

Просунутий підхід

Натхнення для реалізації, яку я збираюсь показати, до мене прийшло з Vue.js, тож вирішив створити щось подібне. Я думав про те, який найбільш зручний спосіб обробити, чи можна давати доступ користувачу до ресурсу, чи ні. Іноді умови для кількох маршрутів однакові, але іноді маршрут має ті самі умови, що й інший, але з якоюсь додатковою умовою. Також деякі умови мають більший пріоритет, тому рішення повинно обробляти й такі випадки.

Було б чудово визначити умови для кожного маршруту окремо. Спробуємо зробити це.

Насамперед створимо бажану структуру. Я віддаю перевагу масиву об’єктів з маршрутом і умовою, щось на кшталт цього.

[

  { route: "/onboarding", condition: (token: JWT, url: string) => {} },

  { route: "/profile", condition: (token: JWT, url: string) => {} }

]

Добре, це працює, але що ця умова повинна повертати?

Якщо умова потребує редіректу користувача, вона повинна повернути редірект. В іншому випадку просто повернемо null.

[

  { 

    route: "/onboarding", 

    condition: (token: JWT, url: string) => { 

      if (!token?.user) return  null;

       

      return  NextResponse.redirect(new  URL("/sign-in", url));

    }

  },

  { 

    route: "/profile", 

    condition: (token: JWT, url: string) => { 

      if (!token?.user) return  null; 

      return  NextResponse.redirect(new  URL("/sign-in", url));    

    }

  },

]

Чудово! Це працює, але маємо дві проблеми:

  1. Як ми будемо це все запускати?
  2. Умови дублюються.

Почнемо з першої:

import type { NextRequestWithAuth } from  "next-auth/middleware";

export type RouteConfig = { 

  condition: (token: JWT) => NextResponse<any> | null; 

  url: string;

};

export  function  runRoutesMiddleware( 

  req: NextRequestWithAuth, 

  config: RouteConfig[] 

): NextResponse<any> | null { 

  const currentRouteConfig = config.find(

    (route) => matchPath(route?.url, props?.nextUrl)

  );

  

  if (!currentRouteConfig) return  null;

  

  return currentRouteConfig.condition(req.token);

} 

export  default  withAuth( 

  async  function  middleware(req) { 

    return  runRoutesMiddleware(req, routesRulesConfig);

  },

  { 

    secret: env.NEXTAUTH_SECRET, 

    callbacks: { 

      authorized: () =>  true,

    },

  }

);

Тут я використовую невеликий хак під назвою «matchPath». Це така ж функція, яку ми маємо в React Router, але вона повертає boolean, якщо URL збігається з шаблоном.

Розв’язання проблеми з дублюванням умов

Давайте розглянемо, як уникнути дублювання умов у нашій конфігурації. Замість того, щоб кожного разу прописувати однакові умови для кожного маршруту, ми можемо створити набір маленьких функцій, що виконують перевірки (назвемо їх «rules»), які можна комбінувати для кожного маршруту.

1. Створимо rules (умови), що перевіряють доступ до маршруту, наприклад, перевірка аутентифікації.

type Rule = (token: JWT) => NextResponse<any> | null;

 

export  const  isAuthenticatedRule: Rule = (token, url) => { 

  if (!token?.user) return NextResponse.redirect(new  URL("/sign-in", url)); 

  // Якщо користувач автентифікований, то нічого не робимо

  return  null; 

 }

2. Тепер використаємо ці правила у конфігурації маршруту:

const routesRulesConfig = [

  { route: "/onboarding", rules: [isAuthenticatedRule] },

  { route: "/profile", rules: [isAuthenticatedRule] },

];

3. Тепер, щоб застосувати всі правила, створимо функцію, яка виконує їх по черзі, поки не отримає відповідь. Якщо жодне правило не повернуло відповідь, ми не виконуватимемо редірект, тобто дозволяємо доступ до ресурсу.

export  function  executeRules(

  rules: Rule[],

  url: string, 

  token: JWT,

  ruleIndex: number = 0 

): ReturnType<Rule> | void { 

  if (ruleIndex > (rules?.length || 0) - 1) return; 

  

  const result = rules?.[ruleIndex]?.(token, url); 

  

  if (!result) { 

    return  executeRules(rules, url, token, ruleIndex + 1);

  } else { 

    return result;

  }

}

4. Тепер налаштуємо основну функцію для виконання middleware, яка використовує цю логіку. Функція runRoutesMiddleware знаходить відповідний маршрут і виконує умови для цього маршруту:

export  function  runRoutesMiddleware( 

  req: NextRequestWithAuth,

  config: RouteConfig[] 

): NextResponse<any> | void { 

  const currentRouteConfig = config.find(

    (route) => matchPath(route?.url, req.nextUrl)

  ); 

  if (!currentRouteConfig) return;

  

  return  executeRules(

    currentRouteConfig?.rules, 

    req.url, 

    req.nextauth.token

  );

}

5. І в кінці інтегруємо цей middleware у основний файл:

export  default  withAuth( 

  async  function  middleware(req) { 

    return  runRoutesMiddleware(req, routesRulesConfig);

  },

  { 

    secret: env.NEXTAUTH_SECRET, 

    callbacks: { 

      authorized: () =>  true,

    },

  }

);

Підсумок

Тепер ми маємо чистіший, більш модульний підхід до обробки доступу до маршрутів. Кожен маршрут має набір умов, що перевіряють доступ, і ви можете комбінувати ці умови для будь-якого маршруту, зберігаючи код чистим і підтримуваним.

Замість того, щоб дублювати логіку перевірки доступу на кожній сторінці, ми визначаємо загальні правила, які потім застосовуємо до кількох маршрутів. Це дозволяє легко змінювати або додавати нові правила без необхідності змінювати кожен маршрут окремо.

Якщо ви хочете зробити цю систему ще більш гнучкою, можете додавати складніші правила, використовувати пріоритети для різних умов, або навіть використовувати динамічні правила для конкретних ситуацій.

Цей підхід допоможе вам уникнути великої кількості коду, спростити логіку маршрутизації та зробити ваш проєкт більш масштабованим і легким для підтримки.

Хоча це може виглядати складно на перший погляд, після деякого часу ви побачите, як легко керувати правилами доступу до маршрутів і як багато переваг ви отримуєте завдяки такій абстракції.

Якщо хочете, можете ознайомитись з більш «покращеною» версією цього рішення у моєму репозиторії, де ви знайдете всі моменти, які я пропустив у статті, щоб спростити сприйняття.

Бонус

Вже після написання статті мені показали, що десь у світі знайшлись мої однодумцій створили бібліотеку, яка дуже схожа за концепцією. Залишаю посилання тут: www.npmjs.com/package/@rescale/nemo. Мені моє кастомне рішення подобається більше, бо для мене воно має більшу гнучкість, але я впевнений, що ця бібліотека так саме може вирішити перераховані в статті проблеми. Тож рішення, що використовувати, залишаю за вами.

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Перш ніж класти весь auth в «middleware» я би порадив подивитися youtu.be/...​AtlDQ?si=XyPV0YB-Gtfc_yVF

Цікаво, автор буде переписувать свої проєкти тепер?)))

Дякую, за коментар. Цього автора теж дивлюсь, він по ділу «прожарює» vercel та middleware, але тут треба зробити невелику ремарку.
Я ніколи не робив в продакшн проєктах SSR компоненти і не підключав до некста БД, також не покладаюсь на middleware як на головну security міру для авторизації, завжди використовую окремий бекенд.
Але, якщо хочеться, можна зробити більш надійно, додавши перевірку підпису JWT. Це не дуже дорога операція, яку можна там використати. Щодо «x-middleware-subrequest» хедера, то nextjs здається ще в березні зробили фікс для більшості останніх версій (13,14,15), головне не використовувати стару.
Всі дані мають бути захищені в першу чергу на сервері, а middleware це скоріше фронтовий інструмент, на який покладати таку відповідальність НЕ рекомендую.

Тут може збентежити, те що в статті використаний next auth, ми його колись хибно використовували, ніякої привʼязки до SSR там не було, він точно не потрібен. Було б слушно прибрати звідси. Замість цього отримати JWT можна самостійно з кукі, проблем не буде

Підписатись на коментарі