- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
231 lines
12 KiB
Go
231 lines
12 KiB
Go
package http
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"net/netip"
|
||
|
||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||
)
|
||
|
||
// Заголовки, которыми обратный прокси называет пришедшего.
|
||
//
|
||
// Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка,
|
||
// которая сама ставит и сама читает своё имя, этого не замечает. Контур уже
|
||
// пишет эти имена соседним сервисам, и настройкой они не делаются: второе место,
|
||
// где их можно написать неверно, выгоды не даёт.
|
||
const (
|
||
LoginHeader = "Remote-User"
|
||
NameHeader = "Remote-Name"
|
||
EmailHeader = "Remote-Email"
|
||
)
|
||
|
||
// IdentityHeaderNames отдаёт имена заголовков входа целиком, в одном порядке.
|
||
//
|
||
// Перечисление тройки живёт здесь и только здесь. Всякий, кому нужен её состав —
|
||
// слой подстановки, проверка старта, строка журнала, — берёт его отсюда: второй
|
||
// список разошёлся бы с первым молча, а имена нормативны.
|
||
func IdentityHeaderNames() []string {
|
||
return []string{LoginHeader, NameHeader, EmailHeader}
|
||
}
|
||
|
||
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
|
||
// Читает его только ограничитель частоты: барьером узнавания он не служит и
|
||
// служить не может — кто пришёл, решает адрес самого соединения.
|
||
const ForwardedForHeader = "X-Forwarded-For"
|
||
|
||
// accountKey — ключ, под которым узнанная учётная запись живёт в контексте
|
||
// запроса. Свой тип, а не строка: чужой ключ с тем же текстом иначе перезаписал
|
||
// бы значение.
|
||
type accountKey struct{}
|
||
|
||
// AccountOf отдаёт учётную запись, от имени которой идёт запрос. Второе значение
|
||
// ложно у неузнанного.
|
||
func AccountOf(r *http.Request) (*contract.UserAccount, bool) {
|
||
account, ok := r.Context().Value(accountKey{}).(*contract.UserAccount)
|
||
return account, ok
|
||
}
|
||
|
||
// withAccount кладёт узнанную учётную запись в контекст запроса.
|
||
func withAccount(r *http.Request, account *contract.UserAccount) *http.Request {
|
||
return r.WithContext(context.WithValue(r.Context(), accountKey{}, account))
|
||
}
|
||
|
||
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
|
||
//
|
||
// # Область
|
||
//
|
||
// Слой вешается на цепочку корня приложения и только на неё. Область поэтому
|
||
// выводится из объявленного адресного пространства сервиса, а не перечисляется
|
||
// вторым списком: корень, переехавший в перечне, уносит слой с собой.
|
||
//
|
||
// Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не
|
||
// срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе
|
||
// запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с
|
||
// новым именем — записи в неё.
|
||
//
|
||
// # Чего слой не делает
|
||
//
|
||
// Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием
|
||
// учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а
|
||
// не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там,
|
||
// где легла база.
|
||
func TrustedHeaderIdentity(
|
||
users contract.UserRepository,
|
||
trusted []netip.Prefix,
|
||
logger *slog.Logger,
|
||
) func(http.Handler) http.Handler {
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// Более одного значения — не выбор, а отказ. Прокси, настроенный
|
||
// добавлять заголовок вместо замены, оставляет рядом со своим
|
||
// значением присланное анонимом, и умолчание «берём первое» отдало
|
||
// бы вход анониму.
|
||
values := r.Header.Values(LoginHeader)
|
||
if len(values) != 1 {
|
||
if len(values) > 1 {
|
||
// Уровень предупреждающий: два значения означают прокси,
|
||
// который заголовок **добавляет** вместо замены, — то есть
|
||
// ровно ту поломку контура, которую модель угроз называет
|
||
// главной. Отладочным уровнем она в бою не видна вовсе.
|
||
logger.Warn("Request carries more than one login header",
|
||
"http.peer_addr", r.RemoteAddr,
|
||
"capability", "access", "transport", "http")
|
||
} else {
|
||
// Заголовка нет вовсе. В этом контуре это значит, что прокси
|
||
// никого не назвал; строка отладочная, потому что случай
|
||
// штатный — так выглядит и человек, которому провайдер
|
||
// отказал.
|
||
logger.Debug("Request carries no login header",
|
||
"http.peer_addr", r.RemoteAddr,
|
||
"capability", "access", "transport", "http")
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
peer, ok := peerAddress(r.RemoteAddr)
|
||
if !ok || !isTrusted(trusted, peer) {
|
||
// Уровень предупреждающий, а не отладочный, и это решение о
|
||
// цене. Заголовок с недоверенного адреса в этом контуре — не
|
||
// рутина: контейнер портов наружу не публикует, снаружи всё
|
||
// приходит прокси, то есть с доверенного адреса. Значит либо
|
||
// перечень задан неверно, либо кто-то оказался внутри сети — и
|
||
// то и другое владелец обязан увидеть.
|
||
//
|
||
// Значение заголовка при этом в журнал не идёт: оно целиком
|
||
// задаётся тем, кто шлёт запрос. Адрес пира идёт — по нему
|
||
// видно, чья это поломка: своя (перечень) или контура (прокси
|
||
// заголовка не ставит).
|
||
logger.Warn("Login header came from an untrusted peer",
|
||
"http.peer_addr", r.RemoteAddr,
|
||
"capability", "access", "transport", "http")
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
account, created, err := users.EnsureUser(contract.Identity{
|
||
Login: values[0],
|
||
Name: r.Header.Get(NameHeader),
|
||
Email: r.Header.Get(EmailHeader),
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, contract.ErrLoginNotAcceptable) {
|
||
// Негодный логин — это негодный ввод, а не поломка сервиса:
|
||
// пустой заголовок прокси шлёт штатно там, где никого не
|
||
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
|
||
// неузнанным.
|
||
logger.Debug("Login header value is not acceptable",
|
||
"http.peer_addr", r.RemoteAddr,
|
||
"capability", "access", "transport", "http")
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
logger.Error("Failed to resolve account by login header",
|
||
"error", err, "capability", "access", "transport", "http")
|
||
fail(w, err)
|
||
return
|
||
}
|
||
|
||
if created {
|
||
// Заведение учётной записи — событие, и владелец обязан его
|
||
// видеть: иначе «никто не заходил» неотличимо от «завелось
|
||
// двадцать», а прокси, пропустивший чужой заголовок, не
|
||
// оставляет следа вовсе. Убрать заведённую запись потом нечем —
|
||
// учётная запись с записями не удаляется.
|
||
//
|
||
// Значение заголовка в строку не идёт: им довольно назваться,
|
||
// чтобы стать этим человеком. Идут адрес пира и идентификатор
|
||
// записи — оба выданы не спрашивающим.
|
||
logger.Info("Account created from login header",
|
||
"http.peer_addr", r.RemoteAddr,
|
||
"account_id", account.ID,
|
||
"capability", "access", "transport", "http")
|
||
}
|
||
|
||
next.ServeHTTP(w, withAccount(r, account))
|
||
})
|
||
}
|
||
}
|
||
|
||
// RequireUser — слой предъявления адресов приложения.
|
||
//
|
||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||
// уложенный файл — чего сервис не умеет вовсе.
|
||
//
|
||
// Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит
|
||
// учётную запись само, и предъявителя без неё не бывает.
|
||
func RequireUser() func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := AccountOf(r); !ok {
|
||
fail(w, contract.ErrUnauthorized)
|
||
return
|
||
}
|
||
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|