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

- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
av
2026-08-22 20:24:22 +03:00
parent e4441f3c49
commit 7f33c957e5
63 changed files with 5257 additions and 2305 deletions
+246
View File
@@ -0,0 +1,246 @@
package http
import (
"errors"
"log/slog"
"net"
"net/netip"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
)
// Заголовки, которыми обратный прокси называет пришедшего.
//
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена
// соседним сервисам, и настройкой они не делаются: второе место, где их можно
// написать неверно, выгоды не даёт.
const (
LoginHeader = "Remote-User"
NameHeader = "Remote-Name"
EmailHeader = "Remote-Email"
)
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла.
//
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе —
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
const FileTokenPath = StorageRoot + "/files/token"
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
//
// # Область
//
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но
// узнаёт он **только на объявленной области**: корень приложения плюс этот
// адрес. Область выводится из перечня адресного пространства, а не пишется
// вторым списком.
//
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение
// настоящего владельца этого имени попало бы в чужую запись вместе со всем
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
// два барьера здесь именно потому, что прежний был один и держался на
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
//
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
// базе, а первый такой запрос с новым именем — записи в неё.
//
// # Кто побеждает
//
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
// учётной записью пользователя отобрала бы у него панель посреди работы.
// Протухший и негодный токен предъявленными не считаются: библиотека их не
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
//
// # Чего слой не делает
//
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием
// учётной записи на адресах приложения.
//
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
// легла база.
func TrustedHeaderIdentity(
app core.App,
mounts []Mount,
trusted []netip.Prefix,
logger *slog.Logger,
) *hook.Handler[*core.RequestEvent] {
if logger == nil {
logger = slog.Default()
}
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberTrustedHeaderIdentity",
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
// убираются ничем.
//
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
// узнавание (−999) → требование учётной записи (−998) → предел тела
// (−990). Требование стоит перед пределом тела намеренно: отказ
// неузнанному обязан наступать до чтения тела.
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
Func: func(e *core.RequestEvent) error {
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
return e.Next()
}
// Более одного значения — не выбор, а отказ. Прокси, настроенный
// добавлять заголовок вместо замены, оставляет рядом со своим
// значением присланное анонимом, и умолчание «берём первое» отдало
// бы вход анониму.
values := e.Request.Header.Values(LoginHeader)
if len(values) != 1 {
if len(values) > 1 {
// Уровень предупреждающий: два значения означают прокси,
// который заголовок **добавляет** вместо замены, — то есть
// ровно ту поломку контура, которую модель угроз называет
// главной. Отладочным уровнем она в бою не видна вовсе:
// боевой уровень журнала информационный.
logger.Warn("Request carries more than one login header",
"http.peer_addr", e.Request.RemoteAddr,
"capability", "access", "transport", "http")
} else {
// Заголовка нет вовсе. В этом контуре это значит, что прокси
// никого не назвал; строка отладочная, потому что случай
// штатный — так выглядит и человек, которому провайдер
// отказал.
logger.Debug("Request carries no login header",
"http.peer_addr", e.Request.RemoteAddr,
"capability", "access", "transport", "http")
}
return e.Next()
}
peer, ok := peerAddress(e.Request.RemoteAddr)
if !ok || !isTrusted(trusted, peer) {
// Уровень предупреждающий, а не отладочный, и это решение о
// цене. Заголовок с недоверенного адреса в этом контуре — не
// рутина: контейнер портов наружу не публикует, снаружи всё
// приходит прокси, то есть с доверенного адреса. Значит либо
// перечень задан неверно, либо кто-то оказался внутри сети — и
// то и другое владелец обязан увидеть.
//
// Значение заголовка при этом в журнал не идёт: оно целиком
// задаётся тем, кто шлёт запрос. Адрес пира идёт — по нему
// видно, чья это поломка: своя (перечень) или контура (прокси
// заголовка не ставит).
logger.Warn("Login header came from an untrusted peer",
"http.peer_addr", e.Request.RemoteAddr,
"capability", "access", "transport", "http")
return e.Next()
}
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{
Login: values[0],
Name: e.Request.Header.Get(NameHeader),
Email: e.Request.Header.Get(EmailHeader),
})
if err != nil {
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) {
// Негодный логин — это негодный ввод, а не поломка сервиса:
// пустой заголовок прокси шлёт штатно там, где никого не
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
// неузнанным.
logger.Debug("Login header value is not acceptable",
"http.peer_addr", e.Request.RemoteAddr,
"capability", "access", "transport", "http")
return e.Next()
}
logger.Error("Failed to resolve account by login header",
"error", err, "capability", "access", "transport", "http")
return fail(e, err)
}
if created {
// Заведение учётной записи — событие, и владелец обязан его
// видеть: иначе «никто не заходил» неотличимо от «завелось
// двадцать», а прокси, пропустивший чужой заголовок, не
// оставляет следа вовсе. Убрать заведённую запись потом нечем —
// учётная запись с записями не удаляется.
//
// Значение заголовка в строку не идёт: им довольно назваться,
// чтобы стать этим человеком. Идут адрес пира и идентификатор
// записи — оба выданы не спрашивающим.
logger.Info("Account created from login header",
"http.peer_addr", e.Request.RemoteAddr,
"account_id", record.Id,
"capability", "access", "transport", "http")
}
e.Auth = record
return e.Next()
},
}
}
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути.
//
// Область — корень приложения из перечня адресного пространства плюс адрес
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень
// объявлен единой точкой адресного пространства, и записанный здесь второй раз
// он разошёлся бы с ней молча.
func underIdentifiedArea(mounts []Mount, requestPath string) bool {
if requestPath == FileTokenPath {
return true
}
for _, mount := range mounts {
if mount.Path == AppRoot {
return mount.Covers(requestPath)
}
}
return false
}
// peerAddress достаёт адрес того, кто открыл соединение.
//
// Берётся именно он, а не пересылаемый заголовок: значением пересылаемого
// распоряжается тот, кто шлёт запрос, и барьер, подделываемый той же строкой,
// которой он обходится, не барьер вовсе.
func peerAddress(remoteAddr string) (netip.Addr, bool) {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
// Адрес без порта — законная форма у некоторых слушателей.
host = remoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return netip.Addr{}, false
}
// Адрес IPv4, приехавший в оболочке IPv6, сверяется с перечнем как IPv4:
// иначе `127.0.0.1` в перечне не совпал бы с `::ffff:127.0.0.1` у пира.
return addr.Unmap(), true
}
func isTrusted(trusted []netip.Prefix, peer netip.Addr) bool {
for _, network := range trusted {
if network.Contains(peer) {
return true
}
}
return false
}