package lkgateway // Сотрудники из домена в шаге «Учётные записи» (07.10.2026). Основной путь — создать учётную запись bj сразу // (логин и пароль); второй, необязательный, — подключить домен Active Directory прямо здесь, проверить связь и // выбрать сотрудника поиском: он входит своим доменным логином и паролем, bj проверяет пароль в домене. import ( "context" "crypto/sha1" "crypto/sha256" "crypto/x509" "crypto/x509/pkix" "encoding/hex" "encoding/pem" "fmt" "log" "net/http" "net/url" "sort" "strconv" "strings" "sync" "time" "github.com/go-ldap/ldap/v3" ) func (h *setupHandlers) registerADWizard(mux *http.ServeMux) { mux.HandleFunc("/admin/setup/users/ad-connect", h.adConnect) mux.HandleFunc("/admin/setup/users/ad-search", h.adSearch) mux.HandleFunc("/admin/setup/users/ad-trust", h.adTrust) mux.HandleFunc("/admin/setup/users/ad-discover", h.adDiscoverHTTP) mux.HandleFunc("/admin/setup/users/ad-group", h.adGroupSave) mux.HandleFunc("/admin/setup/users/ad-group-members", h.adGroupMembersHTTP) } // adConnect — POST: учётная запись домена и пароль; домен и контроллер — из расширенных настроек или bj находит // их сам (addiscover.go). Контроллеры пробуются по очереди: сначала LDAPS (636), если порт закрыт — LDAP с // StartTLS (389). Остальные настройки входа не трогает. func (h *setupHandlers) adConnect(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } cur := h.rc.Snapshot().Auth draft := adDraftAuth() login := strings.TrimSpace(r.FormValue("ad_bind_user")) pw := r.FormValue("ad_bind_password") if pw == "" && draft != nil && draft.ADBindUser == login { pw = draft.ADBindPassword // пароль, введённый в прошлой неудачной попытке } if pw == "" && cur.ADBindUser == login { pw = cur.ADBindPassword } form := adForm{Server: strings.TrimSpace(r.FormValue("ad_server")), Domain: strings.ToLower(strings.Trim(strings.TrimSpace(strings.TrimPrefix(r.FormValue("ad_domain"), "@")), "."))} a := cur a.ADBindUser, a.ADBindPassword = login, pw keep := func(b AuthSettings, msg string, cert *adCertInfo, adv bool) { form.Adv = adv || form.Server != "" || form.Domain != "" adDraftSet(b, msg, cert, form) h.usersFlash(w, r, "Домен: "+msg) } var miss []string if login == "" { miss = append(miss, "учётная запись домена") } if pw == "" { miss = append(miss, "пароль") } if len(miss) > 0 { keep(a, "не заполнено: "+strings.Join(miss, ", "), nil, false) return } domain := form.Domain if _, d, ok := strings.Cut(login, "@"); ok && domain == "" { domain = strings.ToLower(d) } var servers []string if form.Server != "" { servers = []string{form.Server} } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) if domain == "" || len(servers) == 0 { if f := adDiscover(ctx, domain); f != nil { if domain == "" { domain = f.Domain } if len(servers) == 0 { servers = f.DCs } } } cancel() if domain == "" { keep(a, "bj не нашёл домен сам: в сетевых настройках сервера bj его нет, а DNS-серверы не отвечают как контроллеры домена. Укажите домен в «Расширенных настройках» (например corp.example.ru) или учётную запись с доменом: svc-bj@corp.example.ru", nil, true) return } a.ADDomain = domain if len(servers) == 0 { keep(a, "контроллеры домена "+domain+" не нашлись в DNS (_ldap._tcp.dc._msdcs."+domain+") — укажите контроллер в «Расширенных настройках»", nil, true) return } if draft != nil && draft.ADCAPEM != "" && draft.ADDomain == domain { a.ADCAPEM = draft.ADCAPEM // сертификат, которому уже доверились } var errs []string for _, srv := range servers { for _, v := range adVariants(srv) { b := a b.ADServer, b.ADStartTLS = v.url, v.startTLS probe, cert, msg, next := adAttempt(b) if probe != "" { h.adSave(w, r, b, probe) return } if !next { keep(b, msg, cert, false) return } errs = append(errs, msg) } } keep(a, "ни один контроллер домена "+domain+" не подключился: "+strings.Join(errs, "; "), nil, true) } type adVariant struct { url string startTLS bool } // adVariants — как подключаться к контроллеру: адрес со схемой — как указан; имя — LDAPS, затем LDAP + StartTLS. func adVariants(srv string) []adVariant { if strings.Contains(srv, "://") { return []adVariant{{srv, strings.HasPrefix(srv, "ldap://")}} } return []adVariant{{"ldaps://" + srv, false}, {"ldap://" + srv, true}} } // adAttempt — подключиться к одному контроллеру и войти сервисной записью. Успех — описание контроллера; иначе // причина словами, сертификат (если дело в нём) и стоит ли пробовать другой контроллер или способ. func adAttempt(a AuthSettings) (probe string, cert *adCertInfo, msg string, next bool) { probe, err := adProbe(a) if err != nil { if adCertUntrusted(err) { cert, _ = adFetchCert(a) } return "", cert, adExplain(err, a, cert), cert == nil } conn, err := adDial(a) if err == nil { err = adBindService(conn, a) conn.Close() } if err != nil { return "", nil, err.Error() + " — поправьте учётную запись или пароль", false } return probe, nil, "", false } // adSave — подключение прошло: сохранить. Если доверились сертификату самого контроллера, bj берёт из домена // сертификат центра, который его выдал: тогда подходят все контроллеры и их новые сертификаты. func (h *setupHandlers) adSave(w http.ResponseWriter, r *http.Request, a AuthSettings, probe string) { note := "" if a.ADCAPEM != "" { if caPEM, ca, err := adUpgradeCA(a); err == nil && caPEM != "" { a.ADCAPEM = caPEM note = " Сертификаты контроллеров проверяются по центру сертификации «" + ca + "» — подойдут все контроллеры домена." } else if err != nil { log.Printf("ad: сертификат ЦС из домена не получен (%v) — проверка по сертификату контроллера", err) note = " Сертификат контроллера сохранён; если его заменят, bj попросит подтвердить новый." } } if safeReturnPath(r.FormValue("return_to")) != "" { a.ADEnabled = true // в мастере подключение домена и есть включение входа через домен; в настройках — своя галочка } if err := h.rc.UpdateAuth(a); err != nil { adDraftSet(a, "настройки не сохранены — "+err.Error(), nil, adForm{}) h.usersFlash(w, r, "Домен: настройки не сохранены — "+err.Error()) return } adDraftClear() h.usersFlash(w, r, "Домен подключён ("+probe+"): можно добавлять сотрудников из домена — они входят своим доменным логином и паролем."+note) } // adUpgradeCA — сохранён сертификат самого контроллера (контроллер не прислал цепочку): войти по нему и прочитать // в домене сертификат выдавшего центра (адрес — в сертификате, AIA; иначе — контейнер центров сертификации). // Берётся только центр, чьей подписью подписан сертификат контроллера. Уже центр — ничего не делает. func adUpgradeCA(a AuthSettings) (string, string, error) { blk, _ := pem.Decode([]byte(a.ADCAPEM)) if blk == nil { return "", "", fmt.Errorf("сертификат не разобран") } leaf, err := x509.ParseCertificate(blk.Bytes) if err != nil || leaf.IsCA { return "", "", err } conn, err := adDial(a) if err != nil { return "", "", err } defer conn.Close() if err := adBindService(conn, a); err != nil { return "", "", err } var bases []string for _, u := range leaf.IssuingCertificateURL { if dn, ok := strings.CutPrefix(u, "ldap:///"); ok { dn, _, _ = strings.Cut(dn, "?") if d, err := url.PathUnescape(dn); err == nil { bases = append(bases, d) } } } if def := adDefaultBase(conn); def != "" { bases = append(bases, "CN=AIA,CN=Public Key Services,CN=Services,CN=Configuration,"+def, "CN=Certification Authorities,CN=Public Key Services,CN=Services,CN=Configuration,"+def) } for _, base := range bases { sr, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 50, 10, false, "(objectClass=certificationAuthority)", []string{"cACertificate"}, nil)) if err != nil || sr == nil { continue } for _, e := range sr.Entries { for _, raw := range e.GetRawAttributeValues("cACertificate") { ca, err := x509.ParseCertificate(raw) if err != nil || !ca.IsCA || leaf.CheckSignatureFrom(ca) != nil { continue } return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw})), ca.Subject.CommonName, nil } } } return "", "", fmt.Errorf("центр сертификации %q в домене не найден", leaf.Issuer.CommonName) } // adTrust — POST: «Доверять» сертификату, который показал контроллер (черновик неудачной попытки). Отпечаток из // формы должен совпасть с полученным: человек подтверждает именно тот сертификат, который видел на экране. func (h *setupHandlers) adTrust(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } adDraft.Lock() a, cert, form := adDraft.a, adDraft.cert, adDraft.form adDraft.Unlock() if a == nil || cert == nil || cert.SHA256 != r.FormValue("fp") { h.usersFlash(w, r, "Домен: сертификат на странице устарел — нажмите «Подключить» ещё раз") return } b := *a b.ADCAPEM, b.ADSkipVerify = cert.PEM, false probe, c2, msg, _ := adAttempt(b) if probe == "" { adDraftSet(b, msg, c2, form) h.usersFlash(w, r, "Домен: "+msg) return } h.adSave(w, r, b, probe) } // ---------- черновик подключения и сертификат контроллера ---------- // adCertInfo — сертификат, который предъявил контроллер домена (для решения «доверять»). type adCertInfo struct { Subject, Issuer, Until, SHA256 string SHA1 string // «Отпечаток» в окне сертификата Windows — чтобы сверить на контроллере IsCA bool // центр сертификации из цепочки (иначе — сертификат самого контроллера) PEM string // что сохранится в настройках, если доверять } // adForm — расширенные настройки, как их ввёл человек (пусто — bj ищет сам). type adForm struct { Server, Domain string Adv bool // открыть расширенные настройки: домен или контроллер не нашлись сами } // adDraftView — неудачная попытка подключить домен: что было введено и что не так (для формы). type adDraftView struct { Server string // контроллер, с которым не получилось (для карточки сертификата) BindUser string HasPassword bool Form adForm Err string Cert *adCertInfo } const adDraftTTL = 2 * time.Hour var adDraft struct { sync.Mutex a *AuthSettings err string cert *adCertInfo form adForm at time.Time } func adDraftSet(a AuthSettings, msg string, cert *adCertInfo, form adForm) { adDraft.Lock() defer adDraft.Unlock() adDraft.a, adDraft.err, adDraft.cert, adDraft.form, adDraft.at = &a, msg, cert, form, time.Now() } func adDraftClear() { adDraft.Lock() defer adDraft.Unlock() adDraft.a, adDraft.err, adDraft.cert = nil, "", nil } // adDraftAuth — введённое в последней неудачной попытке (nil — попытки не было или она давно). func adDraftAuth() *AuthSettings { adDraft.Lock() defer adDraft.Unlock() if adDraft.a == nil || time.Since(adDraft.at) > adDraftTTL { return nil } a := *adDraft.a return &a } // adDraftForView — для шаблона: черновик без пароля. func adDraftForView() *adDraftView { adDraft.Lock() defer adDraft.Unlock() if adDraft.a == nil || time.Since(adDraft.at) > adDraftTTL { return nil } a := adDraft.a host := a.ADServer if u, err := url.Parse(a.ADServer); err == nil && u.Hostname() != "" { host = u.Hostname() } return &adDraftView{Server: host, BindUser: a.ADBindUser, HasPassword: a.ADBindPassword != "", Form: adDraft.form, Err: adDraft.err, Cert: adDraft.cert} } // adCertUntrusted — TLS не прошёл из-за того, что bj не знает, кто выдал сертификат контроллера. func adCertUntrusted(err error) bool { s := err.Error() return strings.Contains(s, "x509: certificate signed by unknown authority") || strings.Contains(s, "certificate is not trusted") } // adExplain — причина неудачи словами и что сделать. func adExplain(err error, a AuthSettings, cert *adCertInfo) string { s := err.Error() host := a.ADServer if u, e := url.Parse(a.ADServer); e == nil && u.Hostname() != "" { host = u.Hostname() } switch { case adCertUntrusted(err) && cert != nil: return "контроллер " + host + " отвечает, но его сертификат выдан центром сертификации «" + cert.Issuer + "», которого bj пока не знает (обычно это центр сертификации самого домена). Проверьте сертификат ниже и нажмите «Доверять»." case adCertUntrusted(err): return "контроллер " + host + " отвечает, но его сертификат выдан неизвестным центром сертификации; вставьте сертификат корпоративного ЦС в Настройка → Пользователи → Active Directory" case strings.Contains(s, "doesn't contain any IP SANs"): return "контроллер указан IP-адресом, а сертификат выдан на имя — укажите имя контроллера, например dc1." + pickStr(a.ADDomain, "corp.local") case strings.Contains(s, "x509: certificate is valid for"): names := s[strings.Index(s, "x509: certificate is valid for")+len("x509: certificate is valid for "):] if i := strings.Index(names, ", not "); i >= 0 { names = names[:i] } return "сертификат контроллера выдан на другое имя (" + names + ") — укажите контроллер одним из этих имён" case strings.Contains(s, "certificate has expired") || strings.Contains(s, "is not yet valid"): return "срок сертификата контроллера " + host + " истёк или ещё не начался — обновите сертификат на контроллере или проверьте время на сервере bj" case strings.Contains(s, "connection refused"): port := "636" if strings.HasPrefix(a.ADServer, "ldap://") { port = "389" } return "контроллер " + host + " не принимает подключения на порту " + port + ": на нём не включён LDAPS или порт закрыт сетевым экраном" case strings.Contains(s, "no such host"): return "имя " + host + " не находится в DNS сервера bj — проверьте имя контроллера или укажите его полностью (dc1." + pickStr(a.ADDomain, "corp.local") + ")" case strings.Contains(s, "i/o timeout") || strings.Contains(s, "deadline exceeded"): return "контроллер " + host + " не ответил вовремя — проверьте, что сервер bj видит его по сети (порт 636)" } return "не удалось подключиться к контроллеру: " + s } // adFetchCert — сертификат, который предъявляет контроллер: центр сертификации из цепочки (верхний, что прислал // контроллер), а если цепочки нет — сертификат самого контроллера. Соединение — без проверки и только для чтения // сертификата; пароль не отправляется. func adFetchCert(a AuthSettings) (*adCertInfo, error) { b := a b.ADSkipVerify, b.ADCAPEM = true, "" conn, err := adDial(b) if err != nil { return nil, err } defer conn.Close() st, ok := conn.TLSConnectionState() if !ok || len(st.PeerCertificates) == 0 { return nil, fmt.Errorf("контроллер не предъявил сертификат") } pcs := st.PeerCertificates c := pcs[0] for i := len(pcs) - 1; i >= 1; i-- { if pcs[i].IsCA { c = pcs[i] break } } sum := sha256.Sum256(c.Raw) hx := strings.ToUpper(hex.EncodeToString(sum[:])) var fp []string for i := 0; i < len(hx); i += 2 { fp = append(fp, hx[i:i+2]) } s1 := sha1.Sum(c.Raw) name := func(n pkix.Name) string { if n.CommonName != "" { return n.CommonName } return n.String() } return &adCertInfo{Subject: name(c.Subject), Issuer: name(c.Issuer), Until: c.NotAfter.In(time.Local).Format("02.01.2006"), SHA256: strings.Join(fp, ":"), SHA1: strings.ToLower(hex.EncodeToString(s1[:])), IsCA: c.IsCA, PEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw}))}, nil } // adUserView — сотрудник или группа домена в результатах поиска. type adUserView struct { Kind string `json:"kind"` // user | group Login string `json:"login"` // sAMAccountName Name string `json:"name"` Email string `json:"email,omitempty"` Desc string `json:"desc,omitempty"` // описание группы Have bool `json:"have,omitempty"` // сотрудник уже есть в bj / группе уже дан доступ } // adSearch — GET ?q=: сотрудники (только включённые учётные записи) и группы домена по фамилии, логину, почте // или названию группы (до 25). func (h *setupHandlers) adSearch(w http.ResponseWriter, r *http.Request) { a := h.rc.Snapshot().Auth q := strings.TrimSpace(r.URL.Query().Get("q")) if !a.ADEnabled || a.ADBindUser == "" { jsonOut(w, map[string]any{"ok": false, "error": "домен не подключён"}) return } if len([]rune(q)) < 2 { jsonOut(w, map[string]any{"ok": true, "users": []adUserView{}}) return } users, err := adSearchUsers(a, q, 25) if err != nil { jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) return } jsonOut(w, map[string]any{"ok": true, "users": users}) } func adSearchUsers(a AuthSettings, q string, limit int) ([]adUserView, error) { conn, err := adDial(a) if err != nil { return nil, err } defer conn.Close() conn.SetTimeout(10 * time.Second) if err := adBindService(conn, a); err != nil { return nil, err } base := a.ADBaseDN if base == "" { base = adDefaultBase(conn) } e := ldap.EscapeFilter(q) filter := fmt.Sprintf("(|(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(|(sAMAccountName=*%s*)(displayName=*%s*)(sn=*%s*)(mail=*%s*)))"+ "(&(objectCategory=group)(|(sAMAccountName=*%s*)(cn=*%s*)(description=*%s*))))", e, e, e, e, e, e, e) sr, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, limit, 10, false, filter, []string{"sAMAccountName", "displayName", "mail", "cn", "description", "objectClass"}, nil)) if err != nil && (sr == nil || len(sr.Entries) == 0) { // превышение лимита — не ошибка, берём найденное return nil, fmt.Errorf("поиск в домене: %v", err) } var users, groups []adUserView for _, en := range sr.Entries { u := adUserView{Kind: "user", Login: en.GetAttributeValue("sAMAccountName"), Name: en.GetAttributeValue("displayName"), Email: en.GetAttributeValue("mail")} if u.Login == "" { continue } for _, c := range en.GetAttributeValues("objectClass") { if strings.EqualFold(c, "group") { u.Kind, u.Name, u.Email, u.Desc = "group", pickStr(en.GetAttributeValue("cn"), u.Login), "", en.GetAttributeValue("description") } } if u.Kind == "group" { u.Have = adGroupRoleOf(a, u.Login) != "" groups = append(groups, u) continue } u.Have = globalUsers.Get(u.Login) != nil users = append(users, u) } return append(users, groups...), nil } // Name — имя группы для экрана (из DN — CN). func (g ADGroupRole) Name() string { return dnCN(g.Group) } // ADServerHost — имя контроллера без схемы и порта (для экрана). func (a AuthSettings) ADServerHost() string { if u, err := url.Parse(a.ADServer); err == nil && u.Hostname() != "" { return u.Hostname() } return a.ADServer } // adGroupRoleOf — роль, которую даёт группа домена (пусто — группе доступ не дан). func adGroupRoleOf(a AuthSettings, group string) string { for _, g := range a.ADGroups { if strings.EqualFold(dnCN(g.Group), group) || strings.EqualFold(g.Group, group) { return g.Role } } return "" } // adGroupSave — POST: всем сотрудникам группы домена — вход в bj с ролью (remove=1 — убрать группу). Сотрудник // входит своим доменным логином; учётная запись в bj заводится при первом входе. func (h *setupHandlers) adGroupSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } a := h.rc.Snapshot().Auth g := strings.TrimSpace(r.FormValue("group")) if g == "" { h.usersFlash(w, r, "Группа домена: не выбрана") return } var keep []ADGroupRole for _, x := range a.ADGroups { if !strings.EqualFold(dnCN(x.Group), g) && !strings.EqualFold(x.Group, g) { keep = append(keep, x) } } msg := "Группа «" + g + "» убрана: её сотрудники больше не входят в bj (кроме заведённых отдельно)." if r.FormValue("remove") == "" { rl, ok := roleByKey(r.FormValue("role")) if !ok || !roleAvailable(rl.Key) { h.usersFlash(w, r, "Группа домена: выберите роль") return } keep = append(keep, ADGroupRole{Group: g, Role: rl.Key}) msg = "Группа «" + g + "»: все её сотрудники входят в bj своим доменным логином и паролем с ролью «" + rl.Title + "»." } a.ADGroups = keep if err := h.rc.UpdateAuth(a); err != nil { h.usersFlash(w, r, "Группа домена: "+err.Error()) return } igateShareSyncSoon(h.rc) // папки шлюза «через домен» — тем же группам h.usersFlash(w, r, msg) } // adMember — участник группы домена (для контроля, кто получает доступ в bj через группу). type adMember struct { Login string `json:"login"` Name string `json:"name"` Email string `json:"email,omitempty"` Disabled bool `json:"disabled,omitempty"` // учётная запись отключена в AD — войти не сможет InBJ bool `json:"in_bj,omitempty"` // уже входил в bj (есть пользователь bj) } // adGroupMembersHTTP — GET /admin/setup/users/ad-group-members?dn=…: участники группы с доступом в bj, // включая вложенные группы (LDAP_MATCHING_RULE_IN_CHAIN). Только группы из настроек bj. func (h *setupHandlers) adGroupMembersHTTP(w http.ResponseWriter, r *http.Request) { a := h.rc.Snapshot().Auth dn := strings.TrimSpace(r.URL.Query().Get("dn")) if !a.ADEnabled || a.ADBindUser == "" { jsonOut(w, map[string]any{"ok": false, "error": "домен не подключён"}) return } known := false for _, g := range a.ADGroups { known = known || strings.EqualFold(g.Group, dn) } if !known { jsonOut(w, map[string]any{"ok": false, "error": "группы нет в настройках bj"}) return } list, err := adGroupMembers(a, dn, 1000) if err != nil { jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) return } jsonOut(w, map[string]any{"ok": true, "members": list}) } func adGroupMembers(a AuthSettings, groupDN string, limit int) ([]adMember, error) { conn, err := adDial(a) if err != nil { return nil, err } defer conn.Close() conn.SetTimeout(15 * time.Second) if err := adBindService(conn, a); err != nil { return nil, err } base := a.ADBaseDN if base == "" { base = adDefaultBase(conn) } filter := "(&(objectCategory=person)(objectClass=user)(memberOf:1.2.840.113556.1.4.1941:=" + ldap.EscapeFilter(groupDN) + "))" sr, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, limit, 15, false, filter, []string{"sAMAccountName", "displayName", "mail", "userAccountControl"}, nil)) if err != nil && (sr == nil || len(sr.Entries) == 0) { return nil, fmt.Errorf("участники группы: %v", err) } out := []adMember{} for _, en := range sr.Entries { m := adMember{Login: en.GetAttributeValue("sAMAccountName"), Name: en.GetAttributeValue("displayName"), Email: en.GetAttributeValue("mail")} if m.Login == "" { continue } if uac, err := strconv.Atoi(en.GetAttributeValue("userAccountControl")); err == nil && uac&2 != 0 { m.Disabled = true } m.InBJ = globalUsers.Get(m.Login) != nil out = append(out, m) } sort.Slice(out, func(i, j int) bool { if out[i].Disabled != out[j].Disabled { return !out[i].Disabled } return strings.ToLower(pickStr(out[i].Name, out[i].Login)) < strings.ToLower(pickStr(out[j].Name, out[j].Login)) }) return out, nil }