вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// MaxProviderLoginLength — предел длины логина у провайдера.
|
||||
//
|
||||
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
|
||||
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
|
||||
// же, что у имени в умолчании библиотеки: длиннее имени логин не бывает, а два
|
||||
// разных предела на соседних колонках одной записи разошлись бы молча.
|
||||
const MaxProviderLoginLength = 255
|
||||
|
||||
// MaxDisplayNameLength — предел длины имени, пригодного к показу. Число то же и
|
||||
// по той же причине: столько держит колонка имени в умолчании библиотеки.
|
||||
const MaxDisplayNameLength = 255
|
||||
|
||||
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
|
||||
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а
|
||||
// негодный ввод, и звать по нему учётную запись не надо.
|
||||
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
|
||||
|
||||
// Identity — то, чем доверенный источник называет пришедшего.
|
||||
//
|
||||
// Логин — ключ, остальное берётся только при заведении записи.
|
||||
type Identity struct {
|
||||
Login string
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
// EnsureUser находит учётную запись по логину у провайдера, а не найдя —
|
||||
// заводит её.
|
||||
//
|
||||
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
|
||||
// представиться — личные токены — придёт следующей задачей и возьмёт этот же
|
||||
// метод; правило, уложенное куском в слой транспорта, пришлось бы тогда либо
|
||||
// дублировать вторым куском, либо вытаскивать задним числом.
|
||||
//
|
||||
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
|
||||
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
|
||||
// его работы.
|
||||
//
|
||||
// Сравнение точное, знак в знак: приведение регистра завело бы правило, которого
|
||||
// у провайдера нет, — считает ли он `admin` и `Admin` одним человеком, сервису
|
||||
// неизвестно, а угаданное правило склеило бы двух разных людей.
|
||||
func EnsureUser(app core.App, identity Identity) (record *core.Record, created bool, err error) {
|
||||
login, ok := AcceptProviderLogin(identity.Login)
|
||||
if !ok {
|
||||
return nil, false, ErrLoginNotAcceptable
|
||||
}
|
||||
|
||||
record, err = findUserByLogin(app, login)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if record != nil {
|
||||
return record, false, nil
|
||||
}
|
||||
|
||||
users, err := findCollection(app, migrations.UsersCollection)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
record = core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
// Имя и почта принимаются так же, как логин, а не кладутся как есть.
|
||||
// Значения приходят заголовками, то есть задаются тем, кто шлёт запрос;
|
||||
// имя длиннее предела колонки отвергается проверкой записи, и человек с
|
||||
// таким именем у провайдера не завёлся бы **никогда** — каждый его запрос
|
||||
// отвечал бы отказом сервиса. Негодное значение необязательного поля не
|
||||
// вправе отменять заведение записи.
|
||||
record.Set("name", acceptDisplayName(identity.Name))
|
||||
if email, ok := acceptEmail(identity.Email); ok {
|
||||
record.SetEmail(email)
|
||||
}
|
||||
// Пароль записи обязателен при любом значении признака — это проверка самой
|
||||
// библиотеки, а не колонки. Ставится случайный: употребить его нельзя,
|
||||
// потому что вход по паролю у коллекции выключен шагом схемы.
|
||||
record.SetRandomPassword()
|
||||
|
||||
if err := app.Save(record); err != nil {
|
||||
record, err = retryAfterConflict(app, login, record, err)
|
||||
return record, record != nil, err
|
||||
}
|
||||
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// retryAfterConflict разбирает отказ сохранения. Два отказа уникальности здесь
|
||||
// разные, и исход у них разный.
|
||||
//
|
||||
// По **ключевой** колонке — это гонка двух первых обращений одним логином:
|
||||
// запись успел завести соседний запрос, и надо просто взять его. Отказ, который
|
||||
// после повторного поиска никуда не делся, — уже не гонка, и его отдают наверх.
|
||||
//
|
||||
// По **любой другой** — почта, пришедшая от провайдера, занята другой учётной
|
||||
// записью: общий почтовый ящик, семья, группа. Запись заводится без почты; она
|
||||
// необязательна, а ключом не служит. Без этого разреза второй человек с общим
|
||||
// адресом не завёлся бы никогда — повторный поиск по логину снова ничего не
|
||||
// нашёл бы, и исход выродился бы либо в цикл, либо в вечный отказ без внятной
|
||||
// причины.
|
||||
func retryAfterConflict(app core.App, login string, record *core.Record, saveErr error) (*core.Record, error) {
|
||||
if isUniqueViolation(saveErr, migrations.ProviderLoginField) {
|
||||
existing, err := findUserByLogin(app, login)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
|
||||
}
|
||||
|
||||
if !isUniqueViolation(saveErr, core.FieldNameEmail) {
|
||||
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
|
||||
}
|
||||
|
||||
record.SetEmail("")
|
||||
if err := app.Save(record); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user account without email: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// findUserByLogin ищет учётную запись по ключу. Значение уходит хранилищу
|
||||
// **параметром** запроса, а не подстановкой в текст фильтра: строка приходит
|
||||
// снаружи, и подставленная в текст она правила бы сам запрос, а не только его
|
||||
// аргумент.
|
||||
func findUserByLogin(app core.App, login string) (*core.Record, error) {
|
||||
records, err := app.FindRecordsByFilter(
|
||||
migrations.UsersCollection,
|
||||
migrations.ProviderLoginField+" = {:login}",
|
||||
"", 1, 0,
|
||||
map[string]any{"login": login},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to look up user account: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return records[0], nil
|
||||
}
|
||||
|
||||
// uniqueViolationCode — каким кодом библиотека называет отказ уникальности.
|
||||
//
|
||||
// Разбор идёт по **коду**, а не по имени текста и не по тексту драйвера: текст
|
||||
// у драйвера свой на каждую версию, а имя колонки не говорит о причине.
|
||||
const uniqueViolationCode = "validation_not_unique"
|
||||
|
||||
// isUniqueViolation говорит, отказала ли по названной колонке проверка
|
||||
// **уникальности** — а не какая-нибудь другая.
|
||||
//
|
||||
// Разница не педантизм. Под ключом `email` библиотека складывает и отказ
|
||||
// уникальности, и отказ формы адреса; проверка «есть ли ключ в карте» считала
|
||||
// бы опечатку прокси занятым семейным ящиком и молча заводила бы запись без
|
||||
// почты. На ключевой колонке та же неточность когда-нибудь выстрелит громче:
|
||||
// любой отказ проверки логина читался бы как гонка двух первых обращений.
|
||||
func isUniqueViolation(err error, field string) bool {
|
||||
var errs validation.Errors
|
||||
if !errors.As(err, &errs) {
|
||||
return false
|
||||
}
|
||||
|
||||
fieldErr, ok := errs[field]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var object validation.ErrorObject
|
||||
if !errors.As(fieldErr, &object) {
|
||||
return false
|
||||
}
|
||||
|
||||
return object.Code() == uniqueViolationCode
|
||||
}
|
||||
|
||||
// acceptDisplayName приводит имя к годному для колонки значению.
|
||||
//
|
||||
// Обрезается по пределу колонки и чистится от управляющих знаков — тем же
|
||||
// приёмом, каким приём записи чистит имя файла отправителя. Пустое значение
|
||||
// законно: имени у человека может не быть вовсе.
|
||||
func acceptDisplayName(value string) string {
|
||||
name := strings.TrimSpace(stripControls(value))
|
||||
|
||||
runes := []rune(name)
|
||||
if len(runes) > MaxDisplayNameLength {
|
||||
return string(runes[:MaxDisplayNameLength])
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// acceptEmail отдаёт адрес почты, если он вообще похож на адрес.
|
||||
//
|
||||
// Негодный отбрасывается **здесь**, а не отказом сохранения: иначе опечатка в
|
||||
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью
|
||||
// «почта занята», и владелец искал бы общий ящик там, где сломан контур.
|
||||
func acceptEmail(value string) (string, bool) {
|
||||
email := strings.TrimSpace(value)
|
||||
if email == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if err := is.EmailFormat.Validate(email); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return email, true
|
||||
}
|
||||
|
||||
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
|
||||
// им делать нечего.
|
||||
func stripControls(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
|
||||
// отвергает его.
|
||||
//
|
||||
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
|
||||
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
|
||||
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
|
||||
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
|
||||
//
|
||||
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
|
||||
// называет одного человека, а две записи о нём разошлись бы молча.
|
||||
func AcceptProviderLogin(value string) (string, bool) {
|
||||
login := strings.TrimSpace(value)
|
||||
|
||||
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и
|
||||
// два предела в разных единицах разошлись бы вдвое на любой кириллице.
|
||||
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, r := range login {
|
||||
if unicode.IsControl(r) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
return login, true
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// Проверки узнавания: как учётная запись находится и как заводится.
|
||||
|
||||
// Заведение идемпотентно: второе обращение попадает в ту же запись и не
|
||||
// переписывает её.
|
||||
//
|
||||
// Не переписывает — половина требования, и она отдельная: перепись на каждом
|
||||
// запросе означала бы запись в базу на каждый запрос, а правка имени у
|
||||
// провайдера меняла бы карточку человека молча, посреди его работы.
|
||||
func TestEnsureUserIsIdempotent(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
first, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Алиса", Email: "alice@example.test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Другое имя", Email: "other@example.test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first.Id, second.Id, "второе обращение завело вторую запись")
|
||||
assert.Equal(t, "Алиса", second.GetString("name"), "имя переписано вторым обращением")
|
||||
assert.Equal(t, "alice@example.test", second.Email(), "почта переписана вторым обращением")
|
||||
|
||||
assert.Equal(t, 1, countUsers(t, app))
|
||||
}
|
||||
|
||||
// Одновременные первые обращения одним логином дают одну учётную запись.
|
||||
//
|
||||
// Проверка стоит потому, что норма без неё держалась бы на одном уникальном
|
||||
// индексе: забытый в шаге схемы, он дал бы зелёную приёмку и две учётные записи
|
||||
// на одного человека — а архив разъехался бы между ними молча и склеить его было
|
||||
// бы нечем.
|
||||
func TestEnsureUserSurvivesConcurrentFirstRequests(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
const racers = 8
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ids := make([]string, racers)
|
||||
errs := make([]error, racers)
|
||||
|
||||
start := make(chan struct{})
|
||||
for i := range racers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: "racer", Name: "Гонщик"})
|
||||
errs[i] = err
|
||||
if record != nil {
|
||||
ids[i] = record.Id
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
require.NoError(t, err, "обращение %d отказало", i)
|
||||
}
|
||||
for i, id := range ids {
|
||||
assert.Equal(t, ids[0], id, "обращение %d попало в другую учётную запись", i)
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, countUsers(t, app), "гонка завела больше одной учётной записи")
|
||||
}
|
||||
|
||||
// Занятая почта не мешает завести запись: она достаётся первому, а второй
|
||||
// заводится без неё.
|
||||
//
|
||||
// Общий почтовый ящик — обычное дело в семье, а Authelia вправе отдать один
|
||||
// адрес группе. Без разреза двух отказов уникальности второй человек не завёлся
|
||||
// бы никогда: повторный поиск по логину снова ничего не находит.
|
||||
func TestEnsureUserWithTakenEmail(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
first, _, err := EnsureUser(app, Identity{Login: "one", Email: "family@example.test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, _, err := EnsureUser(app, Identity{Login: "two", Email: "family@example.test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, first.Id, second.Id)
|
||||
assert.Equal(t, "family@example.test", first.Email(), "почта досталась первому")
|
||||
assert.Empty(t, second.Email(), "второму почта не досталась, но запись завелась")
|
||||
assert.Equal(t, 2, countUsers(t, app))
|
||||
}
|
||||
|
||||
// Вырожденный логин никого не узнаёт и ничего не заводит.
|
||||
func TestEnsureUserRejectsDegenerateLogin(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
values := map[string]string{
|
||||
"пустой": "",
|
||||
"одни пробелы": " \t ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", MaxProviderLoginLength+1),
|
||||
}
|
||||
|
||||
for name, value := range values {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
record, _, err := EnsureUser(app, Identity{Login: value})
|
||||
|
||||
assert.Nil(t, record)
|
||||
require.ErrorIs(t, err, ErrLoginNotAcceptable)
|
||||
assert.Equal(t, 0, countUsers(t, app))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
|
||||
// называет одного человека, и две записи о нём разошлись бы молча.
|
||||
func TestEnsureUserTrimsSurroundingSpaces(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
first, _, err := EnsureUser(app, Identity{Login: "alice"})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, _, err := EnsureUser(app, Identity{Login: " alice "})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first.Id, second.Id)
|
||||
assert.Equal(t, 1, countUsers(t, app))
|
||||
}
|
||||
|
||||
// Сравнение точное: приведение регистра завело бы правило, которого у
|
||||
// провайдера нет, — и склеило бы двух разных людей.
|
||||
func TestEnsureUserComparesExactly(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
lower, _, err := EnsureUser(app, Identity{Login: "alice"})
|
||||
require.NoError(t, err)
|
||||
|
||||
upper, _, err := EnsureUser(app, Identity{Login: "Alice"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, lower.Id, upper.Id)
|
||||
assert.Equal(t, 2, countUsers(t, app))
|
||||
}
|
||||
|
||||
// Значение, похожее на условие отбора, ищется как значение, а не как часть
|
||||
// запроса: оно уходит хранилищу параметром.
|
||||
func TestEnsureUserDoesNotLetLoginChangeTheQuery(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
victim, _, err := EnsureUser(app, Identity{Login: "victim"})
|
||||
require.NoError(t, err)
|
||||
|
||||
attacker, _, err := EnsureUser(app, Identity{Login: `x" || provider_login = "victim`})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, victim.Id, attacker.Id,
|
||||
"значение изменило сам запрос и вернуло чужую учётную запись")
|
||||
assert.Equal(t, 2, countUsers(t, app))
|
||||
}
|
||||
|
||||
func countUsers(t *testing.T, app core.App) int {
|
||||
t.Helper()
|
||||
|
||||
records, err := app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// Признак заведения отличает первое обращение от всех следующих.
|
||||
//
|
||||
// По нему слой узнавания пишет строку журнала, и без него владелец не отличит
|
||||
// «никто не заходил» от «завелось двадцать»: убрать заведённую запись потом
|
||||
// нечем.
|
||||
func TestEnsureUserReportsWhetherItCreated(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
_, created, err := EnsureUser(app, Identity{Login: "alice"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, created, "первое обращение не назвалось заведением")
|
||||
|
||||
_, created, err = EnsureUser(app, Identity{Login: "alice"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, created, "второе обращение назвалось заведением")
|
||||
}
|
||||
|
||||
// Негодное имя не отменяет заведения: оно обрезается по пределу колонки.
|
||||
//
|
||||
// Прежде имя уходило в колонку как есть, и человек с длинным именем у
|
||||
// провайдера получал отказ сервиса на **каждом** запросе — учётная запись не
|
||||
// заводилась никогда, а починить у себя он ничего не мог.
|
||||
func TestEnsureUserAcceptsDegenerateName(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
long := strings.Repeat("я", MaxDisplayNameLength+50)
|
||||
|
||||
record, created, err := EnsureUser(app, Identity{Login: "bob", Name: long})
|
||||
require.NoError(t, err, "негодное имя отменило заведение записи")
|
||||
require.True(t, created)
|
||||
|
||||
name := record.GetString("name")
|
||||
assert.Equal(t, MaxDisplayNameLength, utf8.RuneCountInString(name), "имя не обрезано по пределу")
|
||||
assert.NotEmpty(t, name)
|
||||
}
|
||||
|
||||
// Управляющие знаки из имени убираются: значение приезжает заголовком.
|
||||
func TestEnsureUserStripsControlsFromName(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: "carol", Name: "Ка\x00ро\nл"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Карол", record.GetString("name"))
|
||||
}
|
||||
|
||||
// Негодная почта отбрасывается **явно**, а не через ветвь «почта занята».
|
||||
//
|
||||
// Иначе опечатка в контуре неотличима от общего семейного ящика, и владелец
|
||||
// ищет второго человека там, где сломан прокси.
|
||||
func TestEnsureUserDropsMalformedEmail(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
record, created, err := EnsureUser(app, Identity{Login: "dave", Email: "не-адрес"})
|
||||
require.NoError(t, err, "негодная почта отменила заведение записи")
|
||||
require.True(t, created)
|
||||
|
||||
assert.Empty(t, record.Email())
|
||||
}
|
||||
|
||||
// Предел логина считается в знаках, а не в байтах: колонка считает знаки.
|
||||
//
|
||||
// Прежде кириллический логин длиннее половины предела отвергался навсегда,
|
||||
// хотя колонка приняла бы его.
|
||||
func TestEnsureUserCountsLoginInRunes(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
login := strings.Repeat("я", MaxProviderLoginLength)
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: login})
|
||||
require.NoError(t, err, "логин ровно на пределе отвергнут: предел считается в байтах")
|
||||
assert.Equal(t, login, record.GetString(migrations.ProviderLoginField))
|
||||
|
||||
_, _, err = EnsureUser(app, Identity{Login: strings.Repeat("я", MaxProviderLoginLength+1)})
|
||||
assert.ErrorIs(t, err, ErrLoginNotAcceptable, "логин сверх предела принят")
|
||||
}
|
||||
|
||||
// Ключ учётной записи не меняется после заведения — ни правкой в панели, ни
|
||||
// прямым сохранением.
|
||||
//
|
||||
// Правила доступа коллекции закрывают только путь снаружи; панель работает
|
||||
// суперпользователем. Переписанный ключ отдал бы весь архив следующему, кто
|
||||
// придёт с этим именем, и вернуть его было бы нечем.
|
||||
func TestProviderLoginIsImmutable(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: "victim-owner"})
|
||||
require.NoError(t, err)
|
||||
|
||||
record.Set(migrations.ProviderLoginField, "someone-else")
|
||||
err = app.Save(record)
|
||||
require.Error(t, err, "ключ учётной записи переписан прямым сохранением")
|
||||
|
||||
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "victim-owner", stored.GetString(migrations.ProviderLoginField))
|
||||
}
|
||||
|
||||
// Правка прочих полей учётной записи при этом проходит: хук сторожит один ключ,
|
||||
// а не запирает коллекцию целиком.
|
||||
func TestUserRecordStaysEditableExceptTheKey(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
record, _, err := EnsureUser(app, Identity{Login: "editable", Name: "Прежнее"})
|
||||
require.NoError(t, err)
|
||||
|
||||
record.Set("name", "Новое")
|
||||
require.NoError(t, app.Save(record), "правка имени в панели отвергнута")
|
||||
|
||||
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Новое", stored.GetString("name"))
|
||||
}
|
||||
|
||||
// Две учётные записи без ключа уживаются: индекс частичный, как и соседний
|
||||
// индекс почты.
|
||||
//
|
||||
// Сплошной индекс ронял бы накатку шага на всякой базе, где записей больше
|
||||
// одной, — то есть у разработчика, ходившего прежним рецептом входа.
|
||||
func TestEmptyProviderLoginDoesNotCollide(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, name := range []string{"Первый", "Второй"} {
|
||||
record := core.NewRecord(users)
|
||||
record.Set("name", name)
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record), "вторая запись без ключа отвергнута индексом")
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, countUsers(t, app))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// ProviderLoginField — колонка, в которой лежит ключ учётной записи: логин
|
||||
// человека у провайдера, тот самый, которым его называет обратный прокси.
|
||||
//
|
||||
// Имя говорит о происхождении значения, а не о заголовке, которым оно приехало:
|
||||
// заголовок — способ доставки и может смениться, а логин у провайдера — то, чем
|
||||
// значение является. Колонка уезжает шагом схемы и потому не переименовывается.
|
||||
const ProviderLoginField = "provider_login"
|
||||
|
||||
// providerLoginIndex — имя уникального индекса по ключу учётной записи.
|
||||
const providerLoginIndex = "idx_users_provider_login"
|
||||
|
||||
// up202608220001 переводит узнавание пришедшего с протокола OIDC на логин,
|
||||
// который называет доверенный источник.
|
||||
//
|
||||
// Три части, и каждая закрывает своё.
|
||||
//
|
||||
// Первая — ключ учётной записи. Прежде идентичность человека лежала в системной
|
||||
// таблице внешних учётных записей библиотеки: её вела механика обмена кода, и
|
||||
// правил её только владелец панели. Механика уходит, и ключу нужен свой дом —
|
||||
// колонка с уникальным индексом. Почта ключом не годится: провайдер не обязан
|
||||
// её приносить, человек её меняет, а первое обращение с чужим адресом досталось
|
||||
// бы чужой записи.
|
||||
//
|
||||
// Вторая — необязательная почта. Умолчание библиотеки требует непустого адреса
|
||||
// у всякой учётной записи; заголовка с почтой может не быть вовсе, а
|
||||
// уникальность почты держится **частичным** индексом (`WHERE email != ”`),
|
||||
// поэтому записи без почты уживаются друг с другом. Пароль остаётся
|
||||
// обязательным при любом значении признака — ему ставится случайный, употребить
|
||||
// его нельзя: вход по паролю у коллекции выключен прежним шагом.
|
||||
//
|
||||
// Третья — поверхность коллекции пользователей. Умолчание библиотеки открывает
|
||||
// владельцу записи чтение, правку и удаление собственной строки, и до сих пор
|
||||
// это ничему не мешало ровно потому, что до поверхности хранилища браузер с
|
||||
// кукой не дотягивался: слой предъявления жил под корнем приложения. С
|
||||
// узнаванием по заголовку такая защита перестаёт быть защитой, а ключ учётной
|
||||
// записи лежит теперь обычной колонкой — то есть правка своей записи и есть
|
||||
// захват чужого имени: поставил себе ключом чужой логин, и первое обращение
|
||||
// настоящего его владельца попало бы в твою запись вместе со всем архивом.
|
||||
// Правила снимаются в пустое, что у хранилища означает «только владелец
|
||||
// панели»; наш код читает и заводит запись мимо правил, панель работает
|
||||
// суперпользователем, своих экранов профиля сервис не заводит.
|
||||
func up202608220001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
users.Fields.Add(&core.TextField{
|
||||
Name: ProviderLoginField,
|
||||
// Предел тот же, что у имени в умолчании библиотеки: логин длиннее
|
||||
// имени не бывает, а колонка без предела принимала бы килобайты,
|
||||
// пришедшие заголовком.
|
||||
Max: 255,
|
||||
})
|
||||
// Индекс **частичный** — ровно как соседний индекс почты у той же коллекции.
|
||||
// Сплошной запретил бы вторую запись с пустым ключом, а такая заводится
|
||||
// рукой в панели и остаётся у всякой базы, пережившей прежний вход: подъём
|
||||
// на ней ронял бы накатку шага отказом уникальности, и сервис не стартовал
|
||||
// бы вовсе. Пустым ключом при этом не узнаётся никто — это держит приём
|
||||
// значения, а не индекс.
|
||||
users.AddIndex(providerLoginIndex, true, ProviderLoginField, ProviderLoginField+" != ''")
|
||||
|
||||
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
|
||||
if !ok {
|
||||
return errors.New("users collection has no email field")
|
||||
}
|
||||
email.Required = false
|
||||
|
||||
// Механика OIDC снимается целиком: настройки провайдера больше не приводятся
|
||||
// к конфигу при подъёме, и обменивать код не на что.
|
||||
users.OAuth2.Enabled = false
|
||||
users.OAuth2.Providers = nil
|
||||
|
||||
// Наглухо все пять: заведение записи идёт нашим кодом, мимо правил.
|
||||
users.ListRule = nil
|
||||
users.ViewRule = nil
|
||||
users.CreateRule = nil
|
||||
users.UpdateRule = nil
|
||||
users.DeleteRule = nil
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to switch users collection to provider login: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// down202608220001 убирает ключ учётной записи и возвращает обязательность
|
||||
// почты.
|
||||
//
|
||||
// Правила доступа сюда не возвращаются намеренно — ни открытое создание записи,
|
||||
// которое закрывал прежний шаг, ни открытая правка, которую закрывает этот.
|
||||
// Откат, восстанавливающий их, оставил бы сервис хуже, чем он был: правка своей
|
||||
// записи открыта только тому, кто узнан, а узнают теперь по колонке, которую эта
|
||||
// правка и переписывает.
|
||||
func down202608220001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
users.RemoveIndex(providerLoginIndex)
|
||||
users.Fields.RemoveByName(ProviderLoginField)
|
||||
|
||||
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
|
||||
if !ok {
|
||||
return errors.New("users collection has no email field")
|
||||
}
|
||||
email.Required = true
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to restore users collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -51,6 +51,7 @@ func init() {
|
||||
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
|
||||
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
|
||||
pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go")
|
||||
pbmigrations.Register(up202608220001, down202608220001, "202608220001_trusted_header_login.go")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
@@ -31,6 +33,8 @@ import (
|
||||
// бы тем же сохранением, а число отказов остановленной записи — которое
|
||||
// остановка хранит намеренно — приходило бы владельцу нулём.
|
||||
func BindPanelRules(app core.App) {
|
||||
bindProviderLoginIsImmutable(app)
|
||||
|
||||
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
original := e.Record.Original()
|
||||
if original == nil {
|
||||
@@ -84,3 +88,44 @@ func BindPanelRules(app core.App) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// bindProviderLoginIsImmutable запрещает менять ключ учётной записи после
|
||||
// заведения.
|
||||
//
|
||||
// Ключ — логин человека у провайдера, и по нему сервис узнаёт пришедшего.
|
||||
// Переписанный, он отдаёт весь архив прежнего владельца следующему, кто придёт
|
||||
// с этим именем: владелец записи назначается один раз и не меняется, так что
|
||||
// вернуть архив будет нечем. Молча — журнала событий у коллекции пользователей
|
||||
// нет.
|
||||
//
|
||||
// Правила доступа коллекции закрывают этот путь **снаружи**, но не изнутри:
|
||||
// панель работает суперпользователем и правила обходит по построению. Отсюда
|
||||
// хук, и он вешается на **модельное** событие, а не на правку запросом — иначе
|
||||
// панель осталась бы незакрытой, а закрывать её и есть весь смысл.
|
||||
//
|
||||
// Заведение проходит: событие правки на нём не срабатывает вовсе.
|
||||
//
|
||||
// Прежнее значение читается **из базы**, а не из снимка правящейся записи.
|
||||
// Снимок у записи, только что заведённой в этом же процессе, пуст — он не
|
||||
// обновляется сохранением, — и сторож, опирающийся на него, пропускал бы правку
|
||||
// в зависимости от того, откуда вызывающий взял запись. Панель её загружает, и
|
||||
// на ней сторож сработал бы; молчаливая же зависимость от способа получения —
|
||||
// ровно тот класс, из-за которого правило и заводится.
|
||||
func bindProviderLoginIsImmutable(app core.App) {
|
||||
app.OnRecordUpdate(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
||||
stored, err := e.App.FindRecordById(migrations.UsersCollection, e.Record.Id)
|
||||
if err != nil {
|
||||
// Записи в базе нет — правки тоже нет: сохранение отвергнется само.
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
was := stored.GetString(migrations.ProviderLoginField)
|
||||
now := e.Record.GetString(migrations.ProviderLoginField)
|
||||
|
||||
if was != "" && was != now {
|
||||
return errors.New("provider login is assigned once and never changes")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
|
||||
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
|
||||
const ProviderName = "oidc"
|
||||
|
||||
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
|
||||
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
|
||||
// применяется, потому что оно никем не выбрано.
|
||||
//
|
||||
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
|
||||
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
|
||||
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
|
||||
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
|
||||
// хранилище продолжило выдавать прежний.
|
||||
const SessionDuration = 7 * 24 * 60 * 60
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -74,6 +74,35 @@ func TestRecordContentIsClosedEverywhere(t *testing.T) {
|
||||
|
||||
// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а
|
||||
// пустая копия висела бы в панели вторым домом для понятия, которого больше нет.
|
||||
// Поверхность коллекции пользователей закрыта наглухо — все пять правил.
|
||||
//
|
||||
// Проверка стоит отдельно от соседней намеренно: та сторожит коллекции, которые
|
||||
// заводит наш шаг схемы, а эту заводит системный шаг библиотеки, и её умолчания
|
||||
// открывают владельцу записи чтение, правку и удаление собственной строки. Пока
|
||||
// узнавание жило под корнем приложения, до этой поверхности браузер не
|
||||
// дотягивался вовсе; с узнаванием по заголовку она достижима, а ключ учётной
|
||||
// записи лежит здесь обычной колонкой — правка своей записи и есть захват чужого
|
||||
// имени.
|
||||
func TestUsersCollectionSurfaceIsClosed(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Nil(t, users.ListRule, "перечисление учётных записей закрыто")
|
||||
assert.Nil(t, users.ViewRule, "чтение учётной записи закрыто")
|
||||
assert.Nil(t, users.CreateRule, "заведение учётной записи снаружи закрыто")
|
||||
assert.Nil(t, users.UpdateRule, "правка учётной записи снаружи закрыта")
|
||||
assert.Nil(t, users.DeleteRule, "удаление учётной записи снаружи закрыто")
|
||||
|
||||
// Собственные способы войти выключены там же: без этого узнавание по
|
||||
// заголовку обходится двумя запросами — завести себе запись и войти паролем.
|
||||
assert.False(t, users.PasswordAuth.Enabled, "вход по паролю выключен")
|
||||
assert.False(t, users.OTP.Enabled, "вход по одноразовому коду выключен")
|
||||
assert.False(t, users.OAuth2.Enabled, "обмен кода у внешнего провайдера выключен")
|
||||
assert.Empty(t, users.OAuth2.Providers, "настроенных провайдеров не осталось")
|
||||
}
|
||||
|
||||
func TestFormerJobsCollectionIsGone(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user