Files
Bridge-and-Join-s/internal/lkgateway/adrecheck.go
T
zuevav da12a9ec1c 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>
2026-09-15 19:41:12 +03:00

228 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}