HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
|
||||
@@ -15,6 +18,7 @@ import (
|
||||
// `apis.Serve` прежде, чем поднять сервер.
|
||||
func init() {
|
||||
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||
migrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
||||
}
|
||||
|
||||
func up202608110001(app core.App) error {
|
||||
@@ -119,4 +123,130 @@ func down202608110001(app core.App) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
|
||||
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
|
||||
// применяется, потому что оно никем не выбрано.
|
||||
//
|
||||
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
|
||||
// провайдера. Причина та же: применённый шаг не переписывается, и число,
|
||||
// положенное туда, разошлось бы со сроком жизни куки при первой же правке —
|
||||
// браузер получил бы новый срок, а хранилище продолжило выдавать прежний.
|
||||
const SessionDuration = 7 * 24 * 60 * 60
|
||||
|
||||
// defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат.
|
||||
const defaultAuthTokenDuration = 1209600
|
||||
|
||||
// up202608120001 закрывает поверхность, которую хранилище приносит своим
|
||||
// системным шагом, и защищает файл записи.
|
||||
//
|
||||
// Коллекция пользователей заводится библиотекой с открытым созданием записи и
|
||||
// включённым входом по паролю. Без этого шага закрытие API обходится двумя
|
||||
// запросами: завести себе учётную запись, войти паролем, предъявить полученное
|
||||
// заголовком. Отдельная цена открытого создания — захват учётной записи: обмен
|
||||
// кода ищет запись сперва по неизменяемому признаку провайдера, а не найдя —
|
||||
// по адресу почты, и запись, заведённая посторонним на чужой адрес, достаётся
|
||||
// первому же настоящему входу с этим адресом.
|
||||
func up202608120001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
// Завести учётную запись можно только входом у провайдера.
|
||||
//
|
||||
// Правило именно такое, а не `nil`: запись при первом входе заводит
|
||||
// внутренний запрос самого обмена, и он идёт без прав суперпользователя —
|
||||
// глухое `nil` отвергло бы его наравне с посторонним, и войти не смог бы
|
||||
// никто. Контекст `oauth2` ставит обмен (`core.RequestInfoContextOAuth2`),
|
||||
// а посторонний запрос приходит с контекстом по умолчанию.
|
||||
//
|
||||
// Открывать правило пустой строкой нельзя: публичный обмен принимает поля
|
||||
// создаваемой записи от вызывающего, и всякий владелец учётной записи у
|
||||
// провайдера задал бы их сам.
|
||||
users.CreateRule = ptr(`@request.context = "oauth2"`)
|
||||
users.PasswordAuth.Enabled = false
|
||||
users.OTP.Enabled = false
|
||||
|
||||
// Провайдер включается здесь с пустыми значениями: адреса, идентификатор
|
||||
// клиента и секрет приходят из конфига при каждом подъёме. Положенный сюда
|
||||
// секрет не пережил бы ротации — применённый шаг не переписывается.
|
||||
users.OAuth2.Enabled = true
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to close users collection surface: %w", err)
|
||||
}
|
||||
|
||||
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find files collection: %w", err)
|
||||
}
|
||||
|
||||
// Ссылка на файл перестаёт быть правом пройти по ней: до этого шага знание
|
||||
// ссылки и было доступом, а отзыва у неё нет. Конвейер этим не затронут —
|
||||
// он читает файл из файловой системы хранилища, а не по ссылке.
|
||||
//
|
||||
// Комментарий прежнего шага утверждает обратное — «защищённым поле не
|
||||
// помечено намеренно». Прежний шаг не переписывается, поэтому решение
|
||||
// отменяется здесь: право прочитать запись больше не даёт знание её
|
||||
// идентификатора.
|
||||
field, ok := files.Fields.GetByName("file").(*core.FileField)
|
||||
if !ok {
|
||||
return errors.New("files collection has no file field")
|
||||
}
|
||||
field.Protected = true
|
||||
|
||||
// Одной пометки мало: защищённый файл судится ещё и правилом просмотра
|
||||
// коллекции, а незаданное правило означает «только владелец панели» — файл
|
||||
// не получил бы и вошедший. Правило пускает всякого узнанного: владельца у
|
||||
// записи ещё нет, и сужать выборку эта задача не должна.
|
||||
files.ViewRule = ptr(`@request.auth.id != ""`)
|
||||
|
||||
if err := app.Save(files); err != nil {
|
||||
return fmt.Errorf("failed to protect record file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// down202608120001 возвращает умолчания библиотеки — те, что стояли до шага.
|
||||
//
|
||||
// Открытое создание записи сюда не возвращается намеренно: это ровно то, что
|
||||
// шаг и закрывал, и откат, восстанавливающий анонимную регистрацию, оставил бы
|
||||
// сервис хуже, чем он был до задачи. Срок жизни сессии возвращается
|
||||
// умолчанием, а не нулём: нулевую длительность валидация коллекции отвергает, и
|
||||
// прежний откат падал на ней, не дойдя до снятия защиты с файла.
|
||||
func down202608120001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
users.CreateRule = nil
|
||||
users.PasswordAuth.Enabled = true
|
||||
users.OTP.Enabled = true
|
||||
users.OAuth2.Enabled = false
|
||||
users.OAuth2.Providers = nil
|
||||
users.AuthToken.Duration = defaultAuthTokenDuration
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to restore users collection: %w", err)
|
||||
}
|
||||
|
||||
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find files collection: %w", err)
|
||||
}
|
||||
|
||||
if field, ok := files.Fields.GetByName("file").(*core.FileField); ok {
|
||||
field.Protected = false
|
||||
}
|
||||
files.ViewRule = nil
|
||||
|
||||
if err := app.Save(files); err != nil {
|
||||
return fmt.Errorf("failed to unprotect record file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
|
||||
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
|
||||
const ProviderName = "oidc"
|
||||
|
||||
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
|
||||
// коллекции.
|
||||
type ProviderSettings struct {
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
UserInfoURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
|
||||
// к значениям конфига.
|
||||
//
|
||||
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
|
||||
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
|
||||
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
|
||||
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
|
||||
// только руками в панели.
|
||||
//
|
||||
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
|
||||
// имя коллекции, а не значения.
|
||||
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
|
||||
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
|
||||
// сроком жизни куки.
|
||||
users.AuthToken.Duration = SessionDuration
|
||||
|
||||
users.OAuth2.Enabled = true
|
||||
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: ProviderName,
|
||||
ClientId: settings.ClientID,
|
||||
ClientSecret: settings.ClientSecret,
|
||||
AuthURL: settings.AuthURL,
|
||||
TokenURL: settings.TokenURL,
|
||||
UserInfoURL: settings.UserInfoURL,
|
||||
DisplayName: "Authelia",
|
||||
}}
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user