Перейти к основному содержимому

Авторизация

В общих чертах авторизация состоит из следующих этапов:

  1. Получить учетные данные от пользователя
  2. Отправить их на бэкенд
  3. Сохранить токен для отправки авторизованных запросов.

Как получить учетные данные пользователя

Мы предполагаем, что ваше приложение само собирает эти данные. Если у вас авторизация через OAuth, вы можете просто создать страницу логина со ссылкой на страницу провайдера OAuth и перейти к шагу 3.

Отдельная страница для логина

Обычно на сайтах есть отдельные страницы для логина, где вы вводите свое имя пользователя и пароль. Эти страницы довольно просты, поэтому не требуют декомпозиции. Более того, формы логина и регистрации внешне очень похожи, поэтому их можно даже сгруппировать на одной странице. Создайте слайс для вашей страницы логина/регистрации на слое Pages:

  • 📂 pages
    • 📂 login
      • 📂 ui
        • 📄 LoginPage.tsx (или аналог в вашем фреймворке)
        • 📄 RegisterPage.tsx
      • 📄 index.ts
    • остальные страницы…

Здесь мы создали два компонента и экспортировали их обоих в индексе слайса. Эти компоненты будут содержать формы, которые содержат понятные пользователю элементы для введения их учетных данных.

Диалог для логина

Если в вашем приложении есть диалоговое окно для входа в систему, которое можно использовать на любой странице, вы можете создать для этого диалогового окна виджет. Таким образом, вы все равно сможете не сильно декомпозировать саму форму, но при этом переиспользовать этот диалог на любой странице.

  • 📂 widgets
    • 📂 login-dialog
      • 📂 ui
        • 📄 LoginDialog.tsx
      • 📄 index.ts
    • остальные виджеты…

Остальная часть этого руководства написана для первого подхода, где логин делается на отдельной странице, но те же принципы применимы и к виджету диалога.

Клиентская валидация

Иногда, особенно при регистрации, имеет смысл выполнить проверку на стороне клиента, чтобы быстро сообщить пользователю, что они допустили ошибку. Проверка может происходить в сегменте model на странице логина. Используйте библиотеку проверки по схемам, например, Zod для JS/TS, и предоставьте эту схему сегменту ui:

pages/login/model/registration-schema.ts
import { z } from "zod";

export const registrationData = z.object({
email: z.string().email(),
password: z.string().min(6),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});

Затем в сегменте ui вы можете использовать эту схему для проверки ввода пользователя:

pages/login/ui/RegisterPage.tsx
import { registrationData } from "../model/registration-schema";

function validate(formData: FormData) {
const data = Object.fromEntries(formData.entries());
try {
registrationData.parse(data);
} catch (error) {
// TODO: Показать пользователю сообщение об ошибке
}
}

export function RegisterPage() {
return (
<form onSubmit={(e) => validate(new FormData(e.target))}>
<label htmlFor="email">Электронная почта</label>
<input id="email" name="email" required />

<label htmlFor="password">Пароль (мин. 6 символов)</label>
<input id="password" name="password" type="password" required />

<label htmlFor="confirmPassword">Подтвердите пароль</label>
<input id="confirmPassword" name="confirmPassword" type="password" required />
</form>
)
}

Как отправить учетные данные на бэкенд

Создайте функцию, которая отправляет запрос к эндпоинту логина на бэкенде. Эту функцию можно вызвать либо непосредственно в коде компонента через библиотеку мутаций (например, TanStack Query), либо как побочный эффект в стейт-менеджере.

Где хранить функцию запроса

Есть два места, куда можно положить эту функцию: в shared/api или в сегмент api на странице.

В shared/api

Этот подход хорошо сочетается с тем, чтоб размещать в shared/api все функции запросов, и группировать их по эндпоинту, например. Структура файлов в таком случае может выглядеть так:

  • 📂 shared
    • 📂 api
      • 📂 endpoints
        • 📄 login.ts
        • остальные функции запросов…
      • 📄 client.ts
      • 📄 index.ts

Файл 📄 client.ts содержит обёртку над примитивом, выполняющим запросы (например, fetch()). Эта обёртка знает про base URL вашего бэкенда, проставляет необходимые заголовки, сериализует данные, и т.д.

shared/api/endpoints/login.ts
import { POST } from "../client";

export function login({ email, password }: { email: string, password: string }) {
return POST("/login", { email, password });
}
shared/api/index.ts
export { login } from "./endpoints/login";

В сегменте api страницы

Если вы не храните все свои запросы в одном месте, возможно, вам подойдет разместить эту функцию запроса в сегменте api на странице логина.

  • 📂 pages
    • 📂 login
      • 📂 api
        • 📄 login.ts
      • 📂 ui
        • 📄 LoginPage.tsx
      • 📄 index.ts
    • остальные страницы…
pages/login/api/login.ts
import { POST } from "shared/api";

export function login({ email, password }: { email: string, password: string }) {
return POST("/login", { email, password });
}

Эту функцию даже необязательно реэкспортировать из индекса страницы, потому что, скорее всего, она будет использоваться только внутри этой страницы.

Двухфакторная аутентификация

Если ваше приложение поддерживает двухфакторную аутентификацию (2FA), возможно, вам придется перенаправить пользователя на другую страницу, где они смогут ввести одноразовый пароль. Обычно, ваш запрос POST /login возвращает объект пользователя с флагом, указывающим, что у пользователя включен 2FA. Если этот флаг установлен, перенаправьте пользователя на страницу 2FA.

Поскольку эта страница очень связана с логином, вы также можете положить её в тот же слайс, login, на слое Pages.

Вам также понадобится еще одна функция запроса, похожая на login(), которую мы создали выше. Поместите их вместе либо в Shared, либо в сегмент api на странице login.

Как хранить токен для авторизованных запросов

Независимо от используемой вами схемы авторизации, будь то простой логин и пароль, OAuth или двухфакторная аутентификация, в конце вы получите токен. Этот токен следует хранить, чтобы последующие запросы могли идентифицировать себя.

Идеальным хранилищем токенов для веб-приложения являются cookies — они не требуют ручного сохранения или обработки токенов. Таким образом, хранение cookies практически не требует усилий со стороны архитектуры фронтенда. Если ваш фронтенд-фреймворк имеет серверную часть (например, Remix), то серверную инфраструктуру cookies следует хранить в shared/api. В разделе туториала «Аутентификация» есть пример того, как это сделать в Remix.

Однако, иногда хранить токен в cookies — не вариант. В этом случае вам придется хранить токен самим. Помимо этого, вам также может потребоваться написать логику для обновления этого токена по истечении срока его действия. В рамках FSD есть несколько мест, где вы можете хранить токен, а также несколько способов сделать его доступным для остальной части приложения.

В Shared

Этот подход хорошо работает, когда API-клиент определен в shared/api, поскольку токен свободно доступен ему для других функций-запросов, которые требуют авторизацию. Вы можете сделать так, чтоб клиент имел свой стейт, либо с помощью реактивного хранилища, либо просто с помощью переменной на уровне модуля. Затем вы можете обновлять этот стейт в ваших функциях login()/logout().

Автоматическое обновление токена может быть реализовано как middleware в API-клиенте — то, что выполняется каждый раз, когда вы делаете какой-либо запрос. Например, можно сделать так:

  • Авторизоваться и сохранить токен доступа, а также токен обновления.
  • Сделать любой запрос, требующий авторизации
  • Если запрос падает с кодом состояния, указывающим на истечение срока действия токена, а в хранилище есть токен, сделать запрос на обновление, сохранить новые токены и повторить исходный запрос.

Одним из недостатков этого подхода является то, что логика хранения и обновления токена не имеет выделенного места. Это может подойти каким-то приложениям или командам, но если логика управления токенами более сложна, может захотеться разделить обязанности по отправке запросов и управлению токенами. В этом случае можно положить запросы и API-клиент в shared/api, а хранилище токенов и логику обновления — в shared/auth.

Еще одним недостатком этого подхода является то, что если ваш сервер возвращает объект c информацией о вашем текущем пользователе вместе с токеном, вам будет некуда её положить, и придется запросить её снова из специального эндпоинта, например /me или /users/current.

В Entities

У проектов на FSD часто есть сущность пользователя и/или сущность текущего пользователя. Это даже может быть одна сущность.

примечание

Текущий пользователь также иногда называется "viewer" или "me". Это делается для того, чтоб различать одного авторизованного пользователя с разрешениями и приватной информацией и всех остальных пользователей с публичной информацией.

Чтоб хранить токен в сущности User, создайте реактивное хранилище в сегменте model. Это хранилище может содержать одновременно и токен, и объект с информацией о пользователе.

Поскольку API-клиент обычно размещается в shared/api или распределяется между сущностями, главной проблемой этого подхода является обеспечение доступа к токену для других запросов, не нарушая при этом правило импортов для слоёв:

Модуль в слайсе может импортировать другие слайсы только в том случае, если они расположены на слоях строго ниже.

Есть несколько решений этой проблемы:

  1. Передавать токен вручную каждый раз, когда делаете запрос
    Это самое простое решение, но оно быстро становится неудобным, и если у вас нет строгой типизации, об этом легко забыть. Это решение также несовместимо с паттерном middleware для API-клиента в Shared.
  2. Открыть доступ к токену для всего приложения через контекст или глобальное хранилище вроде localStorage
    Ключ, по которому можно будет получить токен, будет храниться в shared/api, чтоб API-клиент мог его использовать. Реактивное хранилище токена будет экспортировано из сущности User, а провайдер контекста (если требуется) будет настроен на слое App. Это дает больше свободы для дизайна API-клиента, но такой подход создаёт неявную зависимость
  3. Вставлять токен в API-клиент каждый раз, когда токен меняется
    Если ваше хранилище реактивное, то можно подписаться на изменения и обновлять токен в API-клиенте каждый раз, когда хранилище в сущности User меняется. Это похоже на прошлое решение тем, что они оба создают неявную зависимость, но это решение более императивное ("push"), тогда как предыдущее — более декларативное ("pull").

Как только вы решите проблему доступности токена, хранящегося в модели сущности User, вы сможете описать дополнительную бизнес-логику, связанную с управлением токенами. Например, сегмент model может содержать логику, которая делает токен недействительным через определенный период времени или обновляет токен по истечении срока его действия. Чтобы совершать запросы на бэкенд для выполнения этих задач, используйте сегмент api сущности User или shared/api.

В Pages/Widgets (не рекомендуется)

Не рекомендуется хранить состояние, актуальное для всего приложения, как например токен доступа, в страницах или виджетах. Не стоит размещать хранилище токенов в сегменте model на странице логина. Вместо этого выберите одно из первых двух решений: Shared или Entities.

Логаут и аннулирование токена

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

Если вы храните все ваши запросы в shared/api, оставьте там функцию для запроса на логаут, рядом с функцией для логина. Если нет, разместите функцию-запрос на логаут рядом с кнопкой, которая её вызывает. Например, если у вас есть виджет хэдера, который есть на каждой странице и содержит ссылку для логаута, поместите этот запрос в сегмент api этого виджета.

Обновление хранилища токенов также должно будет запускаться с места кнопки логаута, как, например, виджет заголовка. Вы можете объединить запрос и обновление хранилища в сегменте model этого виджета.

Автоматический логаут

Не забудьте предусмотреть ситуации сбоя запроса на логаут или сбоя запроса на обновление токена. В обоих случаях вам следует очистить хранилище токенов. Если вы храните свой токен в Entities, этот код можно поместить в сегмент model, поскольку это чистая бизнес-логика. Если вы храните токен в Shared, размещение этой логики в shared/api может раздуть сегмент и размыть его предназначение. Если вы замечаете, что ваш сегмент api содержит две несвязанные вещи, рассмотрите возможность выделения логики управления токенами в другой сегмент, например, shared/auth.