Files
transcriber/internal/controller/http/identity.go
T
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00

231 lines
12 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 (
"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
}