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