AD-вход: перебор резервных контроллеров и повтор при сбое сети

Раньше вход по AD бил в один контроллер (adDial → один DialURL) одной попыткой: любой
кратковременный сбой TLS/сети к DC выглядел для пользователя как неверный пароль, вход
был нестабилен. Теперь:
- ad_server может перечислять несколько контроллеров (запятая/пробел/перенос строки) —
  перебираются по порядку как резерв;
- единственный контроллер пробуется дважды, чтобы одиночный сетевой сбой не ронял вход;
- повтор только на временных сбоях (сеть/TLS/контроллер недоступен). Неверный пароль,
  блокировка/отключение учётки, истёкший пароль, нет пользователя/группы — окончательны
  (adFinalError/adNoGroupError/errADNotFound), повтора нет и домен-недоступен не маскирует их.
adDial (проверки/мастер) тоже получил перебор резерва. Тесты: adServerURLs, adErrTransient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fontvielle
2026-10-13 11:22:44 +03:00
parent 6550c9ef02
commit 6ada4855ce
2 changed files with 169 additions and 7 deletions
+62
View File
@@ -0,0 +1,62 @@
package lkgateway
import (
"errors"
"fmt"
"testing"
"github.com/go-ldap/ldap/v3"
)
func TestADServerURLs(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{"ldaps://dc1:636", []string{"ldaps://dc1:636"}},
{" ldaps://dc1:636 ", []string{"ldaps://dc1:636"}},
{"dc1, dc2 dc3", []string{"dc1", "dc2", "dc3"}},
{"ldaps://dc1:636\nldaps://dc2:636; ldaps://dc3:636", []string{"ldaps://dc1:636", "ldaps://dc2:636", "ldaps://dc3:636"}},
}
for _, c := range cases {
got := adServerURLs(AuthSettings{ADServer: c.in})
if len(got) != len(c.want) {
t.Fatalf("adServerURLs(%q) = %v, want %v", c.in, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("adServerURLs(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
}
}
}
}
func TestADErrTransient(t *testing.T) {
// окончательные отказы — повтор не нужен
final := []error{
&adFinalError{"неверный пароль"},
fmt.Errorf("обёртка: %w", &adFinalError{"нет пользователя"}),
errADNotFound,
&adNoGroupError{sam: "ivanov"},
}
for _, e := range final {
if adErrTransient(e) {
t.Errorf("adErrTransient(%v) = true, ожидался окончательный отказ", e)
}
}
// сетевые/прочие — можно повторить/перейти на резерв
transient := []error{
fmt.Errorf("подключение к ldaps://dc1:636: i/o timeout"),
fmt.Errorf("AD bind: %v", ldap.NewError(ldap.ErrorNetwork, errors.New("connection reset"))),
errors.New("StartTLS: handshake failure"),
}
for _, e := range transient {
if !adErrTransient(e) {
t.Errorf("adErrTransient(%v) = false, ожидался временный сбой", e)
}
}
if adErrTransient(nil) {
t.Error("adErrTransient(nil) должно быть false")
}
}
+107 -7
View File
@@ -646,8 +646,29 @@ type adResult struct {
MatchedBy string
}
func adDial(a AuthSettings) (*ldap.Conn, error) {
server := strings.TrimSpace(a.ADServer)
// adServerURLs — список контроллеров домена из настройки ad_server. Можно указать
// несколько адресов (резерв) через запятую, точку с запятой, пробел или перенос строки —
// они перебираются по порядку, пока один не ответит. Одна запись — поведение как раньше.
func adServerURLs(a AuthSettings) []string {
parts := strings.FieldsFunc(a.ADServer, func(r rune) bool {
switch r {
case ',', ';', '\n', '\r', '\t', ' ':
return true
}
return false
})
out := make([]string, 0, len(parts))
for _, s := range parts {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// adDialServer — подключение к одному контроллеру домена.
func adDialServer(a AuthSettings, server string) (*ldap.Conn, error) {
server = strings.TrimSpace(server)
if server == "" {
return nil, fmt.Errorf("не задан адрес контроллера домена")
}
@@ -682,6 +703,54 @@ func adDial(a AuthSettings) (*ldap.Conn, error) {
return conn, nil
}
// adDial — подключение к домену с перебором резервных контроллеров (первый ответивший).
// Используется проверками/мастером; вход (adAuthenticate) перебирает контроллеры сам,
// чтобы повтор охватывал и bind.
func adDial(a AuthSettings) (*ldap.Conn, error) {
servers := adServerURLs(a)
if len(servers) == 0 {
return nil, fmt.Errorf("не задан адрес контроллера домена")
}
var lastErr error
for _, server := range servers {
conn, err := adDialServer(a, server)
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
}
// adFinalError — окончательный отказ входа в AD (неверный пароль, учётка заблокирована/
// отключена, срок пароля истёк, пользователь не найден). Повтор и резервный контроллер не
// помогут — такой отказ не маскируем под «домен недоступен» и не пробуем ещё раз.
type adFinalError struct{ msg string }
func (e *adFinalError) Error() string { return e.msg }
// adErrTransient — ошибку стоит повторить/проверить на другом контроллере (сеть, TLS,
// контроллер недоступен). Окончательные отказы (неверный пароль, нет пользователя/группы)
// — нет: иначе исправный ввод выглядел бы как «домен недоступен», а неверный зря ждал бы
// повторов. Неклассифицированное считаем временным — это почти всегда сбой связи.
func adErrTransient(err error) bool {
if err == nil {
return false
}
var fin *adFinalError
if errors.As(err, &fin) {
return false
}
var noGroup *adNoGroupError
if errors.As(err, &noGroup) {
return false
}
if errors.Is(err, errADNotFound) {
return false
}
return true
}
// adAuthenticate — bind учётными данными пользователя, затем поиск его карточки
// и проверка членства в сопоставленных группах (с вложенностью,
// LDAP_MATCHING_RULE_IN_CHAIN).
@@ -690,7 +759,38 @@ func adAuthenticate(a AuthSettings, login, password string) (*adResult, error) {
if password == "" {
return nil, fmt.Errorf("пустой пароль")
}
conn, err := adDial(a)
servers := adServerURLs(a)
if len(servers) == 0 {
return nil, fmt.Errorf("не задан адрес контроллера домена")
}
// Порядок попыток — контроллеры по очереди (резерв). Единственный контроллер пробуем
// дважды: одиночный сбой сети/TLS не должен выглядеть для пользователя как неверный пароль.
attempts := servers
if len(attempts) == 1 {
attempts = []string{servers[0], servers[0]}
}
var lastErr error
for i, server := range attempts {
if i > 0 {
time.Sleep(400 * time.Millisecond)
}
res, err := adAuthenticateOn(a, server, login, password)
if err == nil {
return res, nil
}
if !adErrTransient(err) {
return res, err // неверный пароль / нет пользователя / нет группы — повтор не нужен
}
lastErr = err
log.Printf("auth: контроллер домена %s не ответил, пробуем следующий: %v", server, err)
}
return nil, fmt.Errorf("контроллер домена недоступен, вход по AD временно невозможен: %v", lastErr)
}
// adAuthenticateOn — одна попытка входа на конкретном контроллере: bind, поиск карточки,
// проверка роли по группам.
func adAuthenticateOn(a AuthSettings, server, login, password string) (*adResult, error) {
conn, err := adDialServer(a, server)
if err != nil {
return nil, err
}
@@ -698,19 +798,19 @@ func adAuthenticate(a AuthSettings, login, password string) (*adResult, error) {
sam, upn := adSplitLogin(a, login)
if err := conn.Bind(upn, password); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
return nil, fmt.Errorf("%s", adBindReason(err))
return nil, &adFinalError{adBindReason(err)} // неверный пароль/блокировка — окончательно
}
return nil, fmt.Errorf("AD bind: %v", err)
return nil, fmt.Errorf("AD bind: %v", err) // прочее (сеть/TLS) — можно повторить
}
res, base, err := adLookup(conn, a, sam, upn)
if err == errADNotFound {
return nil, fmt.Errorf("пользователь %s не найден в %s", sam, base)
return nil, &adFinalError{fmt.Sprintf("пользователь %s не найден в %s", sam, base)}
}
if err != nil {
return res, err
}
if err := adResolveRole(conn, a, base, sam, res); err != nil {
return res, err
return res, err // в т.ч. adNoGroupError — его разбирает вызывающий authenticate
}
return res, nil
}