12k
All articles

Как добавить аутентификацию на сайт Astro

Настройте аутентификацию Astro с adapter, middleware, Astro.locals, sessions, rewrite и Actions: login, logout и защиту маршрутов.

OpenReplay Team
OpenReplay Team
Как добавить аутентификацию на сайт Astro

Аутентификация в Astro зависит не только от того, какую библиотеку вы выбрали, но и от того, как рендерится страница. По умолчанию Astro выполняет пререндеринг страниц проекта на этапе сборки, а отдельные маршруты вы уже вручную выводите из этого режима. Пререндеренная страница генерируется до того, как появится хоть один посетитель, поэтому нет ни запроса, ни заголовка cookie, ни сессии, которую можно было бы прочитать.

Именно по этой причине первая попытка часто заканчивается тихим провалом. Вы устанавливаете библиотеку аутентификации, вставляете её middleware в src/middleware.ts, открываете /account — и Astro.locals оказывается пустым объектом. Ни ошибки, ни записи в логе, ни стектрейса, по которому можно было бы искать причину.

В этой статье мы пройдём всю цепочку по порядку, файл за файлом: адаптер, экспорт prerender для конкретного маршрута, middleware, определяющий пользователя, страница, которая его читает, защита маршрутов через rewrite(), вход и выход как Astro Actions и, наконец, граница, за которой клиентские «острова» перестают видеть всё это. Код рассчитан на Astro 7.x. В Astro 6 минимальная версия Node поднята до 22, а поддержка Node 18 и 20 прекращена, поэтому используйте Node 22.12.0 или новее.

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

  • Astro по умолчанию выполняет пререндеринг всего проекта, поэтому любая страница, читающая сессию, должна отказаться от этого через export const prerender = false, а рендеринг по запросу требует адаптера.
  • Если кажется, что ваш middleware ничего не делает, то маршрут, который он должен защищать, почти наверняка всё ещё пререндерится.
  • Astro.locals живёт ровно один рендер маршрута; данные, которые должны сохраниться до следующего запроса, place в Astro.session, а для неё нужен драйвер сессий.
  • Astro Actions — это публично доступные HTTP-эндпоинты, поэтому каждому обработчику нужна собственная проверка context.locals; защита страницы, которая рендерит форму, не защищает стоящий за ней action.
  • Astro.locals и Astro.session существуют только на сервере, поэтому компонент с директивой client:* видит лишь то, что страница передала ему в props.

Почему статика по умолчанию ломает аутентификацию в Astro?

Проект Astro собирается в статический HTML, если маршрут явно не попросит иного, а статический HTML создаётся один раз, на этапе сборки, сразу для всех посетителей. Пререндеринг всего проекта — это задокументированное поведение по умолчанию, как указано в руководстве по рендерингу по запросу. Сессия живёт в заголовке запроса, которого ещё не существует в момент записи этого HTML на диск, поэтому чтению cookie, Astro.request и всему, что middleware кладёт в locals, попросту не к чему прицепиться.

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

Как включить рендеринг по запросу?

Для рендеринга по запросу нужны две вещи: адаптер, который создаёт сервер для вашей целевой среды выполнения, и отказ от пререндеринга на уровне маршрута. Официальные адаптеры Astro — это @astrojs/cloudflare, @astrojs/netlify, @astrojs/node и @astrojs/vercel, рядом с ними существуют адаптеры от сообщества, а опция adapter описана в справочнике по конфигурации. Установите один из них командой npx astro add node и загляните на страницу самого адаптера, поскольку параметры конфигурации различаются.

Затем выберите форму проекта. Опция output принимает ровно два значения: 'static' и 'server'.

Тип сайтаastro.config.mjsFrontmatter страницы
Контентный сайт с несколькими страницами для авторизованныхадаптер установлен, оставляем значение по умолчанию output: 'static'export const prerender = false на каждом маршруте, читающем сессию
Преимущественно приложение с авторизациейадаптер установлен, задаём output: 'server'export const prerender = true на маркетинговых и контентных маршрутах

Что бы вы ни выбрали, правило одно: маршрут, читающий сессию, должен рендериться по запросу, а документация Node-адаптера описывает специфику среды выполнения для рассматриваемого здесь примера.

Middleware: src/middleware.ts

Middleware определяет пользователя один раз за запрос и передаёт его всему, что идёт дальше по цепочке. Разместите файл по пути src/middleware.js|ts или, если вам удобнее папка, src/middleware/index.js|ts, и сделайте в нём именованный экспорт onRequest. Экспорт по умолчанию подхвачен не будет — руководство по middleware говорит об этом прямо.

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
  const stored = (await context.session?.get('user')) ?? null;
  context.locals.user = stored as User | null;
  return next();
});

Здесь используется встроенная сессия Astro, а не ручной разбор cookie. Одна и та же сессия доступна под двумя именами, как описано в руководстве по сессиям Astro: страницы и компоненты обращаются к ней через Astro.session, а middleware, API-эндпоинты и обработчики actions получают её из context.session. Хранилище не подключается автоматически. Три адаптера выбирают драйвер по умолчанию за вас — Node, Cloudflare и Netlify; с любым другим адаптером драйвер нужно указать самостоятельно, руководствуясь справочником по драйверам сессий. Ограничение, о котором стоит знать до деплоя в edge-среду: в edge middleware сессии не работают.

Типизируйте locals, расширив пространство имён App в src/env.d.ts:

// src/env.d.ts
type User = {
  id: string;
  email: string;
};

declare namespace App {
  interface Locals {
    user: User | null;
  }
}

Чтение пользователя через Astro.locals

Страница читает всё, что назначил middleware, — при условии, что она рендерится по запросу. Astro.locals живёт ровно один рендер маршрута, поэтому это правильное место для передачи объекта пользователя от middleware к странице и неправильное — для хранения чего-либо, что должно пережить текущий запрос.

---
// src/pages/account.astro
/* On-demand rendering */ export const prerender = false;

const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<h1>Signed in as {user.email}</h1>

Удалите первую строку — и страница вернётся в набор пререндеринга: она соберётся в статический HTML, Astro.locals.user никогда не будет заполнен, и все посетители получат один и тот же файл. Эта единственная строка отделяет работающую аутентификацию от экрана входа, который никто не может пройти.

Как защитить маршруты с помощью context.rewrite()?

Защищайте маршруты, централизовав проверку в middleware и рендеря страницу входа на месте вместо перенаправления на неё. context.rewrite('/login') показывает другой контент по тому URL, который запросил посетитель, благодаря чему защищённый путь остаётся в адресной строке.

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

const protectedPaths = ['/account', '/dashboard'];

export const onRequest = defineMiddleware(async (context, next) => {
  const stored = (await context.session?.get('user')) ?? null;
  context.locals.user = stored as User | null;

  const needsAuth = protectedPaths.some((path) =>
    context.url.pathname.startsWith(path),
  );

  if (needsAuth && !context.locals.user) {
    return context.rewrite('/login');
  }

  return next();
});

Поскольку rewrite начинает рендер заново, ваш middleware по пути отрабатывает второй раз, поэтому не включайте /login в protectedPaths — иначе второй проход завалит ту же проверку и получится цикл. Сломанная таким образом аутентификация редко выбрасывает исключения: session replay процесса входа показывает, как пользователя перебрасывает между страницей входа и защищённым маршрутом, — именно так со стороны выглядит cookie сессии с неверной областью действия или проверка, поставленная не в той ветке.

Второй формы rewrite здесь лучше избегать. Когда вы передаёте в next() объект Request, Astro создаёт подменный запрос на основе старого, и любая попытка прочитать тело после этого (или до) приводит к ошибке во время выполнения. Особенно больно это бьёт, когда action вызывается из HTML-формы, — поэтому документация направляет вас к context.rewrite() или Astro.rewrite().

Вход и выход через Astro Actions

Astro Actions, появившиеся в astro@4.15, — это встроенный способ обрабатывать вход и выход, заменяющий самописные API-маршруты. Опишите их в объекте server, экспортируемом из src/actions/index.ts, задайте accept: 'form' и отправляйте на них данные из обычного HTML.

// src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro/zod';

// Replace with your own credential lookup.
async function verifyCredentials(email: string, password: string): Promise<User | null> {
  return null;
}

export const server = {
  login: defineAction({
    accept: 'form',
    input: z.object({
      email: z.email({ error: 'Enter a valid email address.' }),
      password: z.string(),
    }),
    handler: async ({ email, password }, context) => {
      const user = await verifyCredentials(email, password);
      if (!user) throw new ActionError({ code: 'UNAUTHORIZED' });

      await context.session?.regenerate();
      await context.session?.set('user', user);
      return { ok: true };
    },
  }),

  logout: defineAction({
    accept: 'form',
    handler: async (_input, context) => {
      await context.session?.destroy();
      return { ok: true };
    },
  }),

  deleteAccount: defineAction({
    accept: 'form',
    handler: async (_input, context) => {
      if (!context.locals.user) throw new ActionError({ code: 'UNAUTHORIZED' });
      return { ok: true };
    },
  }),
};

z берётся из astro/zod, который реэкспортирует Zod v4, поэтому валидаторы верхнего уровня вроде z.email() и ключ error для пользовательских сообщений — это актуальный синтаксис. Регенерация идентификатора сессии при входе защищает от фиксации сессии, а destroy() очищает cookie и удаляет сохранённые данные на сервере.

Обратите внимание на deleteAccount. Actions — это публично доступные эндпоинты со своими URL, поэтому любой может вызвать их напрямую, ни разу не открыв страницу с соответствующей формой. Каждый обработчик, работающий с пользовательскими данными, сам проверяет context.locals.

Со стороны страницы всё сводится к форме и чтению результата:

---
// src/pages/login.astro
/* On-demand rendering */ export const prerender = false;
import { actions } from 'astro:actions';

const result = Astro.getActionResult(actions.login);
if (result && !result.error) return Astro.redirect('/account');
---
{result?.error && <p class="error">Those details did not match.</p>}

<form method="POST" action={actions.login}>
  <input type="email" name="email" required />
  <input type="password" name="password" required />
  <button>Log in</button>
</form>

Выход устроен так же: <form method="POST" action={actions.logout}>, без единой строки клиентского JavaScript.

Ловушка островов: острова никогда не видят locals

Astro.locals и Astro.session существуют только на сервере. Middleware, страницы и лейауты .astro, API-маршруты и обработчики actions выполняются на сервере и имеют к ним общий доступ. Компонент с директивой client:* гидратируется в браузере, находится вне этой цепочки и видит только то, что страница передала ему в props.

---
// Renders logged-out UI forever. The island cannot reach locals.
import UserMenu from '../components/UserMenu.jsx';
---
<UserMenu client:load />
---
// Correct: the page reads locals on the server and passes the value down.
import UserMenu from '../components/UserMenu.jsx';
const user = Astro.locals.user;
---
<UserMenu client:load user={user} />

В сломанном варианте ничего не падает. Страница рендерит контент для авторизованного пользователя, а остров рядом с ней — кнопку входа; такое расхождение проявляется только визуально.

В этом примере используются встроенные сессии Astro, но та же последовательность справедлива и для библиотек: руководство Astro по аутентификации указывает на библиотеки вроде Better Auth и Clerk для входа по email и OAuth, а фреймворк-независимая архитектура Better Auth распространяется и на Astro, как объясняется в нашем обзоре BetterAuth. Что бы вы ни выбрали, всё равно потребуются адаптер, непререндеренный маршрут и middleware, записывающий данные в context.locals.

Подведём итоги

Аутентификация в Astro — это цепочка с одним слабым звеном в самом начале: нет адаптера и нет prerender = false — значит, нет запроса, и всё, что идёт дальше, молча не делает ничего. Сначала разберитесь с режимом рендеринга, затем с middleware, затем с проверками в каждом обработчике actions. Откройте руководство по рендерингу по запросу рядом с вашим astro.config.mjs, установите адаптер и добавьте export const prerender = false на первую же страницу, которой нужен пользователь.

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

Работают ли Astro Actions на пререндеренной странице?

Нет. Чтобы вызвать action через action формы, страница должна рендериться по запросу, поэтому добавьте 'export const prerender = false' на страницу с формой и установите адаптер, чтобы существовал сервер для запуска обработчика. У тел запросов к actions также есть ограничение по умолчанию в 1 МБ (1048576 байт); увеличьте security.actionBodySizeLimit, если обработчик должен принимать что-то большее, например загружаемый файл.

Может ли пререндеренная статическая страница показывать интерфейс для авторизованного или неавторизованного пользователя?

На сервере — нет. Пререндеренная страница записывается на диск на этапе сборки, и каждый посетитель получает один и тот же файл, так что нет заголовка cookie, по которому можно было бы ветвиться. Работают два решения: вывести маршрут из пререндеринга через 'export const prerender = false' либо оставить страницу статической и запрашивать пользователя в браузере с эндпоинта, рендерящегося по запросу, передавая результат в клиентский компонент.

Защищает ли Astro формы входа от CSRF автоматически?

Частично. На страницах, рендерящихся по запросу, Astro сравнивает заголовок origin, отправленный браузером, с URL, на который пришёл запрос, и отвечает 403, если они расходятся. Это поведение включено по умолчанию начиная с Astro 5 через опцию security.checkOrigin и покрывает только межсайтовые отправки форм. Вам всё равно нужно регенерировать идентификатор сессии при входе и отдельно авторизовывать каждый обработчик action и API-маршрут. Установка security.checkOrigin в false отключает проверку.

Что использовать для отправки неавторизованных пользователей на страницу входа: context.rewrite или Astro.redirect?

Используйте context.rewrite в middleware, когда хотите отдать контент страницы входа по тому URL, который запросил посетитель: защищённый путь останется в адресной строке, а браузеру не придётся делать повторный запрос. Astro.redirect возвращает ответ-редирект, и браузер переходит на /login. Rewrite запускает новый рендер, и ваш middleware отрабатывает снова, поэтому исключите /login из списка защищённых путей, иначе та же проверка будет проваливаться раз за разом.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

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