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) { var noGroup *adNoGroupError if lookupErr == nil && errors.As(roleErr, &noGroup) && u.BJRole != "" && roleAvailable(u.BJRole) { // групп с ролью у сотрудника нет, но роль назначена в bj — она и действует roleErr, res = nil, &adResult{Role: u.BJRole, MatchedBy: "роль, назначенная в bj", Groups: groupsOf(res)} } 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)) } func groupsOf(res *adResult) []string { if res == nil { return nil } return res.Groups }