Files
Bridge-and-Join-s/internal/lkgateway/adwizard.go
T
zuevav 73b246831c Пароль домена не попадает в страницу; домен в мастере — открыт, крупно, группы; справочник DEPO без переводов; 1С — бэк-офис
- Безопасность: в мастере подпись к паролю выводила сам сохранённый пароль служебной записи домена — теперь только «задан/введён» (тест: пароль не попадает в HTML). Список сессий в настройках больше не содержит токены (по ним можно было войти под чужим именем) — короткий ID.
- Домен: после подключения и «Доверять» блок остаётся открытым, страница возвращается к нему; карточка «Домен подключён» и «Найден домен» — крупно; поиск сотрудников и групп; группе — доступ с ролью (ADGroups), список групп с «убрать».
- «Обмен с DEPO»: справочник FTV_M2M_HANDBOOK считается и когда переводов ещё нет (на свежей установке писало «не создана»).
- Модуль 1С: onec-back «1С: база бэк-офиса (тест)» вместо мидл-офиса (в лицензиях не выдавался).

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

548 lines
24 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)
mux.HandleFunc("/admin/setup/users/ad-group", h.adGroupSave)
}
// 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
}
h.usersFlash(w, r, msg)
}