вход переехал на доверенный заголовок Authelia вместо собственного OIDC

- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
av
2026-08-22 20:24:22 +03:00
parent e4441f3c49
commit 7f33c957e5
63 changed files with 5257 additions and 2305 deletions
@@ -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 }
+45
View File
@@ -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)
+58 -56
View File
@@ -3,9 +3,8 @@ package config
import (
"errors"
"fmt"
"net/url"
"net/netip"
"os"
"sort"
"strings"
"time"
@@ -81,64 +80,67 @@ type YandexConfig struct {
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
// секрет, положенный однажды шагом, не пережил бы ротации.
// AuthConfig — кому сервис верит на входе.
//
// Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком,
// сходив к провайдеру. Секция поэтому свелась к одному ключу — перечню адресов,
// чьему заголовку верить. Ни адресов провайдера, ни идентификатора клиента, ни
// его секрета здесь больше нет: обменивать код не на что, и секрет ушёл из
// конфига вместе с протоколом.
type AuthConfig struct {
AuthURL string `toml:"auth_url"`
TokenURL string `toml:"token_url"`
UserInfoURL string `toml:"user_info_url"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
RedirectURL string `toml:"redirect_url"`
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
// выключается только для локального запуска по `http://localhost`, где
// браузер такую куку не сохранит.
SecureCookie bool `toml:"secure_cookie"`
// TrustedProxies — адреса и подсети, чьему заголовку верят. Значение
// сверяется с адресом самого соединения, а не с пересылаемым заголовком:
// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
// той же строкой, которой он обходится, не барьер вовсе.
TrustedProxies []string `toml:"trusted_proxies"`
}
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
// открытым наружу, а узнать об этом было бы неоткуда.
// TrustedNetworks разбирает перечень доверенных адресов.
//
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
// молча падает целиком, вместе с ботом и воркерами.
// Одиночный адрес принимается наравне с подсетью и превращается в подсеть на
// один адрес: писать `/32` руками значит помнить разрядность, а перечень читает
// человек.
func (c AuthConfig) TrustedNetworks() ([]netip.Prefix, error) {
networks := make([]netip.Prefix, 0, len(c.TrustedProxies))
for _, raw := range c.TrustedProxies {
value := strings.TrimSpace(raw)
if prefix, err := netip.ParsePrefix(value); err == nil {
networks = append(networks, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(value)
if err != nil {
return nil, fmt.Errorf("auth: %s не читается как адрес или подсеть: %q", trustedProxiesKey, value)
}
networks = append(networks, netip.PrefixFrom(addr, addr.BitLen()))
}
return networks, nil
}
// trustedProxiesKey — имя ключа в отказах старта. Литерал один на файл: два
// разошлись бы молча, и владелец искал бы в конфиге ключ, которого там нет.
const trustedProxiesKey = "trusted_proxies"
// Validate проверяет, что сервису есть кому верить.
//
// Пустой перечень роняет старт. Он значит «не верить никому», то есть сервис,
// поднявшийся никого не узнающим, — и молчать об этом старт не вправе: узнать о
// такой поломке было бы неоткуда, все адреса приложения просто отвечали бы
// отказом.
//
// Нечитаемая строка роняет старт по той же причине: перечень с опечаткой
// проверяется только тем, что кто-то не смог войти.
func (c AuthConfig) Validate() error {
values := map[string]string{
"auth_url": c.AuthURL,
"token_url": c.TokenURL,
"user_info_url": c.UserInfoURL,
"client_id": c.ClientID,
"client_secret": c.ClientSecret,
"redirect_url": c.RedirectURL,
if len(c.TrustedProxies) == 0 {
return fmt.Errorf("auth: не заполнен ключ %s: сервису некому верить, и узнать он никого не сможет", trustedProxiesKey)
}
missing := make([]string, 0, len(values))
for name, value := range values {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
// Названы имена ключей, а не значения: значение `client_secret` в
// сообщение об ошибке попасть не должно, оно уедет в журнал.
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
}
malformed := make([]string, 0, 4)
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
parsed, err := url.Parse(values[name])
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
malformed = append(malformed, name)
}
}
if len(malformed) > 0 {
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
if _, err := c.TrustedNetworks(); err != nil {
return err
}
return nil
@@ -173,9 +175,9 @@ func defaultConfig() *Config {
ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
},
Auth: AuthConfig{
SecureCookie: true,
},
// Умолчания у перечня доверенных адресов нет намеренно: подставленное
// значение соврало бы ровно там, где по нему решают, кого пускать.
Auth: AuthConfig{},
}
}
+35 -67
View File
@@ -8,97 +8,65 @@ import (
"time"
)
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
// исполнялась ни разу.
// Проверка перечня доверенных адресов — единственная страховка от того, чтобы
// сервис поднялся никого не узнающим. Узнать о такой поломке было бы неоткуда:
// все адреса приложения просто отвечали бы отказом.
func validAuthConfig() AuthConfig {
return AuthConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "secret-value",
RedirectURL: "https://transcriber.example.com/auth/callback",
}
return AuthConfig{TrustedProxies: []string{"172.16.0.0/12", "127.0.0.1"}}
}
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
if err := validAuthConfig().Validate(); err != nil {
t.Fatalf("заполненный конфиг отвергнут: %v", err)
t.Fatalf("заполненный перечень отвергнут: %v", err)
}
}
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
cases := map[string]func(*AuthConfig){
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
"client_id": func(c *AuthConfig) { c.ClientID = "" },
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
}
for key, clear := range cases {
t.Run(key, func(t *testing.T) {
cfg := validAuthConfig()
clear(&cfg)
err := cfg.Validate()
if err == nil {
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
}
if !strings.Contains(err.Error(), key) {
t.Fatalf("имя ключа %s не названо: %v", key, err)
}
})
}
}
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
// и значения секрета в нём быть не должно — только имя ключа.
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
cfg := validAuthConfig()
cfg.ClientSecret = "super-secret-value"
cfg.AuthURL = ""
err := cfg.Validate()
// Пустой перечень значит «не верить никому»: сервис поднялся бы, никого не
// узнавая, и молчать об этом старт не вправе.
func TestAuthConfigValidateRejectsEmptyList(t *testing.T) {
err := AuthConfig{}.Validate()
if err == nil {
t.Fatal("отказа нет")
t.Fatal("пустой перечень принят: сервис поднялся бы никого не узнающим")
}
if strings.Contains(err.Error(), "super-secret-value") {
t.Fatalf("значение секрета попало в текст отказа: %v", err)
if !strings.Contains(err.Error(), "trusted_proxies") {
t.Fatalf("имя ключа не названо: %v", err)
}
}
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
cases := map[string]string{
"без схемы": "auth.example.com/api/oidc/authorization",
"пробел спереди": " https://auth.example.com/authorize",
"чужая схема": "ftp://auth.example.com/authorize",
"пустой хост": "https:///authorize",
"не адрес вовсе": "todo: заполнить",
}
for name, value := range cases {
t.Run(name, func(t *testing.T) {
cfg := validAuthConfig()
cfg.AuthURL = value
// Нечитаемая строка роняет старт: перечень с опечаткой проверяется только тем,
// что кто-то не смог войти.
func TestAuthConfigValidateRejectsMalformedEntry(t *testing.T) {
for _, value := range []string{"", "not-an-address", "10.0.0.0/99", "10.0.0.256"} {
t.Run(value, func(t *testing.T) {
cfg := AuthConfig{TrustedProxies: []string{value}}
err := cfg.Validate()
if err == nil {
t.Fatalf("негодный адрес %q пропущен", value)
t.Fatalf("строка %q принята как адрес", value)
}
if !strings.Contains(err.Error(), "auth_url") {
if !strings.Contains(err.Error(), "trusted_proxies") {
t.Fatalf("имя ключа не названо: %v", err)
}
})
}
}
// Одиночный адрес принимается наравне с подсетью и становится подсетью на один
// адрес: писать разрядность руками значит её помнить, а перечень читает человек.
func TestTrustedNetworksAcceptsBareAddress(t *testing.T) {
networks, err := AuthConfig{TrustedProxies: []string{"10.1.2.3"}}.TrustedNetworks()
if err != nil {
t.Fatalf("одиночный адрес отвергнут: %v", err)
}
if len(networks) != 1 {
t.Fatalf("подсетей %d, ожидалась одна", len(networks))
}
if !networks[0].IsSingleIP() {
t.Fatalf("одиночный адрес стал подсетью шире одного адреса: %s", networks[0])
}
}
func writeConfig(t *testing.T, body string) string {
t.Helper()
+8 -7
View File
@@ -166,16 +166,15 @@ type ReplicaView struct {
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
app := r.Group(AppRoot)
// Слой сессии вешается на **группу корня**, а не на перечень адресов:
// перечень рос бы с каждым новым адресом приложения, и забытый в нём адрес
// молча перестал бы принимать куку. Собственная поверхность хранилища под
// слой не подпадает — часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
// иначе ушли бы телом библиотеки, мимо единой формы.
//
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и
// адрес выдачи файлового токена из пространства хранилища. Область его
// действия при этом выводится из **этого же** перечня адресного
// пространства — см. `underIdentifiedArea`.
app.Bind(OneErrorForm())
app.Bind(SessionFromCookie())
app.Bind(RequireUser(migrations.UsersCollection))
app.GET("/me", h.Me)
@@ -210,7 +209,9 @@ func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
func (h *AppHandler) Me(e *core.RequestEvent) error {
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
// человеку, а не сервису.
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
// наружу наравне с журналом.
return e.JSON(http.StatusOK, MeView{
ID: e.Auth.Id,
Name: e.Auth.GetString("name"),
-382
View File
@@ -1,382 +0,0 @@
package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/security"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
)
const (
// SessionCookieName — имя куки сессии. Имя нормативно: его смена молча
// выкидывает всех вошедших.
SessionCookieName = "transcriber_session"
// stateCookieName — носитель состояния и проверочного кода PKCE. Живёт
// один вход и убирается на возврате, каким бы тот ни был.
stateCookieName = "transcriber_login"
// stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель
// не нужен, а вечный носитель надолго фиксирует состояние.
stateCookieMaxAge = 10 * 60
// exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий
// провайдер держит обработчик возврата открытым неограниченно долго, и «медленный»
// становится неотличим от «отказал».
exchangeTimeout = 15 * time.Second
)
// AuthHandler ведёт вход, возврат от провайдера и выход.
//
// Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11.
// Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом,
// поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же
// роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные
// записи заводит хранилище и они видны в панели.
type AuthHandler struct {
app core.App
logger *slog.Logger
authURL string
redirectURL string
clientID string
secureCookie bool
// storageMux — роутер хранилища, через который идёт обмен кода. Собирается
// один раз: сборка вешает обработчики на само приложение и без
// идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним.
// Собранный на каждый вход, он копил бы их без предела — и копил бы по
// запросу анонима, потому что обмен исполняется раньше обращения к
// провайдеру.
storageMux http.Handler
storageMuxOnce sync.Once
storageMuxErr error
}
type AuthHandlerConfig struct {
AuthURL string
RedirectURL string
ClientID string
SecureCookie bool
}
func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler {
if logger == nil {
logger = slog.Default()
}
return &AuthHandler{
app: app,
logger: logger,
authURL: cfg.AuthURL,
redirectURL: cfg.RedirectURL,
clientID: cfg.ClientID,
secureCookie: cfg.SecureCookie,
}
}
// Register вешает адреса входа вне пространства `/api`: оно поделено с
// собственными адресами хранилища.
func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
// Продление сессии закрывается на всём роутере: адрес приносит хранилище
// своим, и перехватить его можно только слоем.
r.Bind(BlockSessionRefresh())
// Адреса вешаются группой корня, а не литералами: корень объявлен единой
// точкой адресного пространства, и порознь записанный он разошёлся бы с ней
// молча — раздача приложения начала бы отдавать разметку на возврат от
// провайдера, без единой ошибки в журнале.
auth := r.Group(AuthRoot)
auth.GET("/login", h.Login)
auth.GET("/callback", h.Callback)
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
// чужой ссылке.
//
// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
// значение годным. Требования сессии при этом нет: выход без неё убирает
// куку и молчит.
auth.POST("/logout", h.Logout).Bind(SessionFromCookie())
}
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
// PKCE у браузера.
func (h *AuthHandler) Login(e *core.RequestEvent) error {
state := security.RandomString(32)
verifier := security.RandomString(43)
e.SetCookie(&http.Cookie{
Name: stateCookieName,
Value: state + ":" + verifier,
Path: "/",
MaxAge: stateCookieMaxAge,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
query := url.Values{}
query.Set("response_type", "code")
query.Set("client_id", h.clientID)
query.Set("redirect_uri", h.redirectURL)
query.Set("scope", "openid profile email")
query.Set("state", state)
query.Set("code_challenge", security.S256Challenge(verifier))
query.Set("code_challenge_method", "S256")
separator := "?"
if strings.Contains(h.authURL, "?") {
separator = "&"
}
return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode())
}
// Callback принимает возврат от провайдера, сверяет состояние и меняет код на
// сессию средствами хранилища.
func (h *AuthHandler) Callback(e *core.RequestEvent) error {
// Носитель убирается всегда — и на успехе, и на отказе, — и убирается
// **до** записи ответа. Отложенная уборка не работает вовсе: заголовки
// фиксируются в момент, когда ответ начинают писать, и позднейшая правка их
// карты до браузера не доезжает. Состояние одноразовое ровно этим: пока
// носитель жив, переигранный возврат проходит сверку.
h.clearStateCookie(e)
query := e.Request.URL.Query()
// Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка
// сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе
// обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит».
if providerError := query.Get("error"); providerError != "" {
h.logger.Debug("Login rejected by provider",
"reason", knownProviderError(providerError), "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
state, verifier, err := h.readStateCookie(e)
if err != nil {
h.logger.Debug("Login state is missing or malformed",
"error", err, "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
if query.Get("state") != state {
h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
code := query.Get("code")
if code == "" {
h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
token, err := h.exchange(e.Request.Context(), code, verifier)
if err != nil {
// Отказ обмена — уже про сервис и его связь с провайдером, поэтому
// уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель
// входа.
h.logger.Error("Failed to exchange provider code",
"error", err, "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
h.setSessionCookie(e, token)
return e.Redirect(http.StatusFound, "/")
}
// Logout обесценивает выданные учётной записи сессии и убирает куку.
//
// Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке
// выход, разошедшийся с одновременным входом, оставил бы годную сессию, а
// человек был бы уверен, что вышел.
func (h *AuthHandler) Logout(e *core.RequestEvent) error {
if e.Auth != nil {
// Ключ токенов обновляется у свежей записи: между чтением и записью
// могла пройти чужая правка, и полное сохранение устаревшей записи
// затёрло бы её.
record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id)
if err != nil {
h.logger.Error("Failed to load account for logout", "error", err, "transport", "http")
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
}
record.RefreshTokenKey()
if err := h.app.Save(record); err != nil {
h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http")
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
}
}
h.clearSessionCookie(e)
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
// exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не
// идёт: роутер поднимается тот же, что обслуживает внешние запросы.
func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
defer cancel()
body, err := json.Marshal(map[string]string{
"provider": pbrepo.ProviderName,
"code": code,
"codeVerifier": verifier,
"redirectURL": h.redirectURL,
})
if err != nil {
return "", fmt.Errorf("failed to build exchange request: %w", err)
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
"/api/collections/users/auth-with-oauth2",
strings.NewReader(string(body)),
)
if err != nil {
return "", fmt.Errorf("failed to build exchange request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
// Адрес запросу нужен, хотя запрос внутрипроцессный и наружу не идёт.
//
// Ограничитель частоты хранилища ключует клиента адресом, а у собранного
// руками запроса его нет вовсе — и вырожденное значение библиотека отдаёт не
// пустой строкой, а литералом. Её собственный страж «пустой ключ пропускаем»
// такое значение не ловит, поэтому **все** внутренние обмены кода схлопнулись
// бы в один счётчик: третий вход в пределах трёх секунд — чей угодно —
// получал бы отказ ограничителя, неотличимый от настоящего отказа провайдера.
//
// Прежде этого не случалось: ограничитель был выключен целиком. Он включается
// вместе с правилом под корнем приложения, и цена названа здесь.
request.RemoteAddr = "127.0.0.1:0"
handler, err := h.storageHandler()
if err != nil {
return "", err
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
// Тело ответа наружу не выносится: в нём приезжает описание отказа
// провайдера, а оно принадлежит журналу, а не человеку.
return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code)
}
var response struct {
Token string `json:"token"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
return "", fmt.Errorf("failed to read exchange response: %w", err)
}
if response.Token == "" {
return "", errors.New("exchange response carries no session")
}
return response.Token, nil
}
// knownProviderError приводит причину отказа к перечню известных.
//
// Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без
// приведения аноним пишет в журнал что угодно и сколько угодно — предел один,
// размер заголовков. Журнал же единственное место, где наблюдаются инварианты о
// молчаливой потере задачи, и вытеснять его чужим текстом нельзя.
//
// Приём тот же, каким расширение записи приводится к перечню форматов.
func knownProviderError(value string) string {
switch value {
case "access_denied", "invalid_request", "invalid_scope", "server_error",
"temporarily_unavailable", "unauthorized_client", "unsupported_response_type",
"interaction_required", "login_required", "consent_required":
return value
default:
return "other"
}
}
// storageHandler собирает роутер хранилища один раз и отдаёт его всем
// последующим обменам.
func (h *AuthHandler) storageHandler() (http.Handler, error) {
h.storageMuxOnce.Do(func() {
router, err := apis.NewRouter(h.app)
if err != nil {
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
return
}
mux, err := router.BuildMux()
if err != nil {
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
return
}
h.storageMux = mux
})
return h.storageMux, h.storageMuxErr
}
func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) {
cookie, err := e.Request.Cookie(stateCookieName)
if err != nil {
return "", "", fmt.Errorf("login state cookie is missing: %w", err)
}
state, verifier, found := strings.Cut(cookie.Value, ":")
if !found || state == "" || verifier == "" {
return "", "", errors.New("login state cookie is malformed")
}
return state, verifier, nil
}
func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) {
e.SetCookie(&http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
MaxAge: pbrepo.SessionDuration,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) {
e.SetCookie(&http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) {
e.SetCookie(&http.Cookie{
Name: stateCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
+468 -366
View File
@@ -1,6 +1,7 @@
package http
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -16,14 +17,14 @@ import (
)
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
// называется пришедший, кому верят и какие адреса остаются открытыми.
// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
func TestApiRequiresSession(t *testing.T) {
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
// отказ и ничего не заводит.
func TestApiRequiresIdentity(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
t.Run("приём записи без сессии", func(t *testing.T) {
t.Run("приём записи неузнанным", func(t *testing.T) {
req := createMultipartRequest(t, "test.mp3", []byte("audio"))
w := httptest.NewRecorder()
@@ -43,7 +44,7 @@ func TestApiRequiresSession(t *testing.T) {
assert.Empty(t, jobs)
})
t.Run("опрос готовности без сессии", func(t *testing.T) {
t.Run("карточка записи неузнанному", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
w := httptest.NewRecorder()
@@ -55,9 +56,9 @@ func TestApiRequiresSession(t *testing.T) {
})
}
// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
// перебирается список заведённых задач.
func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
// не перебирается список заведённых задач.
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
@@ -78,320 +79,322 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
assert.Equal(t, missing.Code, existing.Code)
}
// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
// роутер с теми же двумя адресами.
func TestOpenEndpointsStayOpen(t *testing.T) {
// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**.
//
// Два запроса подряд с одним значением заголовка: учётная запись заводится
// первым и находится вторым, а в хранилище её строка одна.
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
const login = "newcomer"
before := countAccounts(t, env)
first := httptest.NewRecorder()
env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
second := httptest.NewRecorder()
env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
require.Equal(t, http.StatusOK, second.Code)
assert.Equal(t, before+1, countAccounts(t, env),
"второе обращение завело вторую запись: архив разъехался бы между ними")
assert.Equal(t, first.Body.String(), second.Body.String(),
"второе обращение попало в другую учётную запись")
}
// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же
// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем.
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
before := countAccounts(t, env)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Set(LoginHeader, "intruder")
req.RemoteAddr = untrustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Equal(t, before, countAccounts(t, env),
"заголовок с недоверенного адреса завёл учётную запись")
}
// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**.
//
// Перечень собственных адресов входа хранилища: каждый отвечает отказом и
// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один
// из них работает, узнавание по заголовку обходится двумя запросами.
func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
const usersRoot = "/api/collections/users"
cases := []struct {
name string
path string
body string
}{
{
name: "завести учётную запись самому",
path: usersRoot + "/records",
body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`,
},
{
name: "вход по паролю",
path: usersRoot + "/auth-with-password",
body: `{"identity":"person@example.com","password":"whatever"}`,
},
{
name: "обмен кода у внешнего провайдера",
path: usersRoot + "/auth-with-oauth2",
body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`,
},
{
name: "вход по одноразовому коду",
path: usersRoot + "/auth-with-otp",
body: `{"otpId":"whatever","password":"whatever"}`,
},
{
name: "запрос одноразового кода",
path: usersRoot + "/request-otp",
body: `{"email":"person@example.com"}`,
},
{
name: "восстановление пароля",
path: usersRoot + "/request-password-reset",
body: `{"email":"person@example.com"}`,
},
{
name: "продление сессии",
path: usersRoot + "/auth-refresh",
body: `{}`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
before := countAccounts(t, env)
req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
"адрес %s ответил успехом: собственный вход хранилища открыт", c.path)
assert.NotContains(t, w.Body.String(), `"token"`,
"адрес %s выдал значение доступа", c.path)
assert.Equal(t, before, countAccounts(t, env),
"адрес %s изменил число учётных записей", c.path)
})
}
}
// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт.
//
// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает
// владельцу записи правку собственной строки. Пока узнавание жило под корнем
// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она
// достижима, и правка своей записи была бы захватом чужого имени: поставил себе
// чужой логин — и первое обращение настоящего его владельца попало бы в твою
// запись вместе со всем архивом.
func TestUserRecordCannotBeEditedFromOutside(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`)
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(req, env.login))
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла")
after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id)
require.NoError(t, err)
assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField),
"ключ учётной записи переписан снаружи")
}
// TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто.
// Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те
// значения, которыми довольно назваться.
func TestUserRecordsCannotBeListed(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(
httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login))
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
assert.NotContains(t, w.Body.String(), env.login)
}
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
// ничего не заводит.
//
// Пустое значение здесь не крайний случай, а штатное поведение прокси: там, где
// он никого не назвал, заголовок приходит пустым. Без этой проверки все
// неназванные собрались бы в одну учётную запись с общим архивом.
func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
cases := map[string]string{
"пустое значение": "",
"одни пробелы": " ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1),
}
for name, value := range cases {
t.Run(name, func(t *testing.T) {
before := countAccounts(t, env)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Set(LoginHeader, value)
req.RemoteAddr = trustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Equal(t, before, countAccounts(t, env), "вырожденное значение завело учётную запись")
})
}
}
// TestTwoLoginHeadersIdentifyNobody: запрос с двумя значениями заголовка не
// узнаёт никого.
//
// Прокси, настроенный **добавлять** заголовок вместо замены, оставляет рядом со
// своим значением присланное анонимом. Умолчание «берём первое» отдало бы вход
// анониму, а «берём последнее» зависело бы от порядка, которым распоряжается не
// сервис.
func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
before := countAccounts(t, env)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Add(LoginHeader, "intruder")
req.Header.Add(LoginHeader, env.login)
req.RemoteAddr = trustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Equal(t, before, countAccounts(t, env))
}
// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает
// заголовок, а протухший узнаванию не мешает.
//
// Первая половина защищает владельца панели: подмена его учётной записью
// пользователя отобрала бы у него панель посреди работы. Вторая — обычного
// человека: негодный токен, оставшийся в браузере, не должен запирать его
// снаружи.
func TestStorageTokenBeatsHeader(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
stranger, strangerLogin := newSecondAccount(t, env.app)
token, err := stranger.NewAuthToken()
require.NoError(t, err)
t.Run("годный токен побеждает", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(req, env.login))
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), stranger.Id,
"заголовок победил предъявленный токен")
_ = strangerLogin
})
t.Run("протухший токен узнаванию не мешает", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Set("Authorization", "not-a-token")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(req, env.login))
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), env.account.Id)
})
}
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты
// неузнанному, а заголовок на них учётной записи не заводит.
//
// Вторая половина важнее первой: узнавание сужено до области приложения именно
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
// такой запрос с новым именем — записи в неё.
func TestOpenAddressesDoNotIdentify(t *testing.T) {
app := newTestStorage(t)
r, err := apis.NewRouter(app)
require.NoError(t, err)
r.GET("/health", func(e *core.RequestEvent) error {
appHandler := NewAppHandler(nil, nil, nil, nil, nil)
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil))
r.GET(HealthPath, func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
r.GET("/metrics", func(e *core.RequestEvent) error {
r.GET(MetricsPath, func(e *core.RequestEvent) error {
return e.String(http.StatusOK, "# metrics")
})
mux, err := r.BuildMux()
require.NoError(t, err)
for _, path := range []string{"/health", "/metrics"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
}
}
for _, path := range []string{HealthPath, MetricsPath} {
anonymous := httptest.NewRecorder()
mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil))
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
// вошедших не выкидывает.
func TestSessionSurvivesRestart(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
before := httptest.NewRecorder()
env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, before.Code)
// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
// поверх прежнего каталога данных.
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
env.handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
// ненайденную задачу, а не про отсутствующую сессию.
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
// сессии, а не только убирает куку.
func TestLogoutClosesAccess(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
env.handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
logoutResponse := httptest.NewRecorder()
mux.ServeHTTP(logoutResponse, logout)
require.Equal(t, http.StatusOK, logoutResponse.Code)
// Куку выход убирает.
assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
// себе не даёт: унесённое значение работало бы до истечения срока.
after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
mux.ServeHTTP(afterResponse, after)
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}
// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
func TestLogoutWhenAccountIsGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
// учётной записи в панели.
require.NoError(t, env.app.Delete(env.account))
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, logout)
// Записи нет — проверка сессии её не находит, и до обесценивания дело не
// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
// не осталось, потому что не осталось учётной записи.
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
env.mux.ServeHTTP(afterResponse, after)
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}
// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
func TestLogoutWithoutSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
}
// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
// у браузера.
func TestLoginRedirectsToProvider(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
require.Equal(t, http.StatusFound, w.Code)
location := w.Result().Header.Get("Location")
assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
assert.Contains(t, location, "code_challenge_method=S256")
assert.Contains(t, location, "client_id=transcriber")
// Носитель состояния несёт те же признаки защиты, что и кука сессии.
stateCookie := w.Result().Header.Get("Set-Cookie")
assert.Contains(t, stateCookie, stateCookieName)
assert.Contains(t, stateCookie, "HttpOnly")
assert.Contains(t, stateCookie, "Secure")
assert.Contains(t, stateCookie, "SameSite=Lax")
}
// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
// открывает и учётной записи не заводит.
func TestCallbackRejectsForeignState(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
accountsBefore, err := env.app.FindAllRecords("users")
require.NoError(t, err)
cases := []struct {
name string
cookie *http.Cookie
query string
}{
{
name: "состояния не выдавали вовсе",
cookie: nil,
query: "?code=whatever&state=foreign",
},
{
name: "состояние не совпало с выданным",
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
query: "?code=whatever&state=foreign",
},
{
name: "провайдер вернул отказ",
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
query: "?error=access_denied&state=issued",
},
withHeader := httptest.NewRecorder()
mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby"))
assert.Equal(t, http.StatusOK, withHeader.Code,
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
accountsAfter, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Len(t, accountsAfter, len(accountsBefore))
// Носитель убирается и на отказном возврате: иначе состояние
// осталось бы годным для новой попытки.
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
})
}
records, err := app.FindAllRecords(migrations.UsersCollection)
require.NoError(t, err)
assert.Empty(t, records, "обращение к открытому адресу завело учётную запись")
}
// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
func TestHeaderBeatsCookie(t *testing.T) {
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
//
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся
// кука вернула бы и это.
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
req.Header.Set("Authorization", env.session)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
require.Equal(t, http.StatusOK, w.Code)
// Прошёл заголовок: иначе негодная кука дала бы отказ.
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
}
// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
// двумя запросами.
func TestSelfServiceAccountsAreClosed(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusOK, w.Code)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
})
t.Run("вход паролем недоступен", func(t *testing.T) {
body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
})
}
// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
// ревью дизайна: не печатается ничто, что даёт доступ.
// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не
// печатается ничто, что даёт доступ.
//
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
// другим ключом, поиск по ключу не разбудил бы.
func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
// довольно назваться, чтобы стать этим человеком.
func TestIdentityValuesAreNotLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
@@ -401,59 +404,34 @@ func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
journal := env.journal.String()
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
assert.NotContains(t, journal, env.session,
"значение сессии в журнале: строка стала бы ключом к чужому доступу")
assert.NotContains(t, journal, env.login,
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
assert.NotContains(t, journal, env.account.Email(),
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
}
// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
// приведении настроек провайдера к конфигу.
func TestProviderSecretIsNotLogged(t *testing.T) {
app := newTestStorage(t)
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
// виден **адресом пира**, а не значением заголовка.
//
// Без этой строки владелец, у которого никто не может войти, не отличит своей
// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не
// ставит) — а это разные поломки в разных местах.
func TestUntrustedPeerIsLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
const secret = "super-secret-client-value"
const intruder = "intruder-login-value"
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: secret,
}))
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Set(LoginHeader, intruder)
req.RemoteAddr = untrustedPeer
// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
// журнале прошла бы на невыполненной работе.
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
require.True(t, found)
assert.Equal(t, secret, provider.ClientSecret)
}
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
// не переписывается.
func TestProviderSecretRotationReachesStorage(t *testing.T) {
app := newTestStorage(t)
settings := pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "first-secret",
}
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
settings.ClientSecret = "rotated-secret"
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
require.True(t, found)
assert.Equal(t, "rotated-secret", provider.ClientSecret)
journal := env.journal.String()
assert.Contains(t, journal, "203.0.113.9", "адреса пира в журнале нет: поломку не отличить")
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
}
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
@@ -469,10 +447,10 @@ func TestRecordFileIsProtected(t *testing.T) {
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
}
// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
// TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
// ссылке.
func TestRecordFileNeedsSession(t *testing.T) {
func TestRecordFileNeedsToken(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
@@ -494,9 +472,18 @@ func TestRecordFileNeedsSession(t *testing.T) {
// своего существования. До пометки поля защищённым эта же ссылка отдавала
// содержимое кому угодно — знание ссылки и было доступом.
assert.Equal(t, http.StatusNotFound, anonymous.Code,
"ссылка отдала файл без сессии: знание ссылки снова стало доступом")
"ссылка отдала файл без токена: знание ссылки снова стало доступом")
assert.NotContains(t, anonymous.Body.String(), "audio content")
// А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен
// файла → ссылка» обязан работать целиком, иначе файл записи недостижим для
// браузера вовсе.
withToken := httptest.NewRecorder()
env.mux.ServeHTTP(withToken,
httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil))
require.Equal(t, http.StatusOK, withToken.Code)
assert.Equal(t, "audio content", withToken.Body.String())
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
fileRepo := pbrepo.NewFileRepository(env.app)
reader, err := fileRepo.Open(files[0].Id)
@@ -511,29 +498,144 @@ func TestRecordFileNeedsSession(t *testing.T) {
assert.Equal(t, "audio content", string(content))
}
// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
// умолчанием библиотеки в пять суток.
//
// Назначается он приведением настроек при подъёме, а не шагом схемы: применённый
// шаг не переписывается, и число, положенное туда, разошлось бы со сроком жизни
// куки при первой же правке.
func TestSessionLifetimeIsAssigned(t *testing.T) {
app := newTestStorage(t)
// countAccounts — сколько учётных записей лежит в хранилище. Проверки судят
// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по
// ответу обработчика они неразличимы.
func countAccounts(t *testing.T, env *testEnv) int {
t.Helper()
users, err := app.FindCollectionByNameOrId("users")
records, err := env.app.FindAllRecords(migrations.UsersCollection)
require.NoError(t, err)
require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "local-test-secret",
}))
users, err = app.FindCollectionByNameOrId("users")
return len(records)
}
// TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты
// запрос не заводит учётной записи.
//
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
// ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать
// запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили
// пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями
// не удаляется, а мусорная растёт в той же единственной базе.
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
require.NoError(t, ApplyAppRateLimit(env.app))
before := countAccounts(t, env)
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
// потолок раньше, чем начнутся новые имена.
for range appRateMaxRequests + 5 {
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
}
rejected := 0
for i := range 20 {
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(
httptest.NewRequest(http.MethodGet, "/app/me", nil),
fmt.Sprintf("newcomer-%d", i)))
if w.Code == http.StatusTooManyRequests {
rejected++
}
}
require.Positive(t, rejected, "ограничитель не сработал — проверке не на чем сработать")
assert.Equal(t, before, countAccounts(t, env),
"отвергнутый ограничителем запрос завёл учётную запись: узнавание стоит раньше ограничителя")
}
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
//
// Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а
// прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение
// заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим
// человеком.
func TestAccountCreationIsLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
const login = "brand-new-person"
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
require.Equal(t, http.StatusOK, w.Code)
journal := env.journal.String()
assert.Contains(t, journal, "Account created from login header", "заведение не оставило строки")
assert.NotContains(t, journal, login, "значение заголовка уехало в журнал")
// Второе обращение новой строки не прибавляет: заводится запись однажды.
before := strings.Count(journal, "Account created from login header")
again := httptest.NewRecorder()
env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
require.Equal(t, http.StatusOK, again.Code)
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
"повторное обращение отчиталось заведением")
}
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
//
// Два значения заголовка означают прокси, который его добавляет вместо замены,
// — модель угроз называет это главным барьером. Отладочным уровнем такая
// поломка в бою не видна вовсе: боевой уровень журнала информационный.
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Add(LoginHeader, "intruder")
req.Header.Add(LoginHeader, env.login)
req.RemoteAddr = trustedPeer
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
journal := env.journal.String()
assert.Contains(t, journal, "level=WARN", "поломка контура записана уровнем, невидимым в бою")
assert.Contains(t, journal, "more than one login header")
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
}
// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути
// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным.
//
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя
// то, что сломано не у него.
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так
// же, как она перестанет работать при отказе хранилища.
users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
users.RemoveIndex("idx_users_provider_login")
users.Fields.RemoveByName(migrations.ProviderLoginField)
require.NoError(t, env.app.Save(users))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody"))
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
"отказ хранилища выдан за «вас не узнали»")
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
}
// TestFormerAuthRootServesMarkup — прежние адреса входа отдают разметку.
//
// Корень `/auth` снят из перечня адресного пространства, и путь под ним стал
// обычным путём вне корней. Проверка сторожит именно это: вернувшийся корень
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
// поведение.
func TestFormerAuthRootServesMarkup(t *testing.T) {
env := setupWebappEnv(t, builtDist(), true)
res := env.get("/auth/login")
assert.Equal(t, http.StatusOK, res.Code)
assert.Contains(t, res.Body.String(), "приложение")
}
+13 -5
View File
@@ -96,15 +96,19 @@ func mapDomainError(err error) (int, ErrorBody) {
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
case errors.Is(err, contract.ErrUnauthorized):
// Не «требуется вход»: своего входа у сервиса нет, и уводить человека
// некуда. Сообщение называет то, что произошло на самом деле, — сервис
// не узнал пришедшего, — и приложение показывает его как есть, своего
// словаря текстов под коды ответа не заводя.
return http.StatusUnauthorized, ErrorBody{
Code: CodeUnauthorized,
Message: "Требуется вход",
Message: "Сервис вас не узнал",
}
case errors.Is(err, contract.ErrOwnerRequired):
return http.StatusForbidden, ErrorBody{
Code: CodeForbidden,
Message: "У вашей сессии нет учётной записи пользователя",
Message: "У предъявителя нет учётной записи пользователя",
}
}
@@ -214,9 +218,13 @@ func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberRequireUser",
// Сразу после слоя, который читает предъявленный токен: раньше него
// `e.Auth` ещё пуст, и всякий запрос получал бы отказ.
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
// Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий
// запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за
// ограничителем частоты — см. `TrustedHeaderIdentity`.
//
// Предел тела библиотеки идёт следом (−990), и это обязательно: отказ
// неузнанному наступает **до** чтения тела.
Priority: apis.DefaultRateLimitMiddlewarePriority + 2,
Func: func(e *core.RequestEvent) error {
if e.Auth == nil {
return fail(e, contract.ErrUnauthorized)
+246
View File
@@ -0,0 +1,246 @@
package http
import (
"errors"
"log/slog"
"net"
"net/netip"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
)
// Заголовки, которыми обратный прокси называет пришедшего.
//
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена
// соседним сервисам, и настройкой они не делаются: второе место, где их можно
// написать неверно, выгоды не даёт.
const (
LoginHeader = "Remote-User"
NameHeader = "Remote-Name"
EmailHeader = "Remote-Email"
)
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла.
//
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе —
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
const FileTokenPath = StorageRoot + "/files/token"
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
//
// # Область
//
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но
// узнаёт он **только на объявленной области**: корень приложения плюс этот
// адрес. Область выводится из перечня адресного пространства, а не пишется
// вторым списком.
//
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение
// настоящего владельца этого имени попало бы в чужую запись вместе со всем
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
// два барьера здесь именно потому, что прежний был один и держался на
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
//
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
// базе, а первый такой запрос с новым именем — записи в неё.
//
// # Кто побеждает
//
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
// учётной записью пользователя отобрала бы у него панель посреди работы.
// Протухший и негодный токен предъявленными не считаются: библиотека их не
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
//
// # Чего слой не делает
//
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием
// учётной записи на адресах приложения.
//
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
// легла база.
func TrustedHeaderIdentity(
app core.App,
mounts []Mount,
trusted []netip.Prefix,
logger *slog.Logger,
) *hook.Handler[*core.RequestEvent] {
if logger == nil {
logger = slog.Default()
}
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberTrustedHeaderIdentity",
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
// убираются ничем.
//
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
// узнавание (−999) → требование учётной записи (−998) → предел тела
// (−990). Требование стоит перед пределом тела намеренно: отказ
// неузнанному обязан наступать до чтения тела.
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
Func: func(e *core.RequestEvent) error {
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
return e.Next()
}
// Более одного значения — не выбор, а отказ. Прокси, настроенный
// добавлять заголовок вместо замены, оставляет рядом со своим
// значением присланное анонимом, и умолчание «берём первое» отдало
// бы вход анониму.
values := e.Request.Header.Values(LoginHeader)
if len(values) != 1 {
if len(values) > 1 {
// Уровень предупреждающий: два значения означают прокси,
// который заголовок **добавляет** вместо замены, — то есть
// ровно ту поломку контура, которую модель угроз называет
// главной. Отладочным уровнем она в бою не видна вовсе:
// боевой уровень журнала информационный.
logger.Warn("Request carries more than one login header",
"http.peer_addr", e.Request.RemoteAddr,
"capability", "access", "transport", "http")
} else {
// Заголовка нет вовсе. В этом контуре это значит, что прокси
// никого не назвал; строка отладочная, потому что случай
// штатный — так выглядит и человек, которому провайдер
// отказал.
logger.Debug("Request carries no login header",
"http.peer_addr", e.Request.RemoteAddr,
"capability", "access", "transport", "http")
}
return e.Next()
}
peer, ok := peerAddress(e.Request.RemoteAddr)
if !ok || !isTrusted(trusted, peer) {
// Уровень предупреждающий, а не отладочный, и это решение о
// цене. Заголовок с недоверенного адреса в этом контуре — не
// рутина: контейнер портов наружу не публикует, снаружи всё
// приходит прокси, то есть с доверенного адреса. Значит либо
// перечень задан неверно, либо кто-то оказался внутри сети — и
// то и другое владелец обязан увидеть.
//
// Значение заголовка при этом в журнал не идёт: оно целиком
// задаётся тем, кто шлёт запрос. Адрес пира идёт — по нему
// видно, чья это поломка: своя (перечень) или контура (прокси
// заголовка не ставит).
logger.Warn("Login header came from an untrusted peer",
"http.peer_addr", e.Request.RemoteAddr,
"capability", "access", "transport", "http")
return e.Next()
}
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{
Login: values[0],
Name: e.Request.Header.Get(NameHeader),
Email: e.Request.Header.Get(EmailHeader),
})
if err != nil {
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) {
// Негодный логин — это негодный ввод, а не поломка сервиса:
// пустой заголовок прокси шлёт штатно там, где никого не
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
// неузнанным.
logger.Debug("Login header value is not acceptable",
"http.peer_addr", e.Request.RemoteAddr,
"capability", "access", "transport", "http")
return e.Next()
}
logger.Error("Failed to resolve account by login header",
"error", err, "capability", "access", "transport", "http")
return fail(e, err)
}
if created {
// Заведение учётной записи — событие, и владелец обязан его
// видеть: иначе «никто не заходил» неотличимо от «завелось
// двадцать», а прокси, пропустивший чужой заголовок, не
// оставляет следа вовсе. Убрать заведённую запись потом нечем —
// учётная запись с записями не удаляется.
//
// Значение заголовка в строку не идёт: им довольно назваться,
// чтобы стать этим человеком. Идут адрес пира и идентификатор
// записи — оба выданы не спрашивающим.
logger.Info("Account created from login header",
"http.peer_addr", e.Request.RemoteAddr,
"account_id", record.Id,
"capability", "access", "transport", "http")
}
e.Auth = record
return e.Next()
},
}
}
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути.
//
// Область — корень приложения из перечня адресного пространства плюс адрес
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень
// объявлен единой точкой адресного пространства, и записанный здесь второй раз
// он разошёлся бы с ней молча.
func underIdentifiedArea(mounts []Mount, requestPath string) bool {
if requestPath == FileTokenPath {
return true
}
for _, mount := range mounts {
if mount.Path == AppRoot {
return mount.Covers(requestPath)
}
}
return false
}
// peerAddress достаёт адрес того, кто открыл соединение.
//
// Берётся именно он, а не пересылаемый заголовок: значением пересылаемого
// распоряжается тот, кто шлёт запрос, и барьер, подделываемый той же строкой,
// которой он обходится, не барьер вовсе.
func peerAddress(remoteAddr string) (netip.Addr, bool) {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
// Адрес без порта — законная форма у некоторых слушателей.
host = remoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return netip.Addr{}, false
}
// Адрес IPv4, приехавший в оболочке IPv6, сверяется с перечнем как IPv4:
// иначе `127.0.0.1` в перечне не совпал бы с `::ffff:127.0.0.1` у пира.
return addr.Unmap(), true
}
func isTrusted(trusted []netip.Prefix, peer netip.Addr) bool {
for _, network := range trusted {
if network.Contains(peer) {
return true
}
}
return false
}
+1 -1
View File
@@ -299,7 +299,7 @@ func TestList_ShowsOnlyOwnRecords(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: stranger})
asUser(req, stranger)
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
-358
View File
@@ -1,358 +0,0 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.
// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
// считая обращения: по счётчику видно, дошло ли до сети вообще.
type fakeProvider struct {
server *httptest.Server
tokenHits int
failToken bool
subject string
emailValue string
}
func newFakeProvider(t *testing.T) *fakeProvider {
t.Helper()
provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}
write := func(w http.ResponseWriter, body string) {
if _, err := w.Write([]byte(body)); err != nil {
t.Errorf("подставной провайдер не ответил: %v", err)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
provider.tokenHits++
if provider.failToken {
w.WriteHeader(http.StatusBadRequest)
write(w, `{"error":"invalid_grant"}`)
return
}
w.Header().Set("Content-Type", "application/json")
write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
})
provider.server = httptest.NewServer(mux)
t.Cleanup(provider.server.Close)
return provider
}
// loginEnv — окружение проверки входа: хранилище с настроенным подставным
// провайдером и собранный роутер со всеми слоями.
type loginEnv struct {
app core.App
mux http.Handler
handler *AuthHandler
provider *fakeProvider
}
func setupLoginEnv(t *testing.T) *loginEnv {
t.Helper()
app := newTestStorage(t)
provider := newFakeProvider(t)
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: provider.server.URL + "/authorize",
TokenURL: provider.server.URL + "/token",
UserInfoURL: provider.server.URL + "/userinfo",
ClientID: "transcriber",
ClientSecret: "local-test-secret",
}))
handler := NewAuthHandler(app, AuthHandlerConfig{
AuthURL: provider.server.URL + "/authorize",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(app)
require.NoError(t, err)
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
}
// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
// выданным состоянием — тем, что сервис ждёт обратно.
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
t.Helper()
w := httptest.NewRecorder()
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
require.Equal(t, http.StatusFound, w.Code)
for _, c := range w.Result().Cookies() {
if c.Name == stateCookieName {
cookie = c
}
}
require.NotNil(t, cookie, "носитель состояния не поставлен")
location, err := url.Parse(w.Result().Header.Get("Location"))
require.NoError(t, err)
state = location.Query().Get("state")
require.NotEmpty(t, state)
return cookie, state
}
// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
// провайдера, и он получает сессию.
//
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
// работающим: прочие проверки заводят запись мимо входа.
func TestLoginCreatesAccountAndSession(t *testing.T) {
env := setupLoginEnv(t)
before, err := env.app.FindAllRecords("users")
require.NoError(t, err)
require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")
after, err := env.app.FindAllRecords("users")
require.NoError(t, err)
require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")
var session *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == SessionCookieName {
session = c
}
}
require.NotNil(t, session, "кука сессии не поставлена")
// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
// уносит её по незашифрованному соединению.
assert.True(t, session.HttpOnly)
assert.True(t, session.Secure)
assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
assert.NotEmpty(t, session.Value)
// Носитель состояния убран — и убран так, что это видно готовому ответу, а
// не только живой карте заголовков.
var cleared bool
for _, c := range w.Result().Cookies() {
if c.Name == stateCookieName && c.MaxAge < 0 {
cleared = true
}
}
assert.True(t, cleared, "носитель состояния пережил возврат")
// Выданная сессия открывает доступ к закрытым адресам.
check := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
check.AddCookie(session)
checkResponse := httptest.NewRecorder()
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
NewAppHandler(
pbrepo.NewAudioRecordRepository(env.app),
pbrepo.NewTextRepository(env.app),
pbrepo.NewStructureRepository(env.app),
nil, nil,
).Register(r)
checkMux, err := r.BuildMux()
require.NoError(t, err)
checkMux.ServeHTTP(checkResponse, check)
assert.Equal(t, http.StatusNotFound, checkResponse.Code,
"сессия не открыла доступ: получен %d", checkResponse.Code)
}
// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
// пускает постороннего.
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
env := setupLoginEnv(t)
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
"посторонний завёл себе учётную запись: %s", w.Body.String())
accounts, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Empty(t, accounts)
}
// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
//
// Сборка роутера хранилища вешает обработчики на само приложение и без
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
// что обмен исполняется раньше обращения к провайдеру.
func TestCallbackDoesNotLeakHooks(t *testing.T) {
env := setupLoginEnv(t)
count := func() int {
hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
return hook.Len()
}
// Первый вход собирает роутер — с него и считаем.
cookie, state := env.startLogin(t)
first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
first.AddCookie(cookie)
env.mux.ServeHTTP(httptest.NewRecorder(), first)
before := count()
for range 20 {
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
req.AddCookie(cookie)
env.mux.ServeHTTP(httptest.NewRecorder(), req)
}
assert.Equal(t, before, count(),
"обработчики копятся: 20 входов добавили %d", count()-before)
}
// TestSessionRefreshIsClosed: сессия не продлевает саму себя.
//
// При живом продлении срок её жизни ничего не значит, а вместе с ним перестаёт
// работать единственный канал, которым отзыв доступа у провайдера доходит до
// сервиса.
func TestSessionRefreshIsClosed(t *testing.T) {
env := setupLoginEnv(t)
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code)
var session *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == SessionCookieName {
session = c
}
}
require.NotNil(t, session)
refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
refresh.Header.Set("Authorization", session.Value)
refreshResponse := httptest.NewRecorder()
env.mux.ServeHTTP(refreshResponse, refresh)
assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
"сессия продлилась: %s", refreshResponse.Body.String())
// И нового значения в ответе нет — продлевать нечем.
assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
}
// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
func TestCallbackRejectsProviderFailure(t *testing.T) {
env := setupLoginEnv(t)
env.provider.failToken = true
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
for _, c := range w.Result().Cookies() {
assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
}
accounts, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Empty(t, accounts)
}
// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
// отдаётся анониму.
func TestRecordFileNeedsSessionAndToken(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
require.Equal(t, http.StatusCreated, created.Code)
files, err := env.app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
require.Len(t, files, 1)
names := files[0].GetStringSlice("file")
require.Len(t, names, 1)
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
// Аноним не проходит.
anonymous := httptest.NewRecorder()
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
assert.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
assert.NotContains(t, anonymous.Body.String(), "audio content")
// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
// защищённый файл судится этим токеном, а не сессионной кукой.
tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
tokenRequest.Header.Set("Authorization", env.session)
tokenResponse := httptest.NewRecorder()
env.mux.ServeHTTP(tokenResponse, tokenRequest)
require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())
var payload struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
require.NotEmpty(t, payload.Token)
withToken := httptest.NewRecorder()
env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))
assert.Equal(t, http.StatusOK, withToken.Code,
"вошедший не получил файл: %d", withToken.Code)
assert.Contains(t, withToken.Body.String(), "audio content")
}
+31 -30
View File
@@ -17,40 +17,30 @@ import (
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
// newSecondAccount заводит вторую учётную запись с собственной сессией.
// Постоянный адрес почты первой занят, и повторное сохранение отвергается —
// адрес здесь свой.
// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный
// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь
// свой.
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
t.Helper()
const login = "stranger"
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
record := core.NewRecord(users)
record.Set(migrations.ProviderLoginField, login)
record.Set("email", "stranger@example.com")
record.Set("verified", true)
record.SetRandomPassword()
require.NoError(t, app.Save(record))
token, err := record.NewAuthToken()
require.NoError(t, err)
return record, token
return record, login
}
// withSessionHeader предъявляет сессию заголовком. Собственная поверхность
// хранилища читается только так: слой, перекладывающий куку в заголовок, на неё
// намеренно не наведён — часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
func withSessionHeader(req *http.Request, session string) *http.Request {
req.Header.Set("Authorization", session)
return req
}
// serveAs шлёт запрос от имени названной сессии, а не сессии окружения.
func serveAs(env *testEnv, session string, w http.ResponseWriter, req *http.Request) {
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: session})
env.mux.ServeHTTP(w, req)
// serveAs шлёт запрос от имени названного логина, а не логина окружения.
func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) {
env.mux.ServeHTTP(w, asUser(req, login))
}
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
@@ -139,8 +129,13 @@ func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
token, err := admin.NewAuthToken()
require.NoError(t, err)
// Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему
// никто не ставит, и узнавание по нему его учётной записи не касается.
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
serveAs(env, token, w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
@@ -164,13 +159,15 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
// Переход по ссылке идёт **без** заголовка: файл судится своим коротким
// токеном, а не узнаванием. Так же по ней пойдёт и браузер.
mine := httptest.NewRecorder()
env.mux.ServeHTTP(mine, withSessionHeader(
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.session), http.NoBody), env.session))
env.mux.ServeHTTP(mine,
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody))
foreign := httptest.NewRecorder()
env.mux.ServeHTTP(foreign, withSessionHeader(
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody), stranger))
env.mux.ServeHTTP(foreign,
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody))
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
@@ -179,15 +176,19 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
}
// fileToken берёт у хранилища токен файла для названной сессии. Токен выдаётся
// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся
// на предъявителя: о файле хранилище при выдаче не спрашивает.
func fileToken(t *testing.T, env *testEnv, session string) string {
//
// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на
// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка»
// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера.
func fileToken(t *testing.T, env *testEnv, login string) string {
t.Helper()
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, withSessionHeader(
httptest.NewRequest("POST", "/api/files/token", http.NoBody), session))
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому вошедшему")
env.mux.ServeHTTP(w, asUser(
httptest.NewRequest("POST", "/api/files/token", http.NoBody), login))
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному")
var body struct {
Token string `json:"token"`
+34
View File
@@ -56,3 +56,37 @@ func ApplyAppRateLimit(app core.App) error {
}
return nil
}
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать
// адрес спрашивающего.
//
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет
// в этом случае общий на весь сервис: восемь одновременно открытых карточек
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного
// запроса. Норма при этом требует обратного — бюджет считается по адресу
// спрашивающего.
//
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру,
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**,
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ
// счётчика сам и обходит ограничитель, меняя значение.
//
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл,
// по-прежнему решает адрес самого соединения.
func ApplyTrustedProxyHeaders(app core.App) error {
settings := app.Settings()
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"}
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий,
// чем один пустой ключ на всех.
settings.TrustedProxy.UseLeftmostIP = true
if err := app.Save(settings); err != nil {
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
}
return nil
}
-72
View File
@@ -1,72 +0,0 @@
package http
import (
"net/http"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
)
// RefreshPath — адрес хранилища, которым сессия продлевает саму себя.
const RefreshPath = "/api/collections/users/auth-refresh"
// BlockSessionRefresh закрывает продление сессии.
//
// Хранилище выдаёт сессию продлеваемой: предъявитель значения меняет его на
// новое, с новым сроком, и делает это сколько угодно раз, никуда не входя. При
// живом продлении срок жизни сессии перестаёт что-либо значить, а вместе с ним
// перестаёт работать единственный канал, которым отзыв доступа у провайдера
// доходит до сервиса, — сервис после входа к провайдеру не обращается.
//
// Решение владельца от 2026-08-12: продление выключено, цена — вход раз в
// семь суток.
func BlockSessionRefresh() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberBlockSessionRefresh",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 2,
Func: func(e *core.RequestEvent) error {
if e.Request.URL.Path == RefreshPath {
return e.JSON(http.StatusNotFound, map[string]string{
"error": "Продление сессии выключено",
})
}
return e.Next()
},
}
}
// SessionFromCookie перекладывает значение куки сессии в заголовок, которым
// хранилище читает предъявленную сессию.
//
// Куки хранилище не читает вовсе — только заголовок `Authorization`. Браузер же
// сам заголовка не шлёт, а своей страницы со скриптом у сервиса нет, поэтому
// сессия предъявляется кукой, а способ проверки остаётся один.
//
// Предъявленный заголовок побеждает: иначе браузер с сессионной кукой получал
// бы на собственных адресах хранилища не то, что предъявил.
//
// Слой стоит раньше проверки токена: тот идёт с приоритетом
// DefaultLoadAuthTokenMiddlewarePriority и к этому моменту заголовок должен
// быть на месте.
func SessionFromCookie() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberSessionFromCookie",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 1,
Func: func(e *core.RequestEvent) error {
if e.Request.Header.Get("Authorization") != "" {
return e.Next()
}
cookie, err := e.Request.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
return e.Next()
}
e.Request.Header.Set("Authorization", cookie.Value)
return e.Next()
},
}
}
+2 -1
View File
@@ -261,12 +261,13 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil))
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
asUser(req, env.login)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
+52 -27
View File
@@ -10,6 +10,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"net/netip"
"regexp"
"strings"
"sync"
@@ -72,50 +73,70 @@ type testEnv struct {
handler *AppHandler
app core.App
journal *journalBuffer
// session — значение сессии вошедшего. Приём и опрос закрыты за
// аутентификацией, и проверка, судящая их по существу, обязана предъявить
// сессию ровно так же, как это делает браузер.
session string
// account — учётная запись, которой выдана сессия. Нужна проверкам выхода.
// login — логин у провайдера, которым доверенный источник называет
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
// по существу, обязана назваться ровно так же, как это делает прокси.
login string
// account — учётная запись, которой принадлежит этот логин.
account *core.Record
}
// serve шлёт запрос от имени вошедшего: сессия предъявляется кукой — тем же
// способом, каким её предъявляет браузер. Заголовок проверки не ставят: куку в
// него перекладывает слой предъявления, и подмена его здесь означала бы проверку
// не той цепочки.
// Доверенный источник проверок.
//
// Проверки, судящие отказ без сессии, зовут `mux` напрямую.
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
e.mux.ServeHTTP(w, req)
// Подсеть и адрес пира разведены намеренно: проверка недоверенного источника
// берёт адрес **вне** подсети, и обе стороны условия видны рядом.
const (
trustedPeer = "10.1.2.3:34567"
untrustedPeer = "203.0.113.9:34567"
)
func testTrustedNetworks(t *testing.T) []netip.Prefix {
t.Helper()
prefix, err := netip.ParsePrefix("10.0.0.0/8")
require.NoError(t, err)
return []netip.Prefix{prefix}
}
// newTestAccount заводит учётную запись и выдаёт ей сессию.
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
// пира из доверенной подсети — ровно то, что делает обратный прокси.
func asUser(req *http.Request, login string) *http.Request {
req.Header.Set(LoginHeader, login)
req.RemoteAddr = trustedPeer
return req
}
// serve шлёт запрос от имени узнанного. Проверки, судящие отказ неузнанному,
// зовут `mux` напрямую: запрос без заголовка и есть запрос неузнанного.
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
e.mux.ServeHTTP(w, asUser(req, e.login))
}
// newTestAccount заводит учётную запись с известным логином.
//
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
// запросом больше нельзя — создание закрыто шагом схемы, и в этом весь смысл
// изменения. Прямое сохранение идёт мимо правил доступа так же, как идёт вход,
// когда учётную запись заводит само хранилище.
// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо
// правил доступа так же, как идёт заведение записи первым обращением.
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
t.Helper()
const login = "person"
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
record := core.NewRecord(users)
record.Set(migrations.ProviderLoginField, login)
record.Set("email", "person@example.com")
record.Set("verified", true)
// Случайный пароль ставит и само хранилище, когда заводит запись по входу у
// провайдера: запись auth-коллекции без пароля не сохраняется, а войти по
// Случайный пароль ставит и сам сервис, когда заводит запись первым
// обращением: запись auth-коллекции без пароля не сохраняется, а войти по
// нему всё равно нельзя — парольный вход выключен шагом схемы.
record.SetRandomPassword()
require.NoError(t, app.Save(record))
token, err := record.NewAuthToken()
require.NoError(t, err)
return record, token
return record, login
}
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
@@ -191,23 +212,27 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
// обработчик, и проверка судит ту же цепочку, что и прод.
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку,
// что и прод. Собери роутер иначе — и проверка судила бы не то.
r, err := apis.NewRouter(app)
require.NoError(t, err)
mounts := ServiceMounts(handler, http.NotFoundHandler())
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
account, session := newTestAccount(t, app)
account, login := newTestAccount(t, app)
return &testEnv{
mux: mux,
handler: handler,
app: app,
journal: journal,
session: session,
login: login,
account: account,
}
}
+8 -7
View File
@@ -19,10 +19,16 @@ import (
//
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
// панели владельца. Поменять их нельзя, это литералы библиотеки.
//
// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и
// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают
// тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
// стоит, ничем не отличается от любого другого свободного, а второй перечень
// «когда-то занятых корней» разошёлся бы с первым молча.
const (
StorageRoot = "/api"
PanelRoot = "/_"
AuthRoot = "/auth"
HealthPath = "/health"
MetricsPath = "/metrics"
@@ -74,16 +80,11 @@ func (m Mount) Covers(requestPath string) bool {
}
// ServiceMounts перечисляет адресное пространство сервиса целиком.
func ServiceMounts(
appHandler *AppHandler,
authHandler *AuthHandler,
metricsHandler http.Handler,
) []Mount {
func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount {
return []Mount{
{Path: StorageRoot},
{Path: PanelRoot},
{Path: AppRoot, Bind: appHandler.Register},
{Path: AuthRoot, Bind: authHandler.Register},
{Path: HealthPath, Exact: true, Bind: bindHealth},
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
}
+9 -13
View File
@@ -35,7 +35,7 @@ func builtDist() fs.FS {
type webappEnv struct {
mux http.Handler
journal *journalBuffer
session string
login string
}
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
@@ -68,36 +68,32 @@ func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
)
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
authHandler := NewAuthHandler(app, AuthHandlerConfig{
AuthURL: "https://provider.example/authorize",
RedirectURL: "https://service.example/auth/callback",
ClientID: "client",
}, logger)
mounts := ServiceMounts(appHandler, authHandler, http.NotFoundHandler())
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
r, err := apis.NewRouter(app)
require.NoError(t, err)
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
RegisterServiceRoutes(r, mounts)
NewWebappHandler(dist, built, mounts, logger).Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
_, session := newTestAccount(t, app)
_, login := newTestAccount(t, app)
return &webappEnv{mux: mux, journal: journal, session: session}
return &webappEnv{mux: mux, journal: journal, login: login}
}
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
return e.do(http.MethodGet, path, false)
}
func (e *webappEnv) do(method, path string, withSession bool) *httptest.ResponseRecorder {
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
if withSession {
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
if identified {
asUser(req, e.login)
}
rec := httptest.NewRecorder()
@@ -124,7 +120,7 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
env := setupWebappEnv(t, builtDist(), true)
for _, path := range []string{"/api/nope", "/_/nope", "/auth/nope"} {
for _, path := range []string{"/api/nope", "/_/nope"} {
res := env.get(path)
assert.NotContains(t, res.Body.String(), "приложение", path)
+3
View File
@@ -716,6 +716,9 @@ func newOwner(t *testing.T, app core.App) string {
require.NoError(t, err)
record := core.NewRecord(users)
// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
// пустым ключом схема не примет.
record.Set(migrations.ProviderLoginField, uuid.NewString())
record.Set("email", uuid.NewString()+"@example.test")
record.Set("verified", true)
record.Set("password", uuid.NewString())