Files
transcriber/internal/controller/http/identity.go
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

247 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}