12k
All articles

Авторизация на стороне клиента и на стороне сервера: почему необходимы оба подхода

Авторизация на клиенте и сервере в React и Next.js: проверяйте права на сервере, используйте клиент для UX и избегайте 403 из-за рассинхрона.

OpenReplay Team
OpenReplay Team
Авторизация на стороне клиента и на стороне сервера: почему необходимы оба подхода

Авторизация на стороне клиента — это логика управления доступом, выполняемая в браузере и определяющая, что видят пользователи; авторизация на стороне сервера — это логика управления доступом, выполняемая на сервере и определяющая, что реально происходит. Никакие ограничения в пользовательском интерфейсе не изменят того, какие запросы сервер готов принять. Это две разные задачи на двух разных границах доверия, и отказ от любой из них приводит к конкретному, предсказуемому сбою: проверки только на стороне клиента обходятся любым пользователем, открывшим DevTools, а проверки только на стороне сервера вынуждают пользователей нажимать кнопки, в ответ на которые приходят 403-и — и никакого объяснения.

Если вы разрабатывали приложение на React или Next.js, где проверки ролей реализованы через блоки if (user.role === 'admin'), вы уже писали авторизацию на стороне клиента — возможно, просто не задумывались о том, где проходит реальная граница безопасности. Эта статья проводит её чётко: почему клиент является ненадёжной средой, почему сервер — единственный привратник, который имеет значение, для чего легитимно используются клиентские проверки и как синхронизировать оба уровня с помощью единого идентификатора разрешения, применяемого на обеих сторонах.

Ключевые выводы

  • Клиент — это ненадёжная среда: любой пользователь может открыть DevTools, изменить переменную, хранящую его роль, и наблюдать, как реагируют условные рендеры — поэтому решения об авторизации должны применяться на стороне сервера, на шлюзе или в serverless-функции (OWASP Authorization Cheat Sheet).
  • Авторизация на стороне клиента существует для UX, а не для безопасности: она скрывает кнопки, ограничивает маршруты и отображает только те пункты меню, которые применимы, чтобы пользователи никогда не попадали в тупик.
  • Состояние разрешений на фронтенде — это кэш того, что разрешит сервер; когда они расходятся, возникает либо уязвимый рабочий процесс (UI показывает кнопку, на которую сервер ответит 403), либо скрытая возможность (сервер разрешает действие, которое UI никогда не отображает).
  • Используйте идентификаторы разрешений вида tasks:delete на обоих уровнях, а не сравнения имён ролей вида role === 'admin' — имена разрешений переживают реструктуризацию ролей и соответствуют той единице, которую сервер реально применяет.
  • 401 означает отсутствие аутентификации и должен перенаправлять на страницу входа; 403 означает, что пользователь аутентифицирован, но не авторизован, и должен отображать состояние отказа в доступе (RFC 9110 §15.5.2, §15.5.4).

Клиент — ненадёжная среда

Каждая проверка роли в вашем JavaScript выполняется в среде, которую контролирует пользователь: он может открыть DevTools, найти переменную, хранящую его роль, установить значение 'admin' и наблюдать, как реагируют условные рендеры — единственное, что при этом не изменится, — это ответ сервера на следующий запрос. Это фундаментальный факт, делающий авторизацию только на стороне клиента недостаточной. Браузер выполняет ваш код, но пользователь владеет браузером и может переписать всё, что находится между загрузкой страницы и следующим сетевым запросом.

OWASP Authorization Cheat Sheet формулирует практическое правило прямо: клиентские проверки управления доступом могут улучшить UX, но решения об авторизации должны применяться на стороне сервера, на шлюзе или в serverless-функции — поскольку клиентскую логику легко обойти. Концептуальная основа этого утверждения появилась задолго до современных SPA; в руководстве Static Apps по аутентификации это сформулировано так: «конечный пользователь может выполнять произвольный код на стороне клиента без предварительного разрешения».

Обход через DevTools за 30 секунд

Наглядный способ убедиться в несостоятельности авторизации только на стороне клиента — обойти её самостоятельно. Это воспроизводится в любом браузере с DevTools применительно к любому React-приложению, которое хранит роль в состоянии компонента, а не вычисляет её при каждом запросе из верифицированного токена на сервере:

  1. Войдите в систему как обычный (не администраторский) пользователь. Панель администратора скрыта — ваш компонент рендерит {user.role === 'admin' && <AdminPanel />}, а user.role равно 'user'.
  2. Откройте DevTools и найдите состояние компонента, хранящее user. С помощью React DevTools можно напрямую просматривать и редактировать состояние хуков; без него подойдёт любой путь в коде, открывающий объект пользователя через изменяемую ссылку.
  3. Установите role в значение 'admin'. React выполнит повторный рендер. Панель администратора появится в DOM.
  4. Нажмите кнопку удаления, которую открывает панель. Запрос будет отправлен.
  5. Если сервер применяет авторизацию, он считает идентификатор из вашего токена — а не из изменённого состояния клиента — и вернёт 403 Forbidden. Данные останутся нетронутыми.

Атака успешна на шаге 3 (UI изменился) и терпит неудачу на шаге 5 (сервер отклоняет запрос) только если на сервере есть проверка. При отсутствии серверной проверки шаг 4 изменяет реальные данные. Обход работает именно потому, что роль хранится в изменяемом состоянии клиента; он не работает, когда сервер заново вычисляет авторизацию из верифицированного токена при каждом запросе.

Сервер — единственный привратник, который имеет значение

Решения об авторизации, изменяющие данные или открывающие доступ к защищённым ресурсам, должны приниматься и применяться на сервере — поскольку сервер является единственным участником запроса, которого пользователь не может переписать. Бэкенд — это окончательный привратник: независимо от того, что фронтенд рендерит, скрывает или блокирует, оценка запроса сервером является решением, имеющим силу. Клиентские элементы управления не заменяют серверное применение правил.

Ниже приведён пример серверного применения единственного разрешения tasks:delete в виде Route Handler в Next.js. Разрешение считывается из аутентифицированной сессии, а не из чего-либо, что клиент передаёт в теле запроса:

// app/api/tasks/[id]/route.ts — Next.js App Router
import { NextRequest, NextResponse } from 'next/server';
import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';

export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } },
) {
  const permissions = await getSessionPermissions(req); // derived from a verified token/session

  if (!permissions.includes('tasks:delete')) {
    return NextResponse.json(
      { error: 'forbidden', permission: 'tasks:delete' },
      { status: 403 },
    );
  }

  await deleteTask(params.id);
  return new NextResponse(null, { status: 204 });
}

Ответ 403 содержит конкретное имя разрешения в теле. Эта деталь важна в дальнейшем: она позволяет клиенту отличить отказ в разрешении от любой другой ошибки и отобразить понятное сообщение вместо универсального уведомления. Статус 403 Forbidden является семантически корректным выбором согласно RFC 9110 §15.5.4, который определяет его как ситуацию, когда сервер понял запрос, но отказывается его авторизовать.

Авторизация на стороне клиента — для интерфейса, а не для защиты

Авторизация на стороне клиента существует для формирования интерфейса: скрывать кнопки администратора от обычных пользователей, ограничивать маршруты, чтобы пользователи не попадали на страницы с ответом 403, и показывать только применимые пункты меню. Она улучшает опыт взаимодействия, направляя пользователей к действиям, которые они реально могут выполнить, — но не обеспечивает и не может обеспечить никакой безопасности. Воспринимайте фронтенд как витрину, а не как хранилище: он упорядочивает то, что доступно и предлагается, тогда как замок остаётся на сервере.

Стандартная реализация считывает разрешения из контекста и выполняет условный рендер по идентификатору разрешения:

// components/TaskActions.tsx
'use client';
import { usePermissions } from '@/hooks/usePermissions';

export function TaskActions({ taskId }: { taskId: string }) {
  const { can } = usePermissions();

  return (
    <div className="task-actions">
      {can('tasks:delete') && (
        <button onClick={() => deleteTask(taskId)}>Delete</button>
      )}
    </div>
  );
}

Обратите внимание на идентификатор: tasks:delete, а не role === 'admin'. Проверка user.role === 'admin' связывает UI с вашей таксономией ролей; проверка can('tasks:delete') связывает его с действием, которое реально применяет сервер, — и серверу безразлично, какая роль его предоставила. Когда вы позднее разделите admin на admin и billing-admin, проверки по имени разрешения останутся нетронутыми. OWASP Authorization Cheat Sheet рекомендует разделять роли и разрешения именно по этой причине. RBAC по-прежнему остаётся распространённой моделью управления доступом для фронтенд-приложений; полное сравнение моделей RBAC/ABAC/ACL/PBAC хорошо освещено в статье LogRocket о выборе модели управления доступом.

Одно практическое замечание: записи сессий пользователей в UI с ролевыми ограничениями нередко фиксируют краткий момент, когда неавторизованный пользователь видит кнопку администратора до того, как клиентская проверка разрешений разрешится и скроет её — мерцание неавторизованного UI, вызванное тем, что состояние разрешений поступает после первой отрисовки. Решение — рендерить состояние разрешений на сервере или предзагружать его до рендера, что паттерн передачи из следующего раздела реализует по своей природе.

Два уровня должны быть согласованы: расхождение разрешений

Представление фронтенда о разрешениях — это кэш того, что разрешит сервер, и они должны оставаться синхронизированными. Когда состояние разрешений на фронтенде расходится с правилами применения на сервере, возникает один из двух сбоев: кнопка, на которую сервер ответит 403 (уязвимый рабочий процесс), или действие, которое сервер разрешает, но UI никогда не отображает (скрытая возможность). Первый — это проблема безопасности и UX: вы показываете элементы управления, которые не работают, и злоумышленник может изучить, какие из них сервер реально принимает. Второй — чистая потеря функциональности: пользователи не могут получить доступ к тому, на что имеют право.

Способ предотвратить расхождение — структурный: определить единый источник истины и питать оба уровня из него.

Паттерн передачи

Паттерн передачи — это архитектура авторизации, в которой сервер хранит единственный источник истины для разрешений, а клиент хранит кэш этой истины — заполняемый при входе в систему, используемый для рендера интерфейса, но носящий рекомендательный, а не авторитетный характер. Полный цикл выглядит так: сервер определяет разрешения пользователя, отправляет их клиенту в известном формате передачи данных, клиент кэширует их в контексте, UI рендерится по кэшу, пользователь действует, и сервер повторно проверяет разрешения при запросе. Один и тот же идентификатор разрешения присутствует на каждом шаге.

Начните с формата передачи данных. При входе в систему (или при начальном рендере Server Component) сервер отправляет набор разрешений:

{
  "userId": "u_8123",
  "permissions": ["tasks:read", "tasks:create", "tasks:delete"]
}

Если разрешения хранятся в JWT, они располагаются в виде claims в полезной нагрузке токена. Подписанный JWT (JWS) содержит claims в base64url-кодированной полезной нагрузке, защищённой по целостности, но не зашифрованной — подпись доказывает, что claims не были изменены, однако сами claims не являются секретными и доступны для чтения клиентом. Зашифрованные JWT (JWE) — это другая конструкция. Это различие следует из RFC 7519, спецификации JWT. Практическое следствие: claims разрешений в JWT — вполне подходящий кэш для рендеринга, но они по-прежнему остаются лишь кэшем. Сервер должен проверять авторизацию при каждом запросе, используя тот источник истины, на который опирается ваша архитектура.

Загрузите кэш в контекст один раз, предпочтительно из Server Component, чтобы данные были доступны при первой отрисовке:

// app/layout.tsx — Server Component, Next.js App Router
import { getSessionPermissions } from '@/lib/auth';
import { PermissionsProvider } from '@/hooks/usePermissions';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const permissions = await getSessionPermissions(); // same source the API uses

  return (
    <html lang="en">
      <body>
        <PermissionsProvider permissions={permissions}>
          {children}
        </PermissionsProvider>
      </body>
    </html>
  );
}
// hooks/usePermissions.tsx
'use client';
import { createContext, useContext } from 'react';

const PermissionsContext = createContext<string[]>([]);

export function PermissionsProvider({
  permissions,
  children,
}: {
  permissions: string[];
  children: React.ReactNode;
}) {
  return (
    <PermissionsContext.Provider value={permissions}>
      {children}
    </PermissionsContext.Provider>
  );
}

export function usePermissions() {
  const permissions = useContext(PermissionsContext);
  return { can: (p: string) => permissions.includes(p) };
}

Поскольку getSessionPermissions() — это та же функция, которую вызывает Route Handler, UI и шлюз читают идентичные данные, и tasks:delete означает одно и то же на обеих сторонах. Загрузка разрешений в Server Component (а не их получение в клиентском useEffect) означает, что набор разрешений доступен до первой отрисовки — никакого мерцания неправильного UI, никаких задержек пока проверка не разрешится.

React Server Components и Server Actions: граница синтаксическая

В Next.js App Router слова «клиент» и «сервер» описывают, где выполняется код, а не сетевой вызов, который вы делаете вручную, — и именно это делает Server Actions легитимным местом для применения авторизации. Next.js Server Action, вызывающий проверку разрешений перед изменением данных, является серверной авторизацией независимо от того, где он находится в дереве компонентов: код выполняется на сервере, пользователь не может его изменить, и проверку нельзя обойти редактированием состояния клиента.

В актуальной документации Next.js App Router директива 'use server' помечает Server Function; Server Function, используемая в контексте action/mutation, также называется Server Action. Директива является маркером границы — код под ней выполняется на сервере, даже если он написан в файле .tsx рядом с клиентскими компонентами:

// app/tasks/actions.ts — Next.js App Router
'use server';

import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';

export async function deleteTaskAction(taskId: string) {
  const permissions = await getSessionPermissions(); // runs on the server

  if (!permissions.includes('tasks:delete')) {
    return { ok: false as const, error: 'forbidden', permission: 'tasks:delete' };
  }

  await deleteTask(taskId);
  return { ok: true as const };
}

Это та же проверка tasks:delete, что и в Route Handler, в той же позиции применения. Редактирование состояния клиента для имитации role === 'admin' здесь ничего не даст — action заново вычисляет разрешения из сессии на сервере. Server Actions не устраняют необходимость в шлюзе; они — ещё одно место, где шлюз располагается. Полную семантику директивы см. в документации Next.js по директиве use server.

Когда ваше SPA напрямую обращается к стороннему API

Когда ваше SPA напрямую обращается к стороннему API из браузера, этот сторонний сервис является вашим сервером для целей авторизации — платёжный провайдер, headless CMS или backend-as-a-service выступает шлюзом независимо от того, писали ли вы его сами. Граница применения не исчезает только потому, что вы не писали бэкенд; она перемещается к тому, кто оценивает запрос. Ваши клиентские проверки по-прежнему служат только UX, а правила авторизации стороннего сервиса являются шлюзом. Если вы не можете применить решение в коде, который вы контролируете, направьте запрос через собственный бэкенд.

Режимы сбоя: что ломается при отсутствии одного из уровней

Отказ от любого из уровней приводит к отдельному, предсказуемому сбою. Авторизация только на стороне клиента тривиально обходится; авторизация только на стороне сервера оставляет пользователей в замешательстве. Ни то ни другое в отдельности неприемлемо.

Пропущенный уровеньЧто ломаетсяКто эксплуатируетПользовательский опыт
Серверный (только клиент)Авторизация вообще не применяетсяЛюбой пользователь с DevTools или curlВыглядит нормально — пока данные не изменит тот, кто не должен
Клиентский (только сервер)UI показывает действия, которые сервер отклонитНет эксплойта; это дефект UXПользователи нажимают кнопку, получают 403, нажимают снова, сдаются

Правило достаточно короткое, чтобы его запомнить: рендерите с клиентскими разрешениями, применяйте с серверными разрешениями и передавайте одинаковые идентификаторы разрешений на обе стороны. Нарушите первое — и пользователи будут нажимать кнопки, получая в ответ 403. Нарушите второе — и любой пользователь с DevTools сможет повысить себе привилегии.

UX при сбое авторизации: 403 против 401

Когда сервер отклоняет действие, которое UI считал разрешённым, статус ответа точно указывает, что показать пользователю. 401 означает, что пользователь не аутентифицирован — перенаправьте на страницу входа. 403 означает, что пользователь аутентифицирован, но не авторизован — покажите состояние отказа в доступе, а не универсальную ошибку, чтобы пользователь понял, почему действие не выполнено. Эта семантика закреплена в RFC 9110: §15.5.2 определяет 401 Unauthorized (аутентификация требуется и не была предоставлена или завершилась неудачей), а §15.5.4 определяет 403 Forbidden (сервер понял запрос, но отказывается его авторизовать).

async function deleteTask(taskId: string) {
  const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' });

  if (res.status === 401) {
    window.location.assign('/login');
    return;
  }
  if (res.status === 403) {
    const body = await res.json();
    showPermissionDenied(body.permission); // e.g. "You don't have permission to delete tasks."
    return;
  }
  if (!res.ok) {
    showError('Something went wrong. Try again.');
    return;
  }
  // success
}

Именно в различении 403 проявляется расхождение клиента и сервера для пользователя. Записи сессий с ответами 403 нередко показывают, как пользователь нажимает кнопку, получает универсальное уведомление об ошибке, а затем нажимает снова — признак того, что UI так и не сообщил, что действие было отклонено именно по причине отсутствия разрешения. Отображение имени разрешения из тела ответа (поле permission: 'tasks:delete', которое отправил сервер) устраняет этот пробел. RFC определяет, что означает 403; то, как вы это представляете, — ваш выбор, но смысл — это именно то, что UI должен отражать.

Заключение

Авторизация существует на двух границах, выполняя две задачи: клиент формирует интерфейс, сервер решает, что происходит, а одинаковые идентификаторы разрешений передаются на обе стороны из единственного источника истины на сервере. Проверьте собственный код на два режима сбоя — найдите проверки ролей или разрешений, ограничивающие мутации данных без соответствующего серверного применения (потенциальный обход), и найдите клиентские проверки, расходящиеся с тем, что возвращает ваш API (ответ 403, который пользователи не могут понять). Везде, где проверка изменяет данные, убедитесь, что сервер заново вычисляет решение из верифицированной сессии, а не из чего-либо, что клиент может переписать.

Часто задаваемые вопросы

Безопасно ли хранить разрешения пользователя в JWT, если клиент может их прочитать?

Да, при условии, что вы воспринимаете токен как кэш, а не как границу безопасности. Подписанный JWT (JWS) содержит claims в base64url-кодированной полезной нагрузке, защищённой по целостности, но не зашифрованной, поэтому клиент может прочитать разрешения, однако подпись предотвращает их подделку. Риск состоит не в раскрытии списка разрешений, а в доверии к нему. Сервер должен проверять авторизацию при каждом запросе в соответствии с моделью авторизации, используемой в приложении.

Нужна ли Next.js Server Action собственная проверка авторизации, если страница уже ограничивает UI?

Да. Ограничения UI контролируют только то, что рендерится; они не защищают мутацию. Server Action выполняется на сервере и может быть вызван независимо от страницы, которая его отрендерила, поэтому он должен самостоятельно вычислять разрешения из верифицированной сессии и отклонять неавторизованные вызовы. Директива 'use server' указывает, где выполняется код, а не является ли он авторизованным. Воспринимайте каждый Server Action как точку применения правил — точно так же, как Route Handler.

Какой статус-код должен возвращать API, когда пользователь вошёл в систему, но не имеет разрешения на действие?

Возвращайте 403 Forbidden, а не 401 Unauthorized. RFC 9110 определяет 401 как ситуацию, когда аутентификация требуется и не была предоставлена или завершилась неудачей, а 403 — как ситуацию, когда сервер понял запрос, но отказывается его авторизовать. Вошедший в систему пользователь без необходимого разрешения является аутентифицированным, но не авторизованным — это в точности соответствует 403. Клиент должен перенаправлять на страницу входа при 401 и показывать состояние отказа в доступе при 403, чтобы два типа сбоев оставались визуально и поведенчески различимыми.

Почему на фронтенде следует проверять 'tasks:delete', а не 'role === admin'?

Идентификаторы разрешений переживают реструктуризацию ролей; сравнения имён ролей — нет. Проверка 'role === admin' связывает UI с текущей таксономией ролей, поэтому последующее разделение admin на admin и billing-admin сломает каждую такую проверку. Проверка 'tasks:delete' связывает UI с действием, которое реально применяет сервер, — оно не изменяется при реорганизации ролей.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.