Files
Bridge-and-Join-s/internal/lkgateway/adwizard.go
T
zuevav 773fa8e0bf Мастер: шапка на месте; домен — только логин и пароль; модуль «1С: база мидл-офиса» (тест)
- Шапка мастера: правый блок (шлюз НРД, пользователь, тема) снова справа — после правила [hidden] скрытое меню перестало его распирать.
- Домен в шаге «Учётные записи»: bj сам находит домен и контроллеры (логин с @домен, домены поиска DNS, rootDSE DNS-серверов, SRV _ldap._tcp.dc._msdcs); адрес и домен — в расширенных настройках, открываются сами, если не нашлось. Контроллеры по очереди, LDAPS → LDAP+StartTLS.
- Неудачная попытка ничего не теряет: введённое (и пароль) — черновиком, форма заполнена; причины словами (неизвестный ЦС, имя в сертификате, IP вместо имени, порт закрыт, нет в DNS).
- Сертификат неизвестного центра: карточка «кем выдан, отпечаток (SHA-1 как в Windows и SHA-256)» и «Доверять и подключить»; после входа bj берёт из домена сертификат центра (AIA / Certification Authorities), подписавший сертификат контроллера, — подходят все контроллеры. То же в Настройка → Пользователи при «Проверить».
- Лицензия: модуль onec-middle «1С: база мидл-офиса (тест)»; раздел настроек — адрес публикации, пользователь, пароль, проверка через OData $metadata; в мастере — в «Модулях».
- ЛК (callback, API, готовность) — только при модуле «ЕСИА Финанс»; карточка режима QUIK и стили настроек как в мастере.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
2026-10-07 01:03:16 +03:00

475 lines
21 KiB
Go

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"
"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)
}
// 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 {
Login string `json:"login"` // sAMAccountName
Name string `json:"name"`
Email string `json:"email,omitempty"`
Have bool `json:"have,omitempty"` // уже есть в bj
}
// adSearch — GET ?q=: сотрудники домена по фамилии, логину или почте (до 20, только включённые учётные записи).
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, 20)
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*)))", e, e, e, e)
sr, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, limit, 10, false,
filter, []string{"sAMAccountName", "displayName", "mail"}, nil))
if err != nil && (sr == nil || len(sr.Entries) == 0) { // превышение лимита — не ошибка, берём найденное
return nil, fmt.Errorf("поиск в домене: %v", err)
}
var out []adUserView
for _, en := range sr.Entries {
u := adUserView{Login: en.GetAttributeValue("sAMAccountName"), Name: en.GetAttributeValue("displayName"), Email: en.GetAttributeValue("mail")}
if u.Login == "" {
continue
}
u.Have = globalUsers.Get(u.Login) != nil
out = append(out, u)
}
return out, nil
}