вход переехал на доверенный заголовок 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
+58 -56
View File
@@ -3,9 +3,8 @@ package config
import (
"errors"
"fmt"
"net/url"
"net/netip"
"os"
"sort"
"strings"
"time"
@@ -81,64 +80,67 @@ type YandexConfig struct {
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
// секрет, положенный однажды шагом, не пережил бы ротации.
// AuthConfig — кому сервис верит на входе.
//
// Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком,
// сходив к провайдеру. Секция поэтому свелась к одному ключу — перечню адресов,
// чьему заголовку верить. Ни адресов провайдера, ни идентификатора клиента, ни
// его секрета здесь больше нет: обменивать код не на что, и секрет ушёл из
// конфига вместе с протоколом.
type AuthConfig struct {
AuthURL string `toml:"auth_url"`
TokenURL string `toml:"token_url"`
UserInfoURL string `toml:"user_info_url"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
RedirectURL string `toml:"redirect_url"`
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
// выключается только для локального запуска по `http://localhost`, где
// браузер такую куку не сохранит.
SecureCookie bool `toml:"secure_cookie"`
// TrustedProxies — адреса и подсети, чьему заголовку верят. Значение
// сверяется с адресом самого соединения, а не с пересылаемым заголовком:
// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
// той же строкой, которой он обходится, не барьер вовсе.
TrustedProxies []string `toml:"trusted_proxies"`
}
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
// открытым наружу, а узнать об этом было бы неоткуда.
// TrustedNetworks разбирает перечень доверенных адресов.
//
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
// молча падает целиком, вместе с ботом и воркерами.
// Одиночный адрес принимается наравне с подсетью и превращается в подсеть на
// один адрес: писать `/32` руками значит помнить разрядность, а перечень читает
// человек.
func (c AuthConfig) TrustedNetworks() ([]netip.Prefix, error) {
networks := make([]netip.Prefix, 0, len(c.TrustedProxies))
for _, raw := range c.TrustedProxies {
value := strings.TrimSpace(raw)
if prefix, err := netip.ParsePrefix(value); err == nil {
networks = append(networks, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(value)
if err != nil {
return nil, fmt.Errorf("auth: %s не читается как адрес или подсеть: %q", trustedProxiesKey, value)
}
networks = append(networks, netip.PrefixFrom(addr, addr.BitLen()))
}
return networks, nil
}
// trustedProxiesKey — имя ключа в отказах старта. Литерал один на файл: два
// разошлись бы молча, и владелец искал бы в конфиге ключ, которого там нет.
const trustedProxiesKey = "trusted_proxies"
// Validate проверяет, что сервису есть кому верить.
//
// Пустой перечень роняет старт. Он значит «не верить никому», то есть сервис,
// поднявшийся никого не узнающим, — и молчать об этом старт не вправе: узнать о
// такой поломке было бы неоткуда, все адреса приложения просто отвечали бы
// отказом.
//
// Нечитаемая строка роняет старт по той же причине: перечень с опечаткой
// проверяется только тем, что кто-то не смог войти.
func (c AuthConfig) Validate() error {
values := map[string]string{
"auth_url": c.AuthURL,
"token_url": c.TokenURL,
"user_info_url": c.UserInfoURL,
"client_id": c.ClientID,
"client_secret": c.ClientSecret,
"redirect_url": c.RedirectURL,
if len(c.TrustedProxies) == 0 {
return fmt.Errorf("auth: не заполнен ключ %s: сервису некому верить, и узнать он никого не сможет", trustedProxiesKey)
}
missing := make([]string, 0, len(values))
for name, value := range values {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
// Названы имена ключей, а не значения: значение `client_secret` в
// сообщение об ошибке попасть не должно, оно уедет в журнал.
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
}
malformed := make([]string, 0, 4)
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
parsed, err := url.Parse(values[name])
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
malformed = append(malformed, name)
}
}
if len(malformed) > 0 {
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
if _, err := c.TrustedNetworks(); err != nil {
return err
}
return nil
@@ -173,9 +175,9 @@ func defaultConfig() *Config {
ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
},
Auth: AuthConfig{
SecureCookie: true,
},
// Умолчания у перечня доверенных адресов нет намеренно: подставленное
// значение соврало бы ровно там, где по нему решают, кого пускать.
Auth: AuthConfig{},
}
}