HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
## Context
|
||||
|
||||
Сегодня HTTP API открыт наружу без проверки — так записано первой строкой модели
|
||||
угроз. Приглашение второго человека упирается в это: у записей нет владельца, а
|
||||
подобранный идентификатор задачи отдаёт чужую расшифровку.
|
||||
|
||||
Решение от 2026-08-11 (`ADR-2026-08-11-pocketbase-storage-with-admin-panel`)
|
||||
назвало способ: **ответ провайдера разбирает хранилище, а не наш код**. У
|
||||
коллекции пользователей включается провайдер `oidc` с адресами Authelia, учётные
|
||||
записи заводятся сами, и панель их видит. Проверено на версии 0.39.10 —
|
||||
`docs/research/pocketbase.md`, раздел «Пользователи — только те, кого туда
|
||||
положат».
|
||||
|
||||
**Способ остаётся верным, но его механика уже проверена по исходникам
|
||||
библиотеки, и три ожидания постановки она не подтверждает.** Проверено чтением
|
||||
`pocketbase@v0.39.10`:
|
||||
|
||||
1. **Куки библиотека не читает вовсе.** Сессию она берёт единственным способом —
|
||||
заголовком `Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
|
||||
Критерий приёмки задачи написан про куку.
|
||||
2. **Эндпоинта выхода библиотека не приносит.** Список её адресов
|
||||
аутентификации — `auth-methods`, `auth-refresh`, `auth-with-password`,
|
||||
`auth-with-oauth2`, `request-otp`, `auth-with-otp`, восстановление пароля,
|
||||
подтверждение почты и смена почты (`apis/record_auth.go`). Выхода среди них
|
||||
нет.
|
||||
3. **Браузерный вход по редиректу библиотека своим не приносит.** Она приносит
|
||||
обмен уже полученного кода: `POST /api/collections/{c}/auth-with-oauth2`
|
||||
требует `provider`, `code`, `codeVerifier` и `redirectURL`. Её собственный
|
||||
`/api/oauth2-redirect` служит другому: он ищет клиента realtime-подписки по
|
||||
параметру `state` и отдаёт код туда (`apis/record_auth_with_oauth2_redirect.go`)
|
||||
— это механика её JS-клиента с всплывающим окном, а не серверный вход.
|
||||
|
||||
Отсюда объём: инициировать вход, принять возврат и завести куку — наш код.
|
||||
Разбор ответа провайдера, заведение учётной записи и связь с внешним провайдером
|
||||
остаются за хранилищем, как и решено. Решение 2026-08-11 не пересматривается.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- запрос к приёму записи и к опросу готовности без сессии получает отказ и
|
||||
ничего не заводит;
|
||||
- вход идёт у Authelia по OIDC, учётные записи заводятся сами;
|
||||
- выход закрывает доступ немедленно, а не по истечении срока;
|
||||
- сессия переживает выкладку;
|
||||
- проба здоровья и метрики остаются открытыми.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- владелец у записи и сужение выборки по нему — задача `record-ownership`;
|
||||
- вход для программ по личным токенам — отдельная цель роадмапа. Внешняя
|
||||
программа, ходившая в API анонимно, этим изменением ломается намеренно, и
|
||||
замены ей здесь не появляется;
|
||||
- белый список Telegram — живёт до `telegram-account-link`;
|
||||
- панель администратора — в неё провайдер не пускает, наружу её закрывает
|
||||
обратный прокси;
|
||||
- своя страница входа со скриптом: у сервиса нет фронтенда, и заводить его ради
|
||||
входа незачем.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Сессия предъявляется кукой, а заголовок остаётся внутренним
|
||||
|
||||
**Выбрано:** наш обработчик возврата ставит куку `HttpOnly`, `Secure`,
|
||||
`SameSite=Lax` со значением, выданным хранилищем. Промежуточный слой перед
|
||||
проверкой перекладывает значение куки в заголовок `Authorization`, если заголовка
|
||||
нет. Дальше работает штатная проверка библиотеки.
|
||||
|
||||
Человек увидит обычный вход: перешёл, авторизовался у Authelia, вернулся —
|
||||
работает. Ни строки скрипта на его стороне.
|
||||
|
||||
Отвергнуто:
|
||||
|
||||
- **заголовок `Authorization` как единственный способ.** Это механика библиотеки
|
||||
и путь наименьшего кода, но браузер такой заголовок сам не шлёт: понадобился
|
||||
бы свой фронтенд, который держит значение и подставляет его. Фронтенда у
|
||||
сервиса нет, а заводить его ради входа — работа шире задачи. Критерий приёмки
|
||||
задачи вдобавок написан про куку;
|
||||
- **своя таблица сессий.** Даёт полный контроль над выходом и сроком, но заводит
|
||||
второй способ делать то, что хранилище уже делает, — и второй дом для факта
|
||||
«кто вошёл». Отвергнуто по концептуальной целостности.
|
||||
|
||||
Заголовок при этом остаётся рабочим: его требуют собственные адреса
|
||||
аутентификации хранилища, и глушить их значит ломать библиотеку изнутри. Это
|
||||
осознанно оставленная вторая дверь, и она названа в спеке.
|
||||
|
||||
### Форма решения выбрана из трёх, а не из одной
|
||||
|
||||
Прежде трёх решений ниже — выбор самой формы. Рассматривались три.
|
||||
|
||||
**A — свой тонкий слой входа поверх хранилища.** Выбрана. Наш код ведёт флоу и
|
||||
ставит куку, обмен кода и заведение учётной записи остаются за хранилищем.
|
||||
Цена: сверка состояния и установка куки — наша ответственность, то есть ошибки
|
||||
в чувствительном месте наши.
|
||||
|
||||
**B — вход целиком на обратном прокси.** Authelia стоит перед сервисом и не
|
||||
пускает неузнанные запросы, приложение доверяет заголовку от прокси. Нашего кода
|
||||
почти ноль. Отвергнуто по трём причинам сразу: при прямом обращении к порту
|
||||
заголовок подделывает кто угодно в той же сети, а сервис не имеет способа
|
||||
отличить прокси от постороннего; учётные записи в панели не появляются вовсе, а
|
||||
решение 2026-08-11 требует обратного; вход для программ по личным токенам из
|
||||
этой формы не вырастает — его пришлось бы делать заново и мимо.
|
||||
|
||||
**C — фронтенд и штатный клиент хранилища.** Своя страница, всплывающее окно,
|
||||
подписка, значение сессии в хранилище браузера. Всё штатно для библиотеки.
|
||||
Отвергнуто: у сервиса нет фронтенда, и заводить его ради входа — работа шире
|
||||
задачи; значение сессии становится доступно скриптам страницы, то есть XSS
|
||||
уносит сессию целиком, тогда как кука с запретом чтения скриптом этого не даёт.
|
||||
|
||||
### Вход и возврат ведёт наш код, разбор ответа — хранилище
|
||||
|
||||
**Выбрано:** три своих адреса — начало входа, возврат от провайдера, выход.
|
||||
Начало входа заводит `state` и PKCE-verifier, кладёт их во временную куку и
|
||||
уводит человека на `authURL` провайдера. Возврат сверяет `state`, а код отдаёт
|
||||
хранилищу вызовом его же обмена — тем, что стоит за `auth-with-oauth2`.
|
||||
|
||||
**Обмен кода библиотека наружу не отдаёт** — он живёт неэкспортированной
|
||||
функцией за собственным адресом хранилища. Решением владельца от 2026-08-12
|
||||
обработчик возврата зовёт **этот адрес внутри процесса**, через роутер
|
||||
хранилища, а не по сети.
|
||||
|
||||
Цена названа и принята: получается петля «наш обработчик → наш роутер → наш
|
||||
обработчик», ответ разбирается текстом, а типизированная ошибка теряется.
|
||||
Взамен решение 2026-08-11 соблюдается дословно — разбор ответа провайдера
|
||||
остаётся за хранилищем, и учётные записи видны в панели.
|
||||
|
||||
Отвергнуто:
|
||||
|
||||
- **собрать обмен своими руками** из кусков, которые библиотека всё же отдаёт.
|
||||
Прямой код без петли, но разбор ответа провайдера переезжает к нам — это
|
||||
пересмотр решения 2026-08-11 отдельным ADR, и владелец его не выбрал;
|
||||
- **всплывающее окно и realtime-подписка**, как делает JS-клиент библиотеки.
|
||||
Работает без нашего кода вовсе, но требует того самого фронтенда и держит
|
||||
открытым realtime-соединение ради одного входа.
|
||||
|
||||
### Кого пускать, решает провайдер, а не сервис
|
||||
|
||||
Решением владельца от 2026-08-12 сервис своей проверки допуска **не делает**:
|
||||
кто допущен, определяет правило Authelia на этого клиента. Всякий, кого
|
||||
провайдер пропустил, получает учётную запись и доступ.
|
||||
|
||||
Цена принята и обязана быть записанной: правило живёт вне репозитория, в
|
||||
настройках выкладки, и сервис на него полагается так же, как полагается на
|
||||
обратный прокси в части панели администратора. Настроенный слишком широко
|
||||
клиент открывает сервис всем, у кого есть учётная запись в общей Authelia, — и
|
||||
проверить это по коду нельзя. Строка об этом идёт в `docs/security.md`, раздел
|
||||
«Что разграничивает доступ».
|
||||
|
||||
Отвергнуто: **проверка группы своим кодом** — защита стояла бы в сервисе и не
|
||||
зависела от настройки контура, но владелец выбрал не заводить второе место, где
|
||||
решается допуск.
|
||||
|
||||
### Выход обесценивает выданные сессии, а не только убирает куку
|
||||
|
||||
**Выбрано:** выход обновляет ключ токенов учётной записи
|
||||
(`Record.RefreshTokenKey()` плюс сохранение) и убирает куку. Подпись сессии
|
||||
считается от этого ключа, поэтому все прежние значения перестают проходить
|
||||
разом.
|
||||
|
||||
Отвергнуто:
|
||||
|
||||
- **только уборка куки.** Унесённое значение продолжало бы открывать доступ до
|
||||
истечения срока — то есть выход не закрывал бы доступ, а делал вид;
|
||||
- **чёрный список выданных значений.** Даёт точечный выход одной сессии, но
|
||||
требует своей таблицы и её чистки; при одном человеке и одном браузере это
|
||||
цена без покупателя.
|
||||
|
||||
Цена выбранного названа прямо: выход закрывает **все** сессии учётной записи, а
|
||||
не только текущую. При сегодняшнем числе пользователей это незаметно, и
|
||||
переделка, когда станет заметно, — чёрный список из отвергнутого варианта.
|
||||
|
||||
### Сессия переживает перезапуск сама
|
||||
|
||||
Проверено по исходникам: подпись считается от секрета коллекции
|
||||
(`Collection().AuthToken.Secret`) и ключа записи, оба лежат в базе
|
||||
(`core/record_query.go`, `FindAuthRecordByToken`). Значит требование выполняется
|
||||
устройством хранилища, и нашей работы здесь нет — есть проверка тестом.
|
||||
|
||||
### Настройки провайдера приводятся к конфигу при каждом запуске
|
||||
|
||||
**Выбрано:** шаг схемы включает провайдера с пустыми значениями, а адреса,
|
||||
идентификатор клиента и секрет проставляются при подъёме сервиса из конфига.
|
||||
|
||||
Причина в инварианте: **применённый шаг схемы не переписывается**. Проставь
|
||||
секрет однажды шагом — и ротация секрета в конфиге до хранилища не доедет вовсе,
|
||||
вход сломается после смены ключа, а починить это можно будет только руками в
|
||||
панели.
|
||||
|
||||
Отвергнуто:
|
||||
|
||||
- **секрет в шаге схемы.** Разбито инвариантом выше;
|
||||
- **настройка руками в панели.** Работает, но не воспроизводится: поднятый с
|
||||
нуля сервис оказывается без входа, и знание живёт в голове владельца.
|
||||
|
||||
### Что изменило ревью кода
|
||||
|
||||
Три решения приняты владельцем 2026-08-12 уже после того, как код был написан:
|
||||
ревью нашло, что заявленное поведение не работает.
|
||||
|
||||
**Продление сессии выключено.** Хранилище выдаёт сессию продлеваемой, и
|
||||
предъявитель менял своё значение на новое бессрочно, никуда не входя. При живом
|
||||
продлении семисуточный срок не значил ничего — а он объявлен единственным
|
||||
каналом, которым отзыв доступа у провайдера доходит до сервиса. Цена: вход раз в
|
||||
семь суток. Отвергнуто: сверяться с провайдером по расписанию (новая связь с
|
||||
Authelia и обработка её недоступности — работа шире задачи) и принять как есть
|
||||
(тогда паспорт теряет способ остановить того, кто тратит слишком много).
|
||||
|
||||
**Файл записи открыт вошедшим.** Пометка поля защищённым сама по себе закрыла
|
||||
файл вообще для всех, кроме владельца панели: защищённый файл судится ещё и
|
||||
правилом просмотра коллекции, а незаданное правило означает «только
|
||||
суперпользователь». Назначено правило для всякого узнанного. Отвергнуто:
|
||||
оставить файл только панели — тогда задача про прослушивание записи начинается с
|
||||
того же вопроса.
|
||||
|
||||
**Форма адреса провайдера проверяется на старте.** Непустая, но негодная строка
|
||||
проходила проверку конфига и отвергалась хранилищем позже — из хука подъёма, до
|
||||
регистрации пробы здоровья. Сервис падал целиком, вместе с ботом и воркерами, а
|
||||
у владельца не было даже кода состояния. Отвергнуто: поднимать пробу здоровья
|
||||
раньше настройки провайдера — это завело бы состояние «сервис жив, вход сломан»,
|
||||
которого спека не описывает.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Секрет клиента появляется в новом месте — в базе.** → Инвариант проекта
|
||||
запрещает секрету попадать в git, в лог, в ответ и в `error_text`; база в этом
|
||||
перечне не значится, и запрета не нарушает. Но место новое, и модель угроз
|
||||
обязана его назвать: чтение файла базы теперь равносильно чтению секрета
|
||||
клиента. Пишется в `docs/security.md` этой же задачей.
|
||||
- **Ломается внешняя программа, ходившая в API анонимно.** → Ломка намеренная и
|
||||
объявлена в предложении: это и есть предмет задачи. Замены для программ
|
||||
(личные токены) в этом изменении нет — она отдельной целью.
|
||||
- **Вторая дверь: заголовок `Authorization` остаётся принимаемым.** → Он
|
||||
предъявляет ту же сессию и той же проверке, поэтому обхода не даёт. Но это
|
||||
второй способ войти, и в спеке он назван, чтобы не был обнаружен ревью как
|
||||
находка.
|
||||
- **Выход закрывает все сессии учётной записи.** → Названо решением выше, цена
|
||||
принята.
|
||||
- **PKCE-verifier и `state` живут во временной куке.** → Кука ставится на время
|
||||
входа, `HttpOnly` и `SameSite=Lax`, и убирается на возврате. Хранить их в
|
||||
памяти процесса нельзя: выкладка посреди входа роняла бы вход.
|
||||
- **Признак `Secure` закрывает локальный запуск.** → Браузер не сохранит такую
|
||||
куку по `http://localhost`, и вход перестанет работать у того, кто поднимает
|
||||
сервис командой из раздела команд. Признак берётся из конфига с умолчанием
|
||||
«включено», и расхождение образца называется строкой в
|
||||
`docs/conventions/config.md`.
|
||||
- **Коллекция пользователей остаётся умолчательной `users`.** → Своя коллекция
|
||||
означала бы задание правил и способов входа с нуля вместо подчистки
|
||||
умолчаний, а переезд позже — перевязку связей с провайдером и обесценивание
|
||||
всех выданных сессий. Цена умолчательной: её заводит системный шаг библиотеки
|
||||
с открытым созданием записи, и закрывать это приходится нам.
|
||||
- **Проверить вход целиком без живой Authelia нельзя.** → Тесты закрывают
|
||||
сверку `state`, отказ без сессии, выход и сохранность сессии; живой вход у
|
||||
провайдера остаётся ручной проверкой владельца на выкладке. Это граница
|
||||
покрытия, и она называется в докладе.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Шаг схемы включает провайдера у коллекции пользователей и накатывается при
|
||||
подъёме, как и прежние шаги. Данных он не трогает: ни одной записи не
|
||||
переписывается, учётные записи заводятся сами при первом входе.
|
||||
|
||||
Откат — прежний образ: шаг схемы обратим своим `down`, а до первого входа в
|
||||
коллекции пользователей пусто.
|
||||
|
||||
Порядок выкладки: сперва завести клиента в Authelia и получить секрет, потом
|
||||
положить его в конфиг на сервере, потом выкладывать. Обратный порядок поднимает
|
||||
сервис с провайдером без секрета — вход не работает, а API уже закрыт.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Адрес возврата должен совпадать с тем, что записан клиенту в Authelia. Значение
|
||||
выбирается при заведении клиента и попадает в конфиг; здесь оно не
|
||||
фиксируется.
|
||||
Reference in New Issue
Block a user