вход переехал на доверенный заголовок 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
|
||||
}
|
||||
Reference in New Issue
Block a user