Обработку входа в систему можно организовать и без сервиса авторизации. Можно с нуля написать полноценную систему аутентификации, работающую на основе сеансов. Для этого портребуется связать базу данных PostgreSQL с веб-приложением — и такая конструкция будет работать с любым фронтендом, будь то JavaScript, фреймворк, Java, ещё что-то. Логика авторизации заключена в базе данных, поэтому не важно, какой именно фреймворк используется на фронтенде.

В этом руководстве я расскажу, как реализовать систему аутентификации на основе сеансов. Для этого нам понадобится пара простейших расширений Postgres — pgsodium и pgcrypto — при помощи которых можно выкатить собственный механизм хеширования и хранить пароли лишь в виде хешей. В итоге получим простое приложение, в котором будут прописаны все ожидаемые маршруты: регистрация, вход в систему, выход из системы, сеанс (отладка), сброс пароля. Сосредоточимся, как именно устроена такая работа с сеансами, и как создаются учётные записи. Сброс пароля – тема для отдельной статьи.

Весь код к этой статье выложен в публичном репозитории — схема, миграции, сохранённые процедуру, а также полное веб-приложение. Так что можете читать его код параллельно с этим постом или просто запустить на локальной машине.

На основе сеансов или на основе токенов: действительно важное отличие

Может показаться, что «сеанс» и «JWT» - синонимы. Это близкие понятия, но отличаются они тем, где в каждом из случаев хранится состояние и как именно обеспечивается авторизация. Это важнее, чем сам этап входа в систему.

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

На основе сеансов

  1. Клиент отправляет запрос на вход в систему.

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

  3. Сервис возвращает ID этого сеанса в куки-файле. Этот куки-файл находится в браузере и является временным хранилищем данных.

  4. Этот куки-файл передаётся с каждым последующим запросом. База данных заново смотрит этот ID сеанса, проверяет, действителен ли он до сих пор и охватывает ли при этом те области видимости, которые нужны для данного маршрута. После этого она либо возвращает данные, либо выбрасывает 401/403.

Ключевой момент: эта строка в Postgres и является источником истины. Таким образом, в любой момент можно отменить пребывание любого пользователя на сервере, удалив или инвалидировав соответствующую строку.

На основе токенов (JWT)

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

  • они несут JWT (это токен, обычно записываемый в куки-файле или заголовке авторизации).

  • В JWT записан набор утверждений — свойства метаданных (ID пользователя, роли, права доступа, срок их истечения). Затем эта информация декодируется при отправке на сервер.

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

Таким образом, сеанс сохраняет состояние (при каждой операции проверяется база данных), тогда как JWT не сохраняет (авторизация проводится по утверждениям, сопровождающим токен). Хорошо известно, какой компромисс при этом возникает: JWT легко масштабируются, но их сложно удалить (пока не истечёт срок их годности), тогда как любой сеанс можно завершить мгновенно.

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

Хеш: argon2id, а не bcrypt

Когда пользователь регистрируется, вы получаете его пароль в виде обычного текста, но не сохраняете в такой форме. Вы сохраняете только хеш.

new user + plaintext password  →  argon2id hash  →  stored in auth.users.password_hash

Я пользуюсь Argon2id. Он пришёл на смену bcrypt (bcrypt по-прежнему работает, но Argon2id в настоящее время является стандартом) и специально предназначен для того, чтобы противостоять атакам, выполняемым простым перебором (в лоб), в том числе, с GPU-ускорением. Именно это и требуется в продакшене.

При использовании pgsodium и на хеширование, и на верификацию требуется по одному вызову функции:

-- pgsodium: hash a password (Argon2id)
select crypto_pwhash_str('one2345678', 'argon2id');

-- pgsodium: verify a login against a stored hash, returns boolean
select crypto_pwhash_verify('one2345678', 'argon2id', $stored_hash);

В хеш включаются его собственные параметры/соль, так, что в каждой строке хранится собственный вариант хеша — не требуется отдельный столбец для соли. (В pgcrypto инструментарий округляется так, что вещи вроде gen_random_uuid() упрощаются до mint-ID.)

Cхема: изолированная схема авторизации

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

Таким образом, сырые таблицы авторизации не поддерживают прямых запросов из кода приложения. То есть, нельзя выбрать * из auth.users прямо из вашего приложения. Приходится действовать через функции, благодаря чему поверхность для потенциальных атак остаётся небольшой.

auth.users

uid            uuid pk
email          text
password_hash  text        -- argon2id hash, never plaintext
role           text        -- e.g. 'user'
metadata       jsonb       -- open-ended key/value (like Supabase's auth.users)
created_at     timestamptz

Поле метаданных jsonb — это область «свободы». Здесь можно хранить любые пары ключ/значение — возраст, профиль компании, флаги фич, без ограничений. Эти поля работают точно как auth.users.raw_user_meta_data в Supabase.

auth.sessions

session_id  uuid pk
user_id     uuid fk -> auth.users
token_hash  text          -- never return the raw token to the client
is_usable   bool
is_revoked  bool
created_at  timestamptz
expires_at  timestamptz   -- set the TTL you want
ip          inet          -- optional, for bot/scraping heuristics
user_agent  text          -- optional, same reason

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

В такой сборке каждый пользователь может иметь всего один активный сеанс. Если необходимо открыть сеанс с нескольких устройств одновременно, то действует такая же схема: мы просто разрешаем множество строк auth.sessions на каждый user_id, а не обязываем каждого пользователя иметь всего одну живую строку.

auth.password_resets

Содержит одноразовые токены сброса (token_hash, expires_at, used_at). Я не буду рассматривать их в этом посте — их правильнее выделить в собственную тему. Механика здесь очень похожа: генерируем новый токен, отправляем по электронной почте ссылку, один раз проверяем, применяем новый пароль, затем инвалидируем.

Вспомогательные функции (сохранённые процедуры)

В схеме авторизации все операции авторизации предоставляются в виде функций. Это «точка выхода», которую можно вызвать из вашего приложения, а не просто сырые таблицы. Приложение ни в каком случае не затрагивает auth.users или auth.sessions напрямую.

Функция

Назначение

authenticate_session(token_hash)

Находит сеанс по его хешу; возвращает ID пользователя, электронную почту, ID сеанса (или отказывает → 401).

create_session(user_id, token_hash, user_agent, ttl, ...)

Создаёт сеанс после регистрации/входа в систему; возвращает ID сеанса.

revoke_session(session_id)

Завершает один сеанса (выходит из системы). Устанавливает is_revoked = true, is_usable = false.

revoke_all_sessions(user_id)

Завершает все сеансы конкретного пользователя (напр., "выход на всех устройствах").

create_password_reset(...)

Создание токена сброса.

get_user(uid) / get_user_by_email(email)

Выборка пользователя для конкретного промежуточного ПО.

Вот и вся стратегия безопасности.

Проследим весь поток задач

Регистрируемся. Клиент отправляет методом POST адрес электронной почты, пароль и произвольные метаданные в формате ключ/значение. Внутри базы данных:

  1. пароль хешируется при помощи argon2id, а далее сохраняется в auth.users.password_hash.

  2. Новая строка оказывается в auth.users.

  3. Создаётся строка с профилями, которая делается общедоступной.

  4. На том же шаге создаётся сеанс, после чего ID сеанса отправляется обратно в виде куки-файла.

Так регистрируется, что вы уже прошли аутентификацию.

Состояние аутентификации. Действуем по паттерну «промежуточное ПО». Фактически, на любом защищённом маршруте ваш фронтенд (или слой API) выполняет промежуточное ПО, спрашивающее: «этот пользователь прошёл аутентификацию»? Чтобы ответить на этот вопрос, вызывается authenticate_session с токеном из куки-файла. Если он разрешается, то маршрут отображается. В противном случае программа возвращает 401 или 403.

Выход из системы. При нажатии на кнопку выхода из системы вызывается revoke_session. Строка данного сеанса помечается как is_revoked = true и is_usable = false — сеанса больше нет. Следующий authenticate_session не удастся, так что вы выходите из системы мгновенно.

Ещё один уровень изоляции: выделенная роль в базе данных

Поверх изоляции схемы создаётся выделенный пользователь в базе данных, специально для веб-приложения. Эта роль может видеть лишь публичную схему. Схема авторизации полностью скрывается от приложения, доступ к ней имеет только суперпользователь. Даже если роль приложения будет скомпрометирована, в радиус поражения попадёт только публичная область. Таблицы авторизации затронуты не будут.

Как это соотносится со всей остальной системой

Здесь стоит проследить связь между двумя вещами:

  • Вся конструкция работает из репозитория. Схема, миграция, сохранённые процедуры и приложение на основе SSR (React + Vite + Express) находятся в theodufort/postgresql-authentication-system. Приложение подключается к Postgres в минимально привилегированной роли auth_app, также в репозитории содержатся 13 сквозных тестов, прогоняющих процедуру регистрация → вход в систему → отладка → выход из системы → забыл пароль/сброс пароля на живой базе данных.

  • Всю важную работу здесь выполняют расширения. pgsodium и pgcrypto — это не декорации.  Именно благодаря им вы можете хешировать и генерировать токены внутри Postgres. Есть совсем немного таких расширений, которые я считаю существенными (и pgsodium среди них), я описал их среди  пяти важнейших расширений PostgreSQL для Supabase.

  • Изоляция обязательно должна войти в привычку при работе в продакшене, это не просто «полезное дополнение». Выделенная роль приложения + скрытая схема авторизации — именно та составляющая, которая отличает демо-версию от такого приложения, которое можно сдавать. Шире я рассматриваю эту картину в чеклисте для проверки готовности к продакшену.

Код

Вся реализация лежит в theodufort/postgresql-authentication-system. Это сквозная сборка: схема Postgres плюс небольшое приложение с рендерингом на стороне сервера (SSR). Состоит из React + Vite + Express и демонстрирует весь проделанный путь — регистрация → логин → аутентифицированный сеанс → выход из системы → забытый пароль/сброс пароля.

Что в репозитории

database/                  Postgres + схема (упакована в Docker)
  Dockerfile               Образ Postgres, расширенный pgsodium и pg_cron
  docker-compose.yml       Локальная база данных для разработки, расположенная на порте 15432 (auth_db)
  erd/schema.puml(+png)    ERD—таблицы слева, процедуры справа 
  src/migrations/          Одна миграция: вся система авторизации
  src/entity/Profile.ts    Единственная сущность TypeORM (public.profiles)

web/                       Приложение, демонстрирующее рендеринг на стороне сервера (React + Vite + Express)
  server/auth.ts           Единственный модуль, который может затрагивать авторизацию снаружи auth.* (через procs)
  server/routes/auth.ts    Конечные точки /api/auth 
  server/api.test.ts       13 сквозных тестов, прогоняемых на живой базе данных 
  src/app/…                Страницы: регистрация, вход в систему, выход из системы, забыл пароль/сброс, отладка

Стои т рассматривать ERD (database/erd/schema.puml), даже, если вы её даже не выполняете: auth.users, auth.sessions, auth.password_resets, public.profiles с одной стороны, а с другой — каждая из сохранённых процедур с заметкой о том, что именно она возвращает.

Пара деталей реализации, добавляемых в репозитории поверх концепций, уже описанных выше:

  • Токены никогда не хранятся в сыром виде. Сырой сеансовый токен состоит из 256 бит вывода CSPRNG, устанавливается в виде куки  httpOnly. В базу данных попадает только дайджест SHA-256 — так что даже из полной резервной копии базы данных не утекает ничего, что можно было бы воспроизвести.

  • Все служебные работы автоматизированы. pg_cron ежечасно выполняет auth.prune_expired(), физически удаляя истёкшие/удалённые сеансы и отработанные токены сброса, так что таблицы авторизации остаются небольшими.

  • Полностью реализованы сбросы паролей — одноразовые токены сроком действия 1 час создаются через auth.create_password_reset / auth.consume_password_reset, а после того, как потреблён токен сброса удаляются все сеансы данного пользователя. Эта тема заслуживает глубокого разбора в отдельном посте, но пока его нет – можете изучить полностью рабочую версию, лежащую в репозитории.)

Быстрый старт

1. База данных (Postgres + pgsodium + pg_cron, port 15432):

cd database
docker compose up -d
export DATABASE_URL=postgres://postgres:postgres@localhost:15432/auth_db
npx typeorm-ts-node-commonjs -d src/data-source.ts migration:run
unset DATABASE_URL
docker compose exec -T db psql -U postgres -d auth_db \
  -c "ALTER ROLE auth_app LOGIN PASSWORD 'your-secret';"

2. Приложение — подключается не как суперпользователь, а в наименее привилегированной роли:

cd ../web
cp .env.example .env    # затем устанавливаем DATABASE_URL=postgres://auth_app:yoursecret@localhost:15432/auth_db
npm install
npm test                # 13 сквозных тестов, прогоняемых на живой базе данных
npm run dev             # → http://localhost:5173

Затем откроем сайт, создадим учётную запись и просматриваем отладочную страницу сеанса, в частности, всё, что знает о нас сервер: сырой куки, ID сеанса, обратный отсчёт срока годности — и ничего более. Никакие учётные данные в сохранённые процедуры не попадают.

Что далее

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

Комментарии (0)