HTTP API закрыт за вход через OIDC у Authelia

- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
This commit is contained in:
av
2026-08-12 17:44:22 +03:00
parent d676df8a27
commit c44f0e7582
37 changed files with 3997 additions and 65 deletions
+71
View File
@@ -99,6 +99,77 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
на `/_/` кодом `200`.
## Вход через OIDC: что выяснилось при реализации
Дописано 2026-08-12 задачей `oidc-login`. Провенанс общий: чтение исходников
`pocketbase@v0.39.10` из кеша модулей плюс прогоны против настоящего хранилища на
временном каталоге, все — в ходе ревью того change. Живой Authelia в прогонах не
было ни разу: провайдера подменял свой `httptest`-сервер.
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
`CreateRule = ""` (создание доступно анониму) и `PasswordAuth.Enabled = true`
(`migrations/1640988000_init.go`, `core/collection_model_auth_options.go`).
Прогон подтвердил: `POST /api/collections/users/records``200`, следом
`auth-with-password``200` с токеном. То есть закрытие API за вход обходится
двумя запросами, пока эта поверхность не закрыта своим шагом схемы.
**Правило создания нельзя закрывать полностью.** `CreateRule = nil` означает «только
суперпользователь», а запись при первом входе заводит **внутренний** запрос
самого обмена, идущий без таких прав (`apis/record_crud.go`: проверка
`!hasSuperuserAuth && collection.CreateRule == nil`). Прогон: с `nil` вход
кончался `401`, учётных записей `0`. Работает правило
`@request.context = "oauth2"` — контекст ставит сам обмен
(`core.RequestInfoContextOAuth2`), а посторонний запрос приходит с контекстом по
умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен
принимает поля создаваемой записи от вызывающего.
**Обмен кода наружу не экспортирован.** Пакет `apis` отдаёт ошибки, middleware,
`NewRouter`, `Serve` и обёртки; сам обмен — неэкспортированная функция за
маршрутом `POST /api/collections/{c}/auth-with-oauth2`, принимающая `provider`,
`code`, `codeVerifier`, `redirectURL`. Собственный `/api/oauth2-redirect` служит
другому — он ищет клиента realtime-подписки по параметру `state`, то есть
обслуживает всплывающее окно JS-клиента, а не серверный вход.
**`apis.NewRouter` не идемпотентна: собирать её нужно один раз и держать, а не
создавать заново при каждом вызове.**
Она зовёт `bindRealtimeEvents` и `bindUIExtensions`, а те вешают девять
обработчиков **на приложение** и без поля `Id`; `hook.Bind` такому генерирует
новый идентификатор и **добавляет**. Замер: пять вызовов подряд подняли
`OnModelAfterUpdateSuccess` с 4 до 14, а 3000 вызовов — время сотни сохранений
записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только
перезапуск.
**Связывание учётной записи идёт по `sub`, а не найдя — по почте.** Обмен ищет
запись в `_externalAuths` по `providerId`, и лишь затем `FindAuthRecordByEmail`
(`apis/record_auth_with_oauth2.go`). Отсюда цена открытой регистрации: запись,
заведённая посторонним на чужой адрес почты, достаётся первому же настоящему
входу с этим адресом.
**Защищённое поле файла судится двумя вещами сразу** — коротким токеном файла из
строки запроса **и** правилом просмотра коллекции (`apis/file.go`). Незаданное
правило означает «только суперпользователь», поэтому одной пометки `Protected`
мало: прогон показал `404` анониму, вошедшему кукой, вошедшему заголовком и
вошедшему с законно полученным токеном файла — пока правило не назначено.
**Сессия по умолчанию продлеваема бессрочно.** Токен несёт поле
`refreshable=true`, и `POST /api/collections/{c}/auth-refresh` меняет его на
новый с новым сроком. Прогон: три продления подряд, каждое `200`, `exp` растёт.
Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только
слоем приложения поверх маршрута.
**Подпись сессии считается от секрета коллекции и ключа записи**, обе величины в
базе (`core/record_query.go`, `FindAuthRecordByToken`). Отсюда два следствия:
сессия переживает перезапуск сервиса сама, а смена ключа записи
(`Record.RefreshTokenKey()`) обесценивает все её выданные сессии разом.
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
суток; проект умолчание не переопределяет.
## Что отвергнуто и почему
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто: