вход переехал на доверенный заголовок 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
+126
View File
@@ -0,0 +1,126 @@
// Command devtools — оснастка разработчика: то, что нужно для локального
// прогона и никогда не едет в боевой образ.
//
// Пакет один на все такие инструменты, а не по пакету на инструмент. Причина
// счётная: каждый отдельный пакет стоит четырёх мест — строка сборки образа,
// «Деплой» в устройстве, «Команды» в памятке, README, — и забытая строка сборки
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
//
// Сегодня подкоманда одна — `proxy`. Заведение владельца панели придёт
// подкомандой `admin` вместе с задачей о запуске одной командой.
//
// Имена заголовков берутся **константами транспорта**, а не литералами: они
// нормативны, и второй список разошёлся бы с первым молча — локальный вход
// перестал бы узнавать кого бы то ни было, а искали бы поломку в сервисе.
//
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
//
// В образ пакет не едет: ступень сборки называет `./cmd/transcriber` поимённо.
package main
import (
"flag"
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
)
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "proxy":
runProxy(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprint(os.Stderr, `Оснастка разработчика.
Подкоманды:
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
`)
}
// runProxy поднимает подставной обратный прокси.
//
// Зачем он нужен: сервис узнаёт пришедшего по заголовку, который на сервере
// ставит Caddy, сходив к Authelia. На машине разработчика ни того ни другого
// нет, а браузер заголовков не ставит — значит приложение локально не
// открывалось бы вовсе. Прокси встаёт на их место: слушает свой порт, ставит
// заголовок и переправляет запрос сервису.
//
// Проверок он не делает никаких — ни пароля, ни группы, ни срока. Это его
// назначение, а не упущение: пускать он должен всякого, кто до него дошёл, а
// сам он слушает петлевой адрес.
func runProxy(args []string) {
flags := flag.NewFlagSet("proxy", flag.ExitOnError)
listen := flags.String("listen", "127.0.0.1:9000", "адрес, на котором слушать")
target := flags.String("target", "http://127.0.0.1:8080", "адрес сервиса")
login := flags.String("user", "dev", "логин, которым называть пришедшего")
name := flags.String("name", "Разработчик", "имя, пригодное к показу")
email := flags.String("email", "", "адрес почты; пустой не ставится вовсе")
if err := flags.Parse(args); err != nil {
os.Exit(2)
}
// Слушаем петлевой адрес по умолчанию, и это часть назначения: прокси
// пускает всякого, кто до него дошёл, и в чужой сети становится открытым
// входом в сервис.
upstream, err := url.Parse(*target)
if err != nil {
log.Fatalf("адрес сервиса не читается: %v", err)
}
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(upstream)
// Именно Set, а не Add. Прокси, который **добавляет** заголовок к
// присланному, оставляет рядом со своим значением чужое — и сервис
// отвергает такой запрос целиком, потому что двух значений он не
// разбирает. Настоящий Caddy обязан делать то же самое, и это
// записано требованием к контуру в модели угроз.
r.Out.Header.Set(httpcontroller.LoginHeader, *login)
r.Out.Header.Set(httpcontroller.NameHeader, *name)
if *email != "" {
r.Out.Header.Set(httpcontroller.EmailHeader, *email)
} else {
r.Out.Header.Del(httpcontroller.EmailHeader)
}
},
}
log.Printf("подставной прокси: %s → %s, пришедший — %q", *listen, *target, *login)
log.Printf("приложение открывать по http://%s", *listen)
server := &http.Server{
Addr: *listen,
Handler: proxy,
// Таймаута чтения нет намеренно: сервис принимает записи на несколько
// часов, и прокси, обрывающий такую загрузку, ловил бы разработчика на
// поломке, которой в сервисе нет.
ReadTimeout: 0,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("подставной прокси остановлен: %v", err)
}
}
-178
View File
@@ -1,178 +0,0 @@
// Команда oidcstub — подставной провайдер OIDC для локального запуска.
//
// Сервис держит вход через внешнего провайдера, и без него в приложение не
// попасть вовсе: секция `[auth]` обязательна, а выдуманные адреса разбираются
// как ссылки, но никуда не ведут. Настоящая Authelia — отдельный сервис, который
// надо развернуть и настроить, а ключи боевого провайдера на машине разработчика
// лежать не должны.
//
// Заглушка отвечает на `/authorize`, `/token` и `/userinfo`, и этого хватает:
// `user_info_url` — обязательный ключ конфига, а при заполненном адресе сведений
// библиотека хранилища берёт их обычным запросом с предъявленным токеном и
// `id_token` не смотрит вовсе. Поэтому здесь нет ни ключей подписи, ни документа
// обнаружения.
//
// Проверок она не делает никаких — ни секрета клиента, ни проверочного кода
// PKCE, ни выданного токена. Вход у неё один и заранее известный: она нужна,
// чтобы дойти до куки сессии, а не чтобы изображать провайдера. По той же
// причине слушает она только петлевой адрес.
//
// Своих тестов у заглушки нет, и это решение владельца от 2026-08-15, а не
// недосмотр. Разбор `redirect_uri` и выбор метода машиной поэтому не
// проверяются: регресс в них ловится руками, на первом же локальном входе.
// Довод — цена отказа: в образ этот пакет не едет, читатель у него один, а сам
// отказ громкий и виден сразу. Путь входа при этом проверен и без неё — своим
// подставным провайдером в `internal/controller/http/login_test.go`.
//
// Запуск:
//
// go run ./cmd/oidcstub
// go run ./cmd/oidcstub -sub local-2
//
// Другое значение `-sub` заводит второго вошедшего, и почта переезжает вместе с
// ним: без своей почты второй вход отвергается вовсе. Хранилище ищет учётную
// запись сперва по признаку провайдера, а не найдя — по адресу почты, и на
// найденной записи признак уникален. Второй `sub` при общей почте пришёл бы к той
// же записи и получил бы отказ уникальности — вход отвечал бы 401, а причина
// осталась бы в журнале хранилища строкой про связь.
package main
import (
"encoding/json"
"flag"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"time"
)
// authCode — код, который заглушка отдаёт на возврате. Значение постоянное:
// одноразовость кода держит провайдер, а здесь её изображать не для кого.
const authCode = "local-code"
// accessToken — то, что уезжает в обмен и приходит обратно заголовком запроса
// сведений о вошедшем. Заглушка его не сверяет.
const accessToken = "local-token"
// readHeaderTimeout — потолок чтения заголовков. Заглушка стоит на петлевом
// адресе, но сервер без единого срока держит подвисшее соединение вечно.
const readHeaderTimeout = 5 * time.Second
func main() {
port := flag.Int("port", 9000, "Порт заглушки")
sub := flag.String("sub", "local-1", "Идентификатор вошедшего: разные значения дают разных владельцев записей")
name := flag.String("name", "Локальный", "Имя вошедшего")
email := flag.String("email", "", "Почта вошедшего; пустое значение даёт <sub>@example.com")
flag.Parse()
// Почта выводится из `sub`, а не стоит своим умолчанием: общая почта у двух
// разных `sub` приводит второй вход к первой записи, а признак провайдера на
// ней уникален — второй вошедший получал бы 401 вместо своей учётной записи.
// Обещание «разные `-sub` дают разных владельцев» держится этой строкой.
if *email == "" {
*email = *sub + "@example.com"
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
mux := http.NewServeMux()
mux.HandleFunc("/authorize", authorize(logger))
mux.HandleFunc("/token", token(logger))
mux.HandleFunc("/userinfo", userInfo(logger, *sub, *name, *email))
// Петлевой адрес, а не все — заглушка пускает кого угодно кем угодно, и в
// чужой сети это дыра, а не удобство.
addr := fmt.Sprintf("127.0.0.1:%d", *port)
server := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: readHeaderTimeout,
}
logger.Info("oidc stub started", "addr", addr, "sub", *sub)
if err := server.ListenAndServe(); err != nil {
logger.Error("oidc stub stopped", "error", err)
os.Exit(1)
}
}
// authorize уводит браузер обратно на адрес возврата с готовым кодом.
//
// Настоящий провайдер спросил бы здесь имя и пароль; заглушка не спрашивает
// ничего и возвращает сразу — в этом вся её работа.
func authorize(logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
target, err := url.Parse(query.Get("redirect_uri"))
if err != nil || target.Host == "" {
logger.Error("authorize rejected", "reason", "redirect_uri is missing or malformed")
http.Error(w, "redirect_uri не задан или не разбирается", http.StatusBadRequest)
return
}
// Состояние возвращается тем же значением, каким пришло: сервис сверяет
// его со своей кукой и без совпадения отвергает возврат.
back := target.Query()
back.Set("code", authCode)
if state := query.Get("state"); state != "" {
back.Set("state", state)
}
target.RawQuery = back.Encode()
logger.Info("authorize passed", "redirect_host", target.Host)
http.Redirect(w, r, target.String(), http.StatusFound)
}
}
// token отдаёт токен в обмен на код. Ни код, ни секрет клиента, ни проверочный
// код PKCE не сверяются.
func token(logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "обмен идёт методом POST", http.StatusMethodNotAllowed)
return
}
// `id_token` не отдаётся намеренно: подписать его нечем, а библиотека
// хранилища его и не смотрит — `user_info_url` стоит в перечне
// обязательных ключей конфига, и без него сервис не поднимается вовсе.
writeJSON(logger, w, map[string]any{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
})
}
}
// userInfo отдаёт сведения о вошедшем.
//
// `email_verified` обязано быть истинным: без него библиотека адрес почты в
// учётную запись не запишет, и запись заведётся без него.
func userInfo(logger *slog.Logger, sub, name, email string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
writeJSON(logger, w, map[string]any{
"sub": sub,
"name": name,
"preferred_username": sub,
"email": email,
"email_verified": true,
})
}
}
// writeJSON отвечает разметкой JSON. Отказ записи идёт в журнал: ответ к этому
// моменту уже начат, и сказать о нём спрашивающему нечем.
func writeJSON(logger *slog.Logger, w http.ResponseWriter, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(body); err != nil {
logger.Error("response write failed", "error", err)
}
}
-1
View File
@@ -16,7 +16,6 @@ func journalMounts() []httpcontroller.Mount {
{Path: httpcontroller.StorageRoot},
{Path: httpcontroller.PanelRoot},
{Path: httpcontroller.AppRoot},
{Path: httpcontroller.AuthRoot},
{Path: httpcontroller.HealthPath, Exact: true},
{Path: httpcontroller.MetricsPath, Exact: true},
}
+30 -22
View File
@@ -53,13 +53,26 @@ func main() {
logger.Info("Configuration loaded successfully", "config_path", *configPath)
}
// Незаполненный вход роняет старт: подняться с молча выключенным входом
// значит остаться открытым наружу, и узнать об этом было бы неоткуда.
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
// этом было бы неоткуда.
if err := cfg.Auth.Validate(); err != nil {
logger.Error("Unable to start with incomplete login settings", "error", err)
logger.Error("Unable to start without trusted proxies", "error", err)
os.Exit(1)
}
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
// значило бы платить за настройку, которая не меняется.
trustedNetworks, err := cfg.Auth.TrustedNetworks()
if err != nil {
logger.Error("Unable to read trusted proxies", "error", err)
os.Exit(1)
}
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
// доходит вовсе, — а это разные поломки в разных местах.
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом.
@@ -161,18 +174,12 @@ func main() {
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда.
appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger)
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
AuthURL: cfg.Auth.AuthURL,
RedirectURL: cfg.Auth.RedirectURL,
ClientID: cfg.Auth.ClientID,
SecureCookie: cfg.Auth.SecureCookie,
}, logger)
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
// наблюдения и правило неизвестного пути у раздачи приложения.
mounts := httpcontroller.ServiceMounts(appHandler, authHandler, promhttp.Handler())
mounts := httpcontroller.ServiceMounts(appHandler, promhttp.Handler())
dist, appBuilt := web.Dist()
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger)
@@ -223,18 +230,12 @@ func main() {
return err
})
// Настройки провайдера приводятся к конфигу при каждом подъёме:
// применённый шаг схемы не переписывается, и секрет, положенный
// однажды шагом, не пережил бы ротации.
if err := pbrepo.ApplyProviderSettings(storage, pbrepo.ProviderSettings{
AuthURL: cfg.Auth.AuthURL,
TokenURL: cfg.Auth.TokenURL,
UserInfoURL: cfg.Auth.UserInfoURL,
ClientID: cfg.Auth.ClientID,
ClientSecret: cfg.Auth.ClientSecret,
}); err != nil {
return fmt.Errorf("failed to apply provider settings: %w", err)
}
// Слой узнавания вешается на **корневой** роутер, а не под корнем
// приложения: он накрывает ещё и адрес выдачи файлового токена, который
// принадлежит роутеру хранилища и группой не накрывается. Область его
// действия при этом выводится из перечня адресного пространства — см.
// `underIdentifiedArea`, — а не из места привязки.
se.Router.Bind(httpcontroller.TrustedHeaderIdentity(storage, mounts, trustedNetworks, logger))
// Своё правило ограничителя частоты под корень приложения. Правило
// хранилища настроено на его собственный корень и наших адресов больше не
@@ -244,6 +245,13 @@ func main() {
return fmt.Errorf("failed to apply app rate limit: %w", err)
}
// Хранилищу называется заголовок, из которого брать адрес
// спрашивающего. Без этого счётчик ограничителя ключуется адресом пира,
// а пир теперь всегда один — прокси, и бюджет становится общим на всех.
if err := httpcontroller.ApplyTrustedProxyHeaders(storage); err != nil {
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
}
httpcontroller.RegisterServiceRoutes(se.Router, mounts)
// Раздача приложения вешается последней: она занимает корень, и всё,