feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР

- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+227
View File
@@ -0,0 +1,227 @@
package lkgateway
// Перепроверка групп AD. Роль доменного пользователя назначается при входе
// (adAuthenticate), а этот цикл раз в N минут (по умолчанию 15) перечитывает
// его группы от имени сервисной учётной записи: сменилась группа — меняется роль,
// удалён из групп, отключён или удалён в AD — доступ снимается и сессии
// завершаются. Пароли пользователей bj не хранит, поэтому без сервисной
// учётной записи перепроверка не выполняется.
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/go-ldap/ldap/v3"
)
const adRecheckDefault = 15 * time.Minute
var (
errADNotFound = errors.New("пользователь не найден в AD")
errADDisabled = errors.New("учётная запись отключена в AD")
)
func (a AuthSettings) recheckEvery() time.Duration {
switch {
case a.ADRecheckMinutes <= 0:
return adRecheckDefault
case a.ADRecheckMinutes < 5:
return 5 * time.Minute
}
return time.Duration(a.ADRecheckMinutes) * time.Minute
}
func (a AuthSettings) recheckOn() bool {
return a.ADEnabled && strings.TrimSpace(a.ADBindUser) != "" && a.ADBindPassword != ""
}
// ADRecheckStatus — итог последней перепроверки (для страницы настроек).
type ADRecheckStatus struct {
At time.Time
Checked int
Changed int
Err string
}
var (
adRecheckMu sync.Mutex
adRecheckState ADRecheckStatus
)
func adRecheckSnapshot() ADRecheckStatus {
adRecheckMu.Lock()
defer adRecheckMu.Unlock()
return adRecheckState
}
// adSplitLogin — sAMAccountName и UPN из ivanov, ivanov@corp.ru или CORP\ivanov.
func adSplitLogin(a AuthSettings, login string) (sam, upn string) {
login = strings.TrimSpace(login)
sam, upn = login, login
if i := strings.Index(login, "@"); i > 0 {
sam = login[:i]
} else if i := strings.Index(login, `\`); i > 0 {
sam = login[i+1:]
upn = sam + "@" + a.ADDomain
} else if a.ADDomain != "" {
upn = login + "@" + a.ADDomain
}
return sam, upn
}
func adBindService(conn *ldap.Conn, a AuthSettings) error {
user := strings.TrimSpace(a.ADBindUser)
if !strings.ContainsAny(user, `@\=`) && a.ADDomain != "" {
user += "@" + a.ADDomain
}
if err := conn.Bind(user, a.ADBindPassword); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
return fmt.Errorf("сервисная учётная запись %s: неверный логин или пароль", user)
}
return fmt.Errorf("сервисная учётная запись %s: %v", user, err)
}
return nil
}
// adApplyRecheck применяет итог проверки к пользователю. lookupErr — nil,
// errADNotFound или errADDisabled; roleErr — nil или *adNoGroupError.
// dropSessions — доступ снят, сессии нужно завершить.
func adApplyRecheck(u *User, res *adResult, lookupErr, roleErr error) (changed, dropSessions bool, msg string) {
if lookupErr != nil || roleErr != nil {
reason := "нет в сопоставленных группах"
if lookupErr != nil {
reason = lookupErr.Error()
}
if u.Role == "" {
return false, false, "" // доступ уже снят прошлой проверкой
}
msg = fmt.Sprintf("доступ снят (%s), была роль «%s»", reason, roleTitle(u.Role))
u.Role = ""
u.Note = "доступ снят по AD: " + reason
return true, true, msg
}
if res.Name != "" {
u.Name = res.Name
}
if res.Email != "" {
u.Email = res.Email
}
u.ADGroups = nil
for _, g := range res.Groups {
u.ADGroups = append(u.ADGroups, dnCN(g))
}
u.Note = "роль по AD: " + res.MatchedBy
if res.Role == u.Role {
return false, false, ""
}
was := "без роли"
if u.Role != "" {
was = roleTitle(u.Role)
}
msg = fmt.Sprintf("роль «%s» → «%s» (по «%s»)", was, roleTitle(res.Role), res.MatchedBy)
u.Role = res.Role
return true, false, msg
}
// adRecheckRun — одна перепроверка всех активных доменных пользователей.
func adRecheckRun(a AuthSettings) ADRecheckStatus {
st := ADRecheckStatus{At: time.Now()}
if err := adRecheckUsers(a, &st); err != nil {
st.Err = err.Error()
log.Printf("auth: перепроверка групп AD не выполнена: %v", err)
}
adRecheckMu.Lock()
adRecheckState = st
adRecheckMu.Unlock()
return st
}
func adRecheckUsers(a AuthSettings, st *ADRecheckStatus) error {
var logins []string
for _, u := range globalUsers.List() {
if u.Source == "ad" && u.Active {
logins = append(logins, u.Login)
}
}
if len(logins) == 0 {
return nil
}
conn, err := adDial(a)
if err != nil {
return err
}
defer conn.Close()
if err := adBindService(conn, a); err != nil {
return err
}
for _, login := range logins {
sam, upn := adSplitLogin(a, login)
res, base, lerr := adLookup(conn, a, sam, upn)
if lerr != nil && lerr != errADNotFound && lerr != errADDisabled {
// сбой поиска — роли не трогаем, иначе обрыв связи снял бы доступ у всех
return fmt.Errorf("%s: %v", login, lerr)
}
var rerr error
if lerr == nil {
if rerr = adResolveRole(conn, a, base, sam, res); rerr != nil {
var ng *adNoGroupError
if !errors.As(rerr, &ng) {
return fmt.Errorf("%s: %v", login, rerr)
}
}
}
st.Checked++
var changed, drop bool
var msg string
_ = globalUsers.update(login, func(u *User) { changed, drop, msg = adApplyRecheck(u, res, lerr, rerr) })
if changed {
st.Changed++
log.Printf("auth: перепроверка AD — %s: %s", login, msg)
}
if drop {
globalSessions.DropUser(login)
}
}
return nil
}
func (s *Server) adRecheckLoop(ctx context.Context) {
timer := time.NewTimer(time.Minute)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
}
a := s.rc.Snapshot().Auth
if a.recheckOn() {
adRecheckRun(a)
}
timer.Reset(a.recheckEvery())
}
}
func (h *setupHandlers) recheckAD(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
a := h.rc.Snapshot().Auth
if !a.recheckOn() {
h.usersFlash(w, r, "AD: для перепроверки групп включите вход через AD и укажите сервисную учётную запись с паролем.")
return
}
st := adRecheckRun(a)
if st.Err != "" {
h.usersFlash(w, r, "AD: перепроверка не выполнена — "+st.Err)
return
}
h.usersFlash(w, r, fmt.Sprintf("AD: перепроверено пользователей %d, изменений %d.", st.Checked, st.Changed))
}