da12a9ec1c
- 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>
228 lines
6.9 KiB
Go
228 lines
6.9 KiB
Go
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))
|
||
}
|