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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user