diff --git a/internal/lkgateway/adwizard.go b/internal/lkgateway/adwizard.go index f63a36a..b71897a 100644 --- a/internal/lkgateway/adwizard.go +++ b/internal/lkgateway/adwizard.go @@ -28,6 +28,7 @@ func (h *setupHandlers) registerADWizard(mux *http.ServeMux) { 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 находит @@ -412,15 +413,18 @@ func adFetchCert(a AuthSettings) (*adCertInfo, error) { 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 — сотрудник домена в результатах поиска. +// adUserView — сотрудник или группа домена в результатах поиска. type adUserView struct { + Kind string `json:"kind"` // user | group Login string `json:"login"` // sAMAccountName Name string `json:"name"` Email string `json:"email,omitempty"` - Have bool `json:"have,omitempty"` // уже есть в bj + Desc string `json:"desc,omitempty"` // описание группы + Have bool `json:"have,omitempty"` // сотрудник уже есть в bj / группе уже дан доступ } -// adSearch — GET ?q=: сотрудники домена по фамилии, логину или почте (до 20, только включённые учётные записи). +// 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")) @@ -432,7 +436,7 @@ func (h *setupHandlers) adSearch(w http.ResponseWriter, r *http.Request) { jsonOut(w, map[string]any{"ok": true, "users": []adUserView{}}) return } - users, err := adSearchUsers(a, q, 20) + users, err := adSearchUsers(a, q, 25) if err != nil { jsonOut(w, map[string]any{"ok": false, "error": err.Error()}) return @@ -455,20 +459,89 @@ func adSearchUsers(a AuthSettings, q string, limit int) ([]adUserView, error) { 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) + 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"}, nil)) + 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 out []adUserView + var users, groups []adUserView for _, en := range sr.Entries { - u := adUserView{Login: en.GetAttributeValue("sAMAccountName"), Name: en.GetAttributeValue("displayName"), Email: en.GetAttributeValue("mail")} + 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 - out = append(out, u) + users = append(users, u) } - return out, nil + 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) } diff --git a/internal/lkgateway/adwizard_test.go b/internal/lkgateway/adwizard_test.go index e203604..62e6392 100644 --- a/internal/lkgateway/adwizard_test.go +++ b/internal/lkgateway/adwizard_test.go @@ -87,3 +87,28 @@ func TestADConnectKeepsDraftAndTrust(t *testing.T) { t.Fatal("сертификат не запомнен в черновике") } } + +// Группа домена из мастера: доступ с ролью, повтор меняет роль (без дублей), «убрать» — снимает. +func TestADGroupSave(t *testing.T) { + rc := testRC(t) + h := &setupHandlers{rc: rc} + post := func(v url.Values) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/admin/setup/users/ad-group", strings.NewReader(v.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.adGroupSave(w, r) + } + post(url.Values{"group": {"Depo"}, "role": {"operator"}}) + post(url.Values{"group": {"depo"}, "role": {"viewer"}}) + g := rc.Snapshot().Auth.ADGroups + if len(g) != 1 || g[0].Role != "viewer" { + t.Fatalf("группы: %+v", g) + } + if adGroupRoleOf(rc.Snapshot().Auth, "DEPO") != "viewer" { + t.Fatal("роль группы не находится") + } + post(url.Values{"group": {"Depo"}, "remove": {"1"}}) + if len(rc.Snapshot().Auth.ADGroups) != 0 { + t.Fatal("группа не убрана") + } +} diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 378c78c..fb95191 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -15,6 +15,7 @@ package lkgateway import ( "context" "crypto/rand" + "crypto/sha256" "crypto/subtle" "crypto/tls" "crypto/x509" @@ -479,20 +480,45 @@ func (s *sessionStore) DropUser(login string) { s.mu.Unlock() } -func (s *sessionStore) Active() []*Session { +// SessionView — сессия для списка в настройках: без токена (по нему можно войти под чужим именем), вместо него — ID. +type SessionView struct { + ID, Login, IP, UA string + Created, Seen time.Time +} + +// sessionID — короткий отпечаток токена: им список ссылается на сессию, сам токен в страницу не попадает. +func sessionID(token string) string { + sum := sha256.Sum256([]byte("bj-session-id:" + token)) + return hex.EncodeToString(sum[:9]) +} + +func (s *sessionStore) Active() []SessionView { s.mu.Lock() defer s.mu.Unlock() - out := make([]*Session, 0, len(s.list)) - for _, x := range s.list { + out := make([]SessionView, 0, len(s.list)) + for t, x := range s.list { if time.Since(x.Created) < sessionMaxLife { - cp := *x - out = append(out, &cp) + out = append(out, SessionView{ID: sessionID(t), Login: x.Login, IP: x.IP, UA: x.UA, Created: x.Created, Seen: x.Seen}) } } sort.Slice(out, func(i, j int) bool { return out[i].Seen.After(out[j].Seen) }) return out } +// DropID — завершить сессию по её ID из списка. +func (s *sessionStore) DropID(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for t := range s.list { + if id != "" && sessionID(t) == id { + delete(s.list, t) + s.save() + return true + } + } + return false +} + // ---------- защита от перебора ---------- var loginFails = struct { @@ -910,12 +936,12 @@ func moduleForPath(p string) string { case p == "/admin/setup/lk", has("/admin/setup/lk-check"), has("/admin/setup/lk-integration"), has("/admin/setup/lk-pull"): return "esia-finance" // интеграция с платформой ЕСИА Финанс — отдельный модуль лицензии case has("/admin/setup/onec"): - return "onec-middle" + return "onec-back" } return "" } -var moduleTitles = map[string]string{"esia-finance": "ЕСИА Финанс", "quik-mnp": "QUIK: неторговые поручения и обмен с DEPO", "quik-export": "Экспорт биржевой информации QUIK", "web-cabinet": "Личный кабинет клиента", "npr": "Риск-монитор НПР / УДС", "onec-middle": "1С: база мидл-офиса"} +var moduleTitles = map[string]string{"esia-finance": "ЕСИА Финанс", "quik-mnp": "QUIK: неторговые поручения и обмен с DEPO", "quik-export": "Экспорт биржевой информации QUIK", "web-cabinet": "Личный кабинет клиента", "npr": "Риск-монитор НПР / УДС", "onec-back": "1С: база бэк-офиса"} // moduleTitle — признак лицензии словами (в мастере и на странице лицензии — не коды вроде quik-mnp). func moduleTitle(f string) string { @@ -1186,7 +1212,7 @@ type UsersView struct { Roles []roleDef // доступные при текущей лицензии Hidden []struct{ Title, Missing string } // роли, недоступные без модулей Modules []struct{ Key, Title string } // подключённые модули - Sessions []*Session + Sessions []SessionView Open bool Me *User ADRecheck ADRecheckStatus @@ -1373,7 +1399,7 @@ func (h *setupHandlers) dropSession(w http.ResponseWriter, r *http.Request) { http.Error(w, "method", http.StatusMethodNotAllowed) return } - globalSessions.Drop(r.FormValue("token")) + globalSessions.DropID(r.FormValue("id")) h.usersFlash(w, r, "Сессия завершена.") } diff --git a/internal/lkgateway/depoqueue.go b/internal/lkgateway/depoqueue.go index e817d10..4600f9d 100644 --- a/internal/lkgateway/depoqueue.go +++ b/internal/lkgateway/depoqueue.go @@ -138,6 +138,17 @@ func depoQueueLoad(ctx context.Context, rc *RuntimeConfig, limit int) ([]depoQue out = append(out, r) } rows.Close() + // справочник участников — и когда переводов ещё нет (свежая установка): иначе страница писала «не создана» + if depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") { + sum.HasHandbook = true + _ = db.QueryRowContext(ctx, fsql.QQu01).Scan(&sum.Handbook) + if depoHasColumn(ctx, db, "FTV_M2M_HANDBOOK", "HANDBOOK_AT") { + var t sql.NullTime + if db.QueryRowContext(ctx, fsql.QQu02).Scan(&t) == nil && t.Valid { + sum.HandbookAt = t.Time + } + } + } if len(out) == 0 { return out, sum, nil } @@ -193,16 +204,6 @@ func depoQueueLoad(ctx context.Context, rc *RuntimeConfig, limit int) ([]depoQue er.Close() } } - if depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") { - sum.HasHandbook = true - _ = db.QueryRowContext(ctx, fsql.QQu01).Scan(&sum.Handbook) - if depoHasColumn(ctx, db, "FTV_M2M_HANDBOOK", "HANDBOOK_AT") { - var t sql.NullTime - if db.QueryRowContext(ctx, fsql.QQu02).Scan(&t) == nil && t.Valid { - sum.HandbookAt = t.Time - } - } - } for i := range out { sum.Total++ if out[i].Pending > 0 { diff --git a/internal/lkgateway/licensecheck.go b/internal/lkgateway/licensecheck.go index ed0d51a..323614e 100644 --- a/internal/lkgateway/licensecheck.go +++ b/internal/lkgateway/licensecheck.go @@ -43,7 +43,7 @@ var knownFeatures = []struct{ Key, Title string }{ {"quik-export", "QUIK: экспорт биржевой информации"}, {"npr", "Риск-монитор НПР / УДС"}, {"esia-finance", "ЕСИА Финанс (платформа личного кабинета)"}, - {"onec-middle", "1С: база мидл-офиса (тест)"}, + {"onec-back", "1С: база бэк-офиса (тест)"}, } func licenseFeatureViews(rc *RuntimeConfig) []LicenseFeatureView { diff --git a/internal/lkgateway/onec.go b/internal/lkgateway/onec.go index 9d1d537..5262bc4 100644 --- a/internal/lkgateway/onec.go +++ b/internal/lkgateway/onec.go @@ -1,6 +1,6 @@ package lkgateway -// База 1С мидл-офиса — модуль лицензии onec-middle (07.10.2026, тестовый). Пока это подключение: адрес +// База 1С бэк-офиса — модуль лицензии onec-back (07.10.2026, тестовый). Пока это подключение: адрес // публикации базы на веб-сервере, пользователь и пароль 1С; bj проверяет их через стандартный интерфейс OData // ($metadata). Обмен данными с базой — следующий шаг модуля. @@ -15,7 +15,7 @@ import ( "time" ) -// OneCSettings — подключение к базе 1С мидл-офиса. +// OneCSettings — подключение к базе 1С бэк-офиса. type OneCSettings struct { URL string `json:"url,omitempty"` // адрес публикации: http://1c-server/middle User string `json:"user,omitempty"` diff --git a/internal/lkgateway/onec_test.go b/internal/lkgateway/onec_test.go index 70b1de8..89bfff9 100644 --- a/internal/lkgateway/onec_test.go +++ b/internal/lkgateway/onec_test.go @@ -47,10 +47,10 @@ func TestOneCCheck(t *testing.T) { } func TestOneCModuleGate(t *testing.T) { - if f := moduleForPath("/admin/setup/onec"); f != "onec-middle" { + if f := moduleForPath("/admin/setup/onec"); f != "onec-back" { t.Fatalf("раздел 1С без модуля лицензии: %q", f) } - if moduleTitle("onec-middle") == "onec-middle" { + if moduleTitle("onec-back") == "onec-back" { t.Fatal("нет названия модуля для лицензии") } } diff --git a/internal/lkgateway/prepare.go b/internal/lkgateway/prepare.go index 2b14c6a..eaac643 100644 --- a/internal/lkgateway/prepare.go +++ b/internal/lkgateway/prepare.go @@ -37,7 +37,7 @@ var prepareRun struct { var prepareBoot = time.Now().UnixNano() // prepareCoreFeatures — признаки лицензии, которые работают внутри ядра (отдельных файлов нет). -var prepareCoreFeatures = []string{"quik-mnp", "esia-finance", "onec-middle"} +var prepareCoreFeatures = []string{"quik-mnp", "esia-finance", "onec-back"} // prepareItems — состав лицензии этой установки и состояние каждого модуля. func prepareItems(rc *RuntimeConfig, running, finished bool, runErr string) []PrepareItem { diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index b0afbc6..75512dd 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -43,7 +43,7 @@ type Settings struct { HTTPS HTTPSSettings `json:"https,omitempty"` Time TimeSettings `json:"time,omitempty"` WorkHours WorkHoursSettings `json:"work_hours,omitempty"` // рабочее время: запросы M2M вне него — отказ M2M06 - OneC OneCSettings `json:"onec,omitempty"` // база 1С мидл-офиса (модуль onec-middle) + OneC OneCSettings `json:"onec,omitempty"` // база 1С бэк-офиса (модуль onec-back) LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 98aaf0f..bd195dd 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -15,7 +15,7 @@ {{if mod "web-cabinet"}}{{end}} {{if mod "esia-finance"}}{{end}} - {{if mod "onec-middle"}}{{end}} + {{if mod "onec-back"}}{{end}} @@ -203,7 +203,7 @@
| Пользователь | Вход | Активность | Адрес | Браузер | |
|---|---|---|---|---|---|
| {{.Login}} | {{msk .Created}} | {{msk .Seen}} | {{.IP}} |
- {{end}} |
Сессий нет.
{{end}} @@ -785,11 +785,11 @@ {{end}} - {{/* ============ 1С: БАЗА МИДЛ-ОФИСА (модуль onec-middle, тестовый) ============ */}} - {{if mod "onec-middle"}} + {{/* ============ 1С: БАЗА БЭК-ОФИСА (модуль onec-back, тестовый) ============ */}} + {{if mod "onec-back"}}Связь bj с базой 1С мидл-офиса. Модуль в разработке: сейчас здесь задаётся и проверяется подключение к базе, обмен данными появится в следующих выпусках.
+Связь bj с базой 1С бэк-офиса. Модуль в разработке: сейчас здесь задаётся и проверяется подключение к базе, обмен данными появится в следующих выпусках.
bj обращается к базе через стандартный интерфейс OData. Его включают при публикации базы: конфигуратор 1С → Администрирование → Публикация на веб-сервере → «Публиковать стандартный интерфейс OData».
diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 5ea1cf5..db0a35b 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -663,6 +663,31 @@ .acc-form label{display:block;margin:0} .acc-form label input{width:100%;box-sizing:border-box;margin-top:4px} .acc-pw{display:flex;gap:6px;align-items:flex-end} + .ad-st{display:flex;gap:12px;align-items:center;margin:12px 0 4px;padding:12px 16px;border-radius:12px;border:1px solid var(--border);background:var(--card-2);max-width:640px;box-sizing:border-box} + .ad-st b{display:block;font-size:15px;font-weight:650} + .ad-st small{display:block;color:var(--muted);font-size:13px;line-height:1.4;margin-top:2px;overflow-wrap:anywhere} + .ad-st-ic{flex:none;position:relative;width:26px;height:26px;border-radius:50%;background:var(--border-strong)} + .ad-st-ok{background:color-mix(in srgb,var(--ok) 9%,var(--card));border-color:color-mix(in srgb,var(--ok) 40%,var(--border))} + .ad-st-ok .ad-st-ic{background:var(--ok)} + .ad-st-ok .ad-st-ic::after{content:"";position:absolute;left:50%;top:45%;width:6px;height:11px;border:solid #fff;border-width:0 2.2px 2.2px 0;transform:translate(-50%,-50%) rotate(45deg)} + .ad-st-warn{background:var(--warn-weak);border-color:color-mix(in srgb,var(--warn) 45%,var(--border))} + .ad-st-warn .ad-st-ic{background:var(--warn)} + .ad-st-warn .ad-st-ic::after{content:"!";position:absolute;inset:0;display:grid;place-items:center;color:#fff;font-weight:700;font-size:15px} + .ad-st-spin{background:none;border:2.5px solid var(--accent-weak);border-top-color:var(--accent);box-sizing:border-box;animation:dq-spin .8s linear infinite} + .ad-q-l{display:block;margin:14px 0 0;font-weight:600;font-size:14px;max-width:640px} + .ad-q-l input{display:block;width:100%;box-sizing:border-box;margin-top:6px;font-size:15px !important;font-weight:400} + .ad-res{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px;max-width:640px} + .ad-hit{display:flex;gap:10px;align-items:baseline;width:100%;text-align:left;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--card);color:var(--text);font:inherit;font-size:14px;cursor:pointer} + .ad-hit:hover:not(:disabled){border-color:var(--border-strong)} + .ad-hit.is-sel{border-color:var(--accent);background:var(--accent-weak)} + .ad-hit:disabled{opacity:.6;cursor:default} + .ad-kind{flex:none;font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px;background:var(--card-2);color:var(--muted);border:1px solid var(--border)} + .ad-kind-g{background:var(--accent-weak);color:var(--accent);border-color:transparent} + .ad-pick{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px;padding:12px 14px;border:1px solid var(--accent);border-radius:12px;background:color-mix(in srgb,var(--accent-weak) 50%,var(--card));max-width:640px;box-sizing:border-box} + .ad-pick[hidden]{display:none} + .ad-groups{margin-top:16px;max-width:640px} + .ad-grp{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--border);border-radius:10px;margin-top:6px;font-size:14px} + .ad-grp form{margin:0} @media (max-width:600px){.acc-roles,.acc-form{grid-template-columns:minmax(0,1fr)}} {{with .Fancy}}{{$U := .Users}} @@ -687,42 +712,58 @@Пароль передайте сотруднику лично; сменить его можно в разделе Настройка → Пользователи.
- {{$A := .Settings.Auth}}{{$D := adDraft}} -Домен {{$A.ADDomain}} подключён. Найдите сотрудника — он будет входить в bj своим доменным логином и паролем; пароль проверяет домен.
- - -