вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
+58
-56
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,97 +8,65 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
|
||||
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
|
||||
// исполнялась ни разу.
|
||||
// Проверка перечня доверенных адресов — единственная страховка от того, чтобы
|
||||
// сервис поднялся никого не узнающим. Узнать о такой поломке было бы неоткуда:
|
||||
// все адреса приложения просто отвечали бы отказом.
|
||||
|
||||
func validAuthConfig() AuthConfig {
|
||||
return AuthConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "secret-value",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
}
|
||||
return AuthConfig{TrustedProxies: []string{"172.16.0.0/12", "127.0.0.1"}}
|
||||
}
|
||||
|
||||
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
|
||||
if err := validAuthConfig().Validate(); err != nil {
|
||||
t.Fatalf("заполненный конфиг отвергнут: %v", err)
|
||||
t.Fatalf("заполненный перечень отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
|
||||
cases := map[string]func(*AuthConfig){
|
||||
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
|
||||
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
|
||||
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
|
||||
"client_id": func(c *AuthConfig) { c.ClientID = "" },
|
||||
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
|
||||
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
|
||||
}
|
||||
|
||||
for key, clear := range cases {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
clear(&cfg)
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
|
||||
}
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Fatalf("имя ключа %s не названо: %v", key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
|
||||
// и значения секрета в нём быть не должно — только имя ключа.
|
||||
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
cfg.ClientSecret = "super-secret-value"
|
||||
cfg.AuthURL = ""
|
||||
|
||||
err := cfg.Validate()
|
||||
// Пустой перечень значит «не верить никому»: сервис поднялся бы, никого не
|
||||
// узнавая, и молчать об этом старт не вправе.
|
||||
func TestAuthConfigValidateRejectsEmptyList(t *testing.T) {
|
||||
err := AuthConfig{}.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("отказа нет")
|
||||
t.Fatal("пустой перечень принят: сервис поднялся бы никого не узнающим")
|
||||
}
|
||||
if strings.Contains(err.Error(), "super-secret-value") {
|
||||
t.Fatalf("значение секрета попало в текст отказа: %v", err)
|
||||
if !strings.Contains(err.Error(), "trusted_proxies") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
|
||||
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
|
||||
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
|
||||
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"без схемы": "auth.example.com/api/oidc/authorization",
|
||||
"пробел спереди": " https://auth.example.com/authorize",
|
||||
"чужая схема": "ftp://auth.example.com/authorize",
|
||||
"пустой хост": "https:///authorize",
|
||||
"не адрес вовсе": "todo: заполнить",
|
||||
}
|
||||
|
||||
for name, value := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
cfg.AuthURL = value
|
||||
// Нечитаемая строка роняет старт: перечень с опечаткой проверяется только тем,
|
||||
// что кто-то не смог войти.
|
||||
func TestAuthConfigValidateRejectsMalformedEntry(t *testing.T) {
|
||||
for _, value := range []string{"", "not-an-address", "10.0.0.0/99", "10.0.0.256"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
cfg := AuthConfig{TrustedProxies: []string{value}}
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("негодный адрес %q пропущен", value)
|
||||
t.Fatalf("строка %q принята как адрес", value)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "auth_url") {
|
||||
if !strings.Contains(err.Error(), "trusted_proxies") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Одиночный адрес принимается наравне с подсетью и становится подсетью на один
|
||||
// адрес: писать разрядность руками значит её помнить, а перечень читает человек.
|
||||
func TestTrustedNetworksAcceptsBareAddress(t *testing.T) {
|
||||
networks, err := AuthConfig{TrustedProxies: []string{"10.1.2.3"}}.TrustedNetworks()
|
||||
if err != nil {
|
||||
t.Fatalf("одиночный адрес отвергнут: %v", err)
|
||||
}
|
||||
if len(networks) != 1 {
|
||||
t.Fatalf("подсетей %d, ожидалась одна", len(networks))
|
||||
}
|
||||
if !networks[0].IsSingleIP() {
|
||||
t.Fatalf("одиночный адрес стал подсетью шире одного адреса: %s", networks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user