package lkgateway // Пользователи, роли и вход в bj. // // Вход — локальные учётные записи (bcrypt) и/или Active Directory (LDAP bind // учётными данными самого пользователя, роль по группам AD). Сессия — cookie // bj_session, хранится на сервере (/var/lib/bj/.bj/sessions.json), чтобы // переживать перезапуск. Пока не создан ни один пользователь, система открыта // (режим первичной настройки) — так текущие установки не блокируются. // // Роли (roleDefs): admin — всё; operator — работа с переводами и обменом; // risk — только риск-монитор НПР/УДС; viewer — просмотр без изменений. // Модули (npr, quik, export) можно дополнительно выдать пользователю поверх роли. import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "crypto/tls" "crypto/x509" "encoding/hex" "encoding/json" "errors" "fmt" "html" "html/template" "log" "net" "net/http" "net/url" "os" "sort" "strings" "sync" "time" "github.com/go-ldap/ldap/v3" "golang.org/x/crypto/bcrypt" ) const ( sessionsFile = "/var/lib/bj/.bj/sessions.json" sessionCookie = "bj_session" sessionMaxLife = 24 * time.Hour ) // usersFile — стор учётных записей (var, чтобы подменять в тестах). var usersFile = "/var/lib/bj/.bj/users.json" // ---------- роли и права ---------- type roleDef struct { Key, Title, Desc string Perms []string } // Права: view — просмотр журнала/переводов; operate — действия по переводам; // settings — настройка системы и пользователи; npr/quik/export — модули. var roleDefs = []roleDef{ {"admin", "Администратор", "Полный доступ: переводы, настройки, пользователи, все модули.", []string{"view", "operate", "settings", "npr", "quik", "export", "users"}}, {"operator", "Оператор депозитария", "Журнал, заявки, входящие/исходящие переводы, обмен с DEPO и QUIK. Без настроек.", []string{"view", "operate", "quik"}}, {"risk", "Риск-менеджер", "Только риск-монитор НПР / УДС.", []string{"npr"}}, {"viewer", "Наблюдатель", "Просмотр журнала и переводов без права действий.", []string{"view"}}, } var moduleDefs = []struct{ Key, Title string }{ {"npr", "Риск-монитор НПР / УДС"}, {"quik", "Поручения QUIK (МНП)"}, {"export", "Экспорт биржевой информации (просмотр состояния)"}, } func roleByKey(k string) (roleDef, bool) { for _, r := range roleDefs { if r.Key == k { return r, true } } return roleDef{}, false } // permFeature — права модулей и лицензионные признаки, без которых они не действуют. // Остальные права (view, operate, settings, users) базовые: модуль прав не лицензируется. var permFeature = map[string]string{"npr": "npr", "quik": "quik-mnp", "export": quikExportFeature} // featureAllowed — признак есть в текущей лицензии (подменяется в тестах). var featureAllowed = func(feature string) bool { if globalRC == nil { return true } return moduleAllowed(globalRC, feature) } func permLicensed(perm string) bool { f, ok := permFeature[perm] return !ok || featureAllowed(f) } // roleAvailable — у роли осталось хотя бы одно действующее право. func roleAvailable(k string) bool { r, ok := roleByKey(k) if !ok { return false } for _, p := range r.Perms { if permLicensed(p) { return true } } return false } // roleMissingModules — названия неподключённых модулей, от которых зависит роль. func roleMissingModules(k string) string { r, _ := roleByKey(k) var out []string for _, p := range r.Perms { if permLicensed(p) { continue } for _, d := range moduleDefs { if d.Key == p { out = append(out, d.Title) } } } return strings.Join(out, ", ") } func availableRoles() []roleDef { var out []roleDef for _, r := range roleDefs { if roleAvailable(r.Key) { out = append(out, r) } } return out } func availableModules() []struct{ Key, Title string } { var out []struct{ Key, Title string } for _, d := range moduleDefs { if permLicensed(d.Key) { out = append(out, d) } } return out } // userHasAccess — пользователю доступен хоть один раздел при текущей лицензии. func userHasAccess(u *User) bool { if roleAvailable(u.Role) { return true } for _, m := range u.Modules { if permLicensed(m) { return true } } return false } func roleTitle(k string) string { if r, ok := roleByKey(k); ok { return r.Title } return k } // ---------- пользователи ---------- type User struct { Login string `json:"login"` Name string `json:"name"` Email string `json:"email,omitempty"` Role string `json:"role"` Modules []string `json:"modules,omitempty"` // дополнительные модули поверх роли Source string `json:"source"` // local | ad PassHash string `json:"pass_hash,omitempty"` Active bool `json:"active"` CreatedAt time.Time `json:"created_at"` LastLogin time.Time `json:"last_login,omitempty"` ADGroups []string `json:"ad_groups,omitempty"` // BJRole — роль доменного пользователя, назначенная в bj (мастер, «Добавить из домена»): действует, когда // ни одна группа домена не сопоставлена с ролью; сопоставленные группы — главнее. BJRole string `json:"bj_role,omitempty"` Note string `json:"note,omitempty"` } func (u *User) Can(perm string) bool { if perm == "dev" { // режим разработки: включает сервер лицензий, внутри — права настройки return u != nil && devMode(globalRC) && u.Can("settings") } if u == nil || !permLicensed(perm) { return false } if r, ok := roleByKey(u.Role); ok { for _, p := range r.Perms { if p == perm { return true } } } for _, m := range u.Modules { if m == perm { return true } } return false } func (u *User) RoleTitle() string { return roleTitle(u.Role) } // Home — стартовая страница по роли. func (u *User) Home() string { if u.Can("view") { return "/admin/" } if u.Can("npr") { return "/admin/npr" } if u.Can("quik") { return "/admin/quik" } return "/admin/" } type userStore struct { mu sync.Mutex users map[string]*User // ключ — login в нижнем регистре } var globalUsers = loadUsers() // usersLoadFailed — файл учётных записей есть, но прочитать/разобрать его не // удалось (повреждение, права). В этом случае НЕЛЬЗЯ открывать админку без входа: // пустой стор из-за сбоя не должен выглядеть как «первичная настройка». var usersLoadFailed bool func loadUsers() *userStore { s := &userStore{users: map[string]*User{}} b, err := os.ReadFile(usersFile) switch { case err == nil: var list []*User if json.Unmarshal(b, &list) != nil { usersLoadFailed = true // файл есть, но это не корректный JSON — сбой, не «нет пользователей» return s } for _, u := range list { s.users[strings.ToLower(u.Login)] = u } if len(s.users) > 0 { markConfigured() // бэкфилл маркера для установок, заведённых до этой защиты } case os.IsNotExist(err): // файла нет: либо действительно первый запуск, либо стор потеряли на // настроенной системе — различаем по постоянному маркеру ниже (authOpen). default: usersLoadFailed = true // есть, но недоступен (права и т. п.) } return s } // configuredMarker — постоянный признак «система уже была настроена» (заведён // хотя бы один пользователь). В отличие от first-run-файла он НЕ удаляется, и по // нему видно, что нулевой стор — это потеря данных, а не свежая установка. var configuredMarker = "/var/lib/bj/.bj/configured" func isConfigured() bool { _, err := os.Stat(configuredMarker); return err == nil } // markConfigured — отметить систему настроенной (идемпотентно, best-effort). func markConfigured() { if isConfigured() { return } _ = os.WriteFile(configuredMarker, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o600) } func (s *userStore) save() error { list := s.list() b, _ := json.MarshalIndent(list, "", " ") tmp := usersFile + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } if err := os.Rename(tmp, usersFile); err != nil { return err } if len(list) > 0 { markConfigured() // с этого момента нулевой стор трактуется как потеря данных } return nil } func (s *userStore) list() []*User { out := make([]*User, 0, len(s.users)) for _, u := range s.users { cp := *u out = append(out, &cp) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Login) < strings.ToLower(out[j].Login) }) return out } func (s *userStore) List() []*User { s.mu.Lock() defer s.mu.Unlock() return s.list() } func (s *userStore) Count() int { s.mu.Lock() defer s.mu.Unlock() return len(s.users) } func (s *userStore) Get(login string) *User { s.mu.Lock() defer s.mu.Unlock() if u, ok := s.users[strings.ToLower(strings.TrimSpace(login))]; ok { cp := *u return &cp } return nil } func (s *userStore) Put(u *User) error { s.mu.Lock() defer s.mu.Unlock() cp := *u s.users[strings.ToLower(u.Login)] = &cp return s.save() } func (s *userStore) Delete(login string) error { s.mu.Lock() defer s.mu.Unlock() delete(s.users, strings.ToLower(login)) return s.save() } func (s *userStore) update(login string, fn func(u *User)) error { s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[strings.ToLower(login)] if !ok { return fmt.Errorf("пользователь %s не найден", login) } fn(u) return s.save() } // AdminCount — сколько активных администраторов (чтобы не удалить последнего). func (s *userStore) AdminCount() int { s.mu.Lock() defer s.mu.Unlock() n := 0 for _, u := range s.users { if u.Role == "admin" && u.Active { n++ } } return n } func hashPassword(p string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(p), 12) return string(b), err } func checkPassword(hash, p string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)) == nil } // authOpen — режим первичной настройки: пользователей нет, вход не требуется. // authOpen — режим первичной настройки: вход не требуется. Открываем ТОЛЬКО на // действительно свежей системе — стор пуст, не было сбоя загрузки и система ещё // не отмечена настроенной. Потеря/порча users.json на настроенной системе сюда // НЕ попадает (fail-closed): её ловит запрет в authMiddleware. func authOpen() bool { return globalUsers.Count() == 0 && !usersLoadFailed && !isConfigured() } // authLockedOut — стор недоступен/пуст на уже настроенной системе: админку // блокируем, пока файл не восстановят. func authLockedOut() bool { return globalUsers.Count() == 0 && (usersLoadFailed || isConfigured()) } // ---------- сессии ---------- type Session struct { Token string `json:"token"` Login string `json:"login"` Created time.Time `json:"created"` Seen time.Time `json:"seen"` IP string `json:"ip"` UA string `json:"ua"` } type sessionStore struct { mu sync.Mutex list map[string]*Session } var globalSessions = loadSessions() func loadSessions() *sessionStore { s := &sessionStore{list: map[string]*Session{}} if b, err := os.ReadFile(sessionsFile); err == nil { var l []*Session if json.Unmarshal(b, &l) == nil { for _, x := range l { if time.Since(x.Created) < sessionMaxLife { s.list[x.Token] = x } } } } return s } func (s *sessionStore) save() { l := make([]*Session, 0, len(s.list)) for _, x := range s.list { l = append(l, x) } b, _ := json.Marshal(l) tmp := sessionsFile + ".tmp" if os.WriteFile(tmp, b, 0o600) == nil { _ = os.Rename(tmp, sessionsFile) } } func (s *sessionStore) New(login, ip, ua string) *Session { raw := make([]byte, 32) _, _ = rand.Read(raw) x := &Session{Token: hex.EncodeToString(raw), Login: login, Created: time.Now(), Seen: time.Now(), IP: ip, UA: ua} s.mu.Lock() s.list[x.Token] = x s.save() s.mu.Unlock() return x } func (s *sessionStore) Get(token string, idle time.Duration) *Session { s.mu.Lock() defer s.mu.Unlock() x, ok := s.list[token] if !ok { return nil } if time.Since(x.Created) > sessionMaxLife || time.Since(x.Seen) > idle { delete(s.list, token) s.save() return nil } if time.Since(x.Seen) > time.Minute { x.Seen = time.Now() s.save() } return x } func (s *sessionStore) Drop(token string) { s.mu.Lock() delete(s.list, token) s.save() s.mu.Unlock() } // DropUser — завершить все сессии пользователя (блокировка, смена роли). func (s *sessionStore) DropUser(login string) { s.mu.Lock() for t, x := range s.list { if strings.EqualFold(x.Login, login) { delete(s.list, t) } } s.save() s.mu.Unlock() } // 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([]SessionView, 0, len(s.list)) for t, x := range s.list { if time.Since(x.Created) < sessionMaxLife { 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 { mu sync.Mutex m map[string][]time.Time }{m: map[string][]time.Time{}} func loginFailCount(key string) int { loginFails.mu.Lock() defer loginFails.mu.Unlock() var keep []time.Time for _, t := range loginFails.m[key] { if time.Since(t) < 15*time.Minute { keep = append(keep, t) } } loginFails.m[key] = keep return len(keep) } func loginFailAdd(key string) { loginFails.mu.Lock() loginFails.m[key] = append(loginFails.m[key], time.Now()) loginFails.mu.Unlock() } func loginFailReset(key string) { loginFails.mu.Lock() delete(loginFails.m, key) loginFails.mu.Unlock() } // ---------- настройки входа (AD) ---------- type ADGroupRole struct { Group string `json:"group"` // имя группы (CN) или полный DN Role string `json:"role"` } type AuthSettings struct { SessionHours int `json:"session_hours"` // бездействие до выхода (по умолчанию 8) ADEnabled bool `json:"ad_enabled"` ADServer string `json:"ad_server"` // ldaps://dc1.corp.example.ru:636 или ldap://…:389 ADDomain string `json:"ad_domain"` // corp.example.ru — суффикс UPN для входа ADBaseDN string `json:"ad_base_dn"` // DC=corp,DC=example,DC=ru ADStartTLS bool `json:"ad_starttls"` // для ldap:// — поднимать TLS ADSkipVerify bool `json:"ad_skip_verify"` ADCAPEM string `json:"ad_ca_pem"` // сертификат корпоративного ЦС (PEM), если проверяем ADGroups []ADGroupRole `json:"ad_groups"` // группа → роль (первое совпадение) ADDefaultRole string `json:"ad_default_role"` // роль, если группы не совпали ("" = отказать) ADModulesByRole bool `json:"-"` // Перепроверка групп доменных пользователей (adrecheck.go): поиск от имени // сервисной учётной записи только на чтение. Пусто — роль меняется только при входе. ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN ADBindPassword string `json:"ad_bind_password"` ADRecheckMinutes int `json:"ad_recheck_minutes"` // 0 = 15 } func (a AuthSettings) idle() time.Duration { if a.SessionHours <= 0 { return 8 * time.Hour } return time.Duration(a.SessionHours) * time.Hour } func (r *RuntimeConfig) UpdateAuth(s AuthSettings) error { r.mu.Lock() r.data.Auth = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() return r.save() } // ---------- Active Directory ---------- type adResult struct { DN string Name string Email string Groups []string // DN групп (memberOf) Role string MatchedBy string } func adDial(a AuthSettings) (*ldap.Conn, error) { server := strings.TrimSpace(a.ADServer) if server == "" { return nil, fmt.Errorf("не задан адрес контроллера домена") } if !strings.Contains(server, "://") { server = "ldaps://" + server } u, err := url.Parse(server) if err != nil { return nil, err } host := u.Hostname() tlsCfg := &tls.Config{ServerName: host, InsecureSkipVerify: a.ADSkipVerify, MinVersion: tls.VersionTLS12} if pem := strings.TrimSpace(a.ADCAPEM); pem != "" && !a.ADSkipVerify { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM([]byte(pem)) { return nil, fmt.Errorf("сертификат ЦС не разобран (ожидается PEM)") } tlsCfg.RootCAs = pool } dialer := &net.Dialer{Timeout: 8 * time.Second} conn, err := ldap.DialURL(server, ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsCfg)) if err != nil { return nil, fmt.Errorf("подключение к %s: %v", server, err) } conn.SetTimeout(10 * time.Second) if u.Scheme == "ldap" && a.ADStartTLS { if err := conn.StartTLS(tlsCfg); err != nil { conn.Close() return nil, fmt.Errorf("StartTLS: %v", err) } } return conn, nil } // adAuthenticate — bind учётными данными пользователя, затем поиск его карточки // и проверка членства в сопоставленных группах (с вложенностью, // LDAP_MATCHING_RULE_IN_CHAIN). func adAuthenticate(a AuthSettings, login, password string) (*adResult, error) { login = strings.TrimSpace(login) if password == "" { return nil, fmt.Errorf("пустой пароль") } conn, err := adDial(a) if err != nil { return nil, err } defer conn.Close() sam, upn := adSplitLogin(a, login) if err := conn.Bind(upn, password); err != nil { if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { return nil, fmt.Errorf("%s", adBindReason(err)) } return nil, fmt.Errorf("AD bind: %v", err) } res, base, err := adLookup(conn, a, sam, upn) if err == errADNotFound { return nil, fmt.Errorf("пользователь %s не найден в %s", sam, base) } if err != nil { return res, err } if err := adResolveRole(conn, a, base, sam, res); err != nil { return res, err } return res, nil } // adLookup — карточка пользователя в AD. errADNotFound / errADDisabled — // ответ каталога; прочие ошибки — сбой поиска. func adLookup(conn *ldap.Conn, a AuthSettings, sam, upn string) (*adResult, string, error) { base := a.ADBaseDN if base == "" { base = adDefaultBase(conn) } req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 10, false, fmt.Sprintf("(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)))", ldap.EscapeFilter(sam), ldap.EscapeFilter(upn)), []string{"distinguishedName", "displayName", "mail", "memberOf", "userAccountControl"}, nil) sr, err := conn.Search(req) if err != nil { return nil, base, fmt.Errorf("поиск пользователя в AD: %v", err) } if len(sr.Entries) == 0 { return nil, base, errADNotFound } e := sr.Entries[0] res := &adResult{DN: e.DN, Name: e.GetAttributeValue("displayName"), Email: e.GetAttributeValue("mail"), Groups: e.GetAttributeValues("memberOf")} if uac := e.GetAttributeValue("userAccountControl"); uac != "" { var n int fmt.Sscanf(uac, "%d", &n) if n&2 != 0 { return res, base, errADDisabled } } return res, base, nil } // adNoGroupError — пользователь не состоит ни в одной сопоставленной группе // (в отличие от сбоя поиска, после которого роль менять нельзя). type adNoGroupError struct{ sam string } func (e *adNoGroupError) Error() string { return fmt.Sprintf("пользователь %s вошёл в AD, но не состоит ни в одной из сопоставленных групп", e.sam) } // adResolveRole — роль по группам: первое совпадение в порядке настройки. func adResolveRole(conn *ldap.Conn, a AuthSettings, base, sam string, res *adResult) error { for _, gr := range a.ADGroups { g := strings.TrimSpace(gr.Group) if g == "" || !roleAvailable(gr.Role) { continue } dn := g if !strings.Contains(strings.ToLower(g), "dc=") { // имя группы → DN gs, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 10, false, fmt.Sprintf("(&(objectClass=group)(|(cn=%s)(sAMAccountName=%s)))", ldap.EscapeFilter(g), ldap.EscapeFilter(g)), []string{"distinguishedName"}, nil)) if err != nil { return fmt.Errorf("поиск группы %s в AD: %v", g, err) } if len(gs.Entries) == 0 { continue } dn = gs.Entries[0].DN } // прямое членство matched := false for _, m := range res.Groups { if strings.EqualFold(m, dn) { matched = true break } } if !matched { // вложенное членство cs, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 10, false, fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s)(memberOf:1.2.840.113556.1.4.1941:=%s))", ldap.EscapeFilter(sam), ldap.EscapeFilter(dn)), []string{"distinguishedName"}, nil)) if err != nil { return fmt.Errorf("проверка вложенного членства в %s: %v", g, err) } matched = len(cs.Entries) > 0 } if matched { res.Role, res.MatchedBy = gr.Role, g return nil } } if a.ADDefaultRole != "" && roleAvailable(a.ADDefaultRole) { res.Role, res.MatchedBy = a.ADDefaultRole, "роль по умолчанию" return nil } return &adNoGroupError{sam: sam} } func adDefaultBase(conn *ldap.Conn) string { sr, err := conn.Search(ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 5, false, "(objectClass=*)", []string{"defaultNamingContext"}, nil)) if err == nil && len(sr.Entries) > 0 { return sr.Entries[0].GetAttributeValue("defaultNamingContext") } return "" } // adProbe — проверка доступности контроллера (rootDSE без bind). func adProbe(a AuthSettings) (string, error) { conn, err := adDial(a) if err != nil { return "", err } defer conn.Close() sr, err := conn.Search(ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 5, false, "(objectClass=*)", []string{"defaultNamingContext", "dnsHostName"}, nil)) if err != nil { return "", fmt.Errorf("rootDSE: %v", err) } if len(sr.Entries) == 0 { return "", fmt.Errorf("контроллер не вернул rootDSE") } e := sr.Entries[0] return fmt.Sprintf("контроллер %s, база %s", e.GetAttributeValue("dnsHostName"), e.GetAttributeValue("defaultNamingContext")), nil } // ---------- вход ---------- // authenticate — локальный пользователь, затем AD. Возвращает пользователя // (для AD — создаёт/обновляет локальную запись). func authenticate(rc *RuntimeConfig, login, password string) (*User, error) { login = strings.TrimSpace(login) if login == "" || password == "" { return nil, fmt.Errorf("введите логин и пароль") } a := rc.Snapshot().Auth u := globalUsers.Get(login) if u != nil && u.Source != "ad" { if !u.Active { return nil, fmt.Errorf("учётная запись заблокирована") } if u.PassHash != "" && checkPassword(u.PassHash, password) { if !userHasAccess(u) { return nil, fmt.Errorf("роль «%s» недоступна: в лицензии нет модуля %s", u.RoleTitle(), roleMissingModules(u.Role)) } return u, nil } if !a.ADEnabled { return nil, fmt.Errorf("неверный логин или пароль") } } if !a.ADEnabled { return nil, fmt.Errorf("неверный логин или пароль") } res, err := adAuthenticate(a, login, password) key := login if i := strings.Index(key, "@"); i > 0 { key = key[:i] } if i := strings.Index(key, `\`); i > 0 { key = key[i+1:] } var noGroup *adNoGroupError if err != nil && res != nil && errors.As(err, &noGroup) { if pre := globalUsers.Get(key); pre != nil && pre.Source == "ad" && pre.BJRole != "" && roleAvailable(pre.BJRole) { res.Role, res.MatchedBy, err = pre.BJRole, "роль, назначенная в bj", nil } } if err != nil { return nil, err } if u = globalUsers.Get(key); u == nil { u = &User{Login: key, Source: "ad", Active: true, CreatedAt: time.Now(), Role: res.Role} } else if !u.Active { return nil, fmt.Errorf("учётная запись заблокирована в bj") } u.Source = "ad" u.Role = res.Role 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 err := globalUsers.Put(u); err != nil { return nil, err } return u, nil } func dnCN(dn string) string { for _, part := range strings.Split(dn, ",") { if strings.HasPrefix(strings.ToLower(part), "cn=") { return part[3:] } } return dn } // ---------- middleware ---------- type authRW struct { http.ResponseWriter user *User } func (a *authRW) Unwrap() http.ResponseWriter { return a.ResponseWriter } func (a *authRW) Flush() { if f, ok := a.ResponseWriter.(http.Flusher); ok { f.Flush() } } func currentUser(w http.ResponseWriter) *User { for w != nil { if a, ok := w.(*authRW); ok { return a.user } u, ok := w.(interface{ Unwrap() http.ResponseWriter }) if !ok { return nil } w = u.Unwrap() } return nil } // authExempt — пути без входа: API модулей (свой ключ/токен), health, статика. func authExempt(p string) bool { switch { case p == "/login" || p == "/logout" || p == "/healthz" || p == "/first-run": return true case strings.HasPrefix(p, "/first-run/"): // восстановление из копии при переустановке — по одноразовому коду return true case strings.HasPrefix(p, "/api/"): return true case strings.HasPrefix(p, "/admin/npr/static/"): return true } return false } // devOnlyPath — служебные разделы разработчиков (тестирование, имитации, прогоны с роботом НРД): // доступны только в режиме разработки. func devOnlyPath(p string) bool { // прогоны в DEPO (/admin/incoming/depo-sim, /admin/outgoing/*/depo-sim) — не только для разработки: // их использует депозитарий для проверки обработки трансферных таблиц на боевой базе (22.09.2026). for _, pre := range []string{"/admin/m2m-testing", "/admin/depo-test", "/admin/setup/test-nsd", "/admin/setup/test-run", "/admin/setup/lk-simulate", "/admin/setup/postgres/quick-start", "/admin/help/robot", "/admin/api/clients", "/admin/api/callback-url", "/admin/setup/advanced-unlock", "/admin/setup/advanced-lock"} { if p == pre || strings.HasPrefix(p, pre+"/") { return true } } return false } // moduleForPath — лицензируемый модуль, которому принадлежит раздел ("" — ядро). Модуль, снятый // с лицензии, пропадает из меню и настроек, а его адреса отвечают «не входит в лицензию». func moduleForPath(p string) string { has := func(pre string) bool { return p == pre || strings.HasPrefix(p, pre+"/") } switch { case has("/admin/quik"), has("/admin/setup/quik"), has("/admin/fancy-queue"), has("/admin/setup/fancy"), has("/admin/setup/wizard-depo"), has("/admin/manual/depo"): return "quik-mnp" case has("/admin/setup/quikexport"): return "quik-export" case strings.HasPrefix(p, "/admin/setup/lk/"): // личный кабинет клиента (bj-lk) return "web-cabinet" 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-back" } return "" } 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 { if t, ok := moduleTitles[f]; ok { return t } if f == "updates" { return "обновления" } return f } func moduleNotLicensed(w http.ResponseWriter, f string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintf(w, `
«%s» не входит в лицензию этого сервера. Модуль можно добавить в лицензию у ZETIT — после этого раздел появится сам.
`, html.EscapeString(moduleTitles[f])) } // requiredPerm — какое право нужно для пути и метода. func requiredPerm(p, method string) string { if devOnlyPath(p) { return "dev" } switch { case strings.HasPrefix(p, "/admin/setup") || p == "/admin/wizard": return "settings" case strings.HasPrefix(p, "/admin/npr"): return "npr" case strings.HasPrefix(p, "/admin/quik"): return "quik" case p == "/admin/api/clients": return "view" } if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete { return "operate" } return "view" } // clientIP — адрес клиента. X-Real-IP учитывается только от локального прокси: иначе его // подставит кто угодно и обойдёт лимит попыток входа и fail2ban. func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { if h := strings.TrimSpace(r.Header.Get("X-Real-IP")); h != "" { return h } } return host } // sameOriginRequest — изменяющий запрос пришёл с нашего же сайта. Сверяем хост из // Origin (или Referer, если Origin нет) с Host запроса. Запрос без обоих заголовков // (curl, старые клиенты) пропускаем — CSRF из браузера без них не бывает. func sameOriginRequest(r *http.Request) bool { src := r.Header.Get("Origin") if src == "" || src == "null" { src = r.Header.Get("Referer") } if src == "" { return true } u, err := url.Parse(src) if err != nil || u.Host == "" { return false } if strings.EqualFold(u.Host, r.Host) { return true } // За прокси Host может быть переписан; браузер же видит внешний адрес — // его передаёт X-Forwarded-Host. Доверяем только от loopback-прокси. if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { if fh := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); fh != "" { return strings.EqualFold(u.Host, fh) } } } return false } // reqIsHTTPS — запрос защищён TLS: напрямую или через доверенный (loopback) // TLS-терминирующий прокси, сообщивший X-Forwarded-Proto: https. Нужно, чтобы за // nginx на том же хосте кука сессии всё равно получала признак Secure. func reqIsHTTPS(r *http.Request) bool { if r.TLS != nil { return true } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { proto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) return strings.EqualFold(proto, "https") } return false } func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path if devOnlyPath(p) && !devMode(rc) { // вне режима разработки служебных разделов нет вовсе http.NotFound(w, r) return } if authExempt(p) { next.ServeHTTP(w, r) return } // CSRF для изменяющих запросов админки: cookie сессии — SameSite=Lax, а вторым // слоем — источник запроса должен быть тем же сайтом (Origin, иначе Referer). // Токена в формах нет, поэтому это единственная явная проверка; браузеры всегда // шлют Origin на кросс-сайтовый POST. if r.Method != http.MethodGet && r.Method != http.MethodHead && !sameOriginRequest(r) { http.Error(w, "запрос с чужого сайта отклонён", http.StatusForbidden) return } if firstRunPending() { // свежая установка: сначала администратор по одноразовому коду if isAjax(r) { http.Error(w, "требуется первичная настройка", http.StatusUnauthorized) return } http.Redirect(w, r, "/first-run", http.StatusSeeOther) return } if authOpen() { next.ServeHTTP(&authRW{ResponseWriter: w, user: nil}, r) return } if authLockedOut() { // Учётные записи потеряны/повреждены на настроенной системе. // НЕ открываем админку без входа — блокируем до восстановления файла. log.Printf("auth: админ-панель заблокирована — стор учётных записей недоступен (usersLoadFailed=%v, configured=%v)", usersLoadFailed, isConfigured()) http.Error(w, "Учётные записи недоступны — админ-панель заблокирована. Восстановите /var/lib/bj/.bj/users.json из резервной копии и перезапустите bj-server.", http.StatusServiceUnavailable) return } var u *User if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { if s := globalSessions.Get(c.Value, rc.Snapshot().Auth.idle()); s != nil { u = globalUsers.Get(s.Login) if u != nil && !u.Active { globalSessions.Drop(c.Value) u = nil } } } if u == nil { if isAjax(r) { http.Error(w, "требуется вход", http.StatusUnauthorized) return } next := r.URL.RequestURI() http.Redirect(w, r, "/login?next="+url.QueryEscape(next), http.StatusSeeOther) return } if f := moduleForPath(p); f != "" && globalRC != nil && !moduleAllowed(globalRC, f) { moduleNotLicensed(w, f) return } perm := requiredPerm(p, r.Method) if !u.Can(perm) { // пользователь без права просмотра — отправляем на его стартовую страницу if r.Method == http.MethodGet && (p == "/admin" || p == "/admin/") && !u.Can("view") && u.Home() != "/admin/" { http.Redirect(w, r, u.Home(), http.StatusSeeOther) return } forbidden(w, r, u, perm) return } next.ServeHTTP(&authRW{ResponseWriter: w, user: u}, r) }) } func isAjax(r *http.Request) bool { return r.Header.Get("HX-Request") != "" || strings.Contains(r.Header.Get("Accept"), "application/json") || strings.HasPrefix(r.URL.Path, "/admin/npr/api/") || strings.HasPrefix(r.URL.Path, "/admin/incoming/feed") } var permTitles = map[string]string{"view": "просмотр переводов", "operate": "действия по переводам", "settings": "настройка системы", "npr": "модуль НПР / УДС", "quik": "поручения QUIK", "export": "экспорт QUIK", "users": "управление пользователями"} func forbidden(w http.ResponseWriter, r *http.Request, u *User, perm string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusForbidden) _ = loginTpl.ExecuteTemplate(w, "login", loginData{Title: "Нет доступа", Forbidden: true, User: u, Perm: permTitles[perm], Next: u.Home()}) } // ---------- страница входа ---------- type loginData struct { Title string Error string Login string Next string ADEnabled bool ADDomain string Forbidden bool User *User Perm string Brand string } var loginTpl = template.Must(template.New("login").Parse(loginHTML)) func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) { registerFirstRun(mux) mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { a := rc.Snapshot().Auth next := safeReturnPath(r.FormValue("next")) if firstRunPending() { http.Redirect(w, r, "/first-run", http.StatusSeeOther) return } if authOpen() { http.Redirect(w, r, "/admin/setup#users", http.StatusSeeOther) return } d := loginData{Title: "Вход", Next: next, ADEnabled: a.ADEnabled, ADDomain: a.ADDomain} if r.Method == http.MethodPost { login := strings.TrimSpace(r.FormValue("login")) d.Login = login key := clientIP(r) + "|" + strings.ToLower(login) if loginFailCount(key) >= 8 { d.Error = "Слишком много неудачных попыток. Подождите 15 минут." w.WriteHeader(http.StatusTooManyRequests) _ = loginTpl.ExecuteTemplate(w, "login", d) return } u, err := authenticate(rc, login, r.FormValue("password")) if err != nil { loginFailAdd(key) time.Sleep(700 * time.Millisecond) log.Printf("auth: отказ входа %q с %s: %v", login, clientIP(r), err) d.Error = err.Error() w.WriteHeader(http.StatusUnauthorized) _ = loginTpl.ExecuteTemplate(w, "login", d) return } loginFailReset(key) _ = globalUsers.update(u.Login, func(x *User) { x.LastLogin = time.Now() }) s := globalSessions.New(u.Login, clientIP(r), r.UserAgent()) http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: reqIsHTTPS(r), MaxAge: int(sessionMaxLife.Seconds())}) log.Printf("auth: вход %s (%s, %s) с %s", u.Login, u.Role, u.Source, clientIP(r)) if next == "" || next == "/login" { next = u.Home() } http.Redirect(w, r, next, http.StatusSeeOther) return } w.Header().Set("Cache-Control", "no-store") _ = loginTpl.ExecuteTemplate(w, "login", d) }) mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil { globalSessions.Drop(c.Value) } http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, MaxAge: -1}) http.Redirect(w, r, "/login", http.StatusSeeOther) }) } // ---------- обработчики настроек ---------- type UsersView struct { Users []*User Roles []roleDef // доступные при текущей лицензии Hidden []struct{ Title, Missing string } // роли, недоступные без модулей Modules []struct{ Key, Title string } // подключённые модули Sessions []SessionView Open bool Me *User ADRecheck ADRecheckStatus } func usersView(w http.ResponseWriter) UsersView { v := UsersView{Users: globalUsers.List(), Roles: availableRoles(), Modules: availableModules(), Sessions: globalSessions.Active(), Open: authOpen(), Me: currentUser(w), ADRecheck: adRecheckSnapshot()} for _, r := range roleDefs { if !roleAvailable(r.Key) { v.Hidden = append(v.Hidden, struct{ Title, Missing string }{r.Title, roleMissingModules(r.Key)}) } } return v } // RoleShown — роль есть в списке доступных (иначе в выпадающих списках она // показывается отдельной пометкой, чтобы сохранение не подменило её первой по списку). func (v UsersView) RoleShown(k string) bool { for _, r := range v.Roles { if r.Key == k { return true } } return false } func (v UsersView) RoleNote(k string) string { if _, ok := roleByKey(k); !ok { return "без роли — доступ снят" } return roleTitle(k) + " — нет модуля: " + roleMissingModules(k) } func (h *setupHandlers) usersFlash(w http.ResponseWriter, r *http.Request, msg string) { if safeReturnPath(r.FormValue("return_to")) != "" { // форма из мастера (шаг «Учётные записи») — туда же setupFlash(w, r, msg) return } http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg)+"#users", http.StatusSeeOther) } // doneUsers — кроме администратора заведена хотя бы одна действующая учётная запись: у каждого сотрудника свой // вход, и в журнале видно, кто что сделал. func doneUsers() bool { n := 0 for _, u := range globalUsers.List() { if u.Active { n++ } } return n > 1 } func (h *setupHandlers) saveUser(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } me := currentUser(w) login := strings.TrimSpace(r.FormValue("login")) if login == "" || strings.ContainsAny(login, " /\\@\"'<>") { h.usersFlash(w, r, "Пользователи: логин пустой или содержит недопустимые символы.") return } role := r.FormValue("role") if _, ok := roleByKey(role); !ok { h.usersFlash(w, r, "Пользователи: неизвестная роль.") return } u := globalUsers.Get(login) isNew := u == nil if !isNew && r.FormValue("create") != "" { // форма «создать» не должна молча менять существующую запись h.usersFlash(w, r, "Пользователи: логин «"+login+"» уже занят — выберите другой.") return } if isNew { u = &User{Login: login, Source: "local", Active: true, CreatedAt: time.Now()} } if u.Role != role && !roleAvailable(role) { h.usersFlash(w, r, "Пользователи: роль «"+roleTitle(role)+"» недоступна — в лицензии нет модуля: "+roleMissingModules(role)+".") return } wasAdmin := u.Role == "admin" && u.Active u.Name = strings.TrimSpace(r.FormValue("name")) u.Email = strings.TrimSpace(r.FormValue("email")) u.Role = role // модули вне лицензии в форме не показываются — ранее выданные сохраняем до её расширения var mods []string for _, m := range u.Modules { if !permLicensed(m) { mods = append(mods, m) } } for _, m := range r.Form["modules"] { for _, d := range availableModules() { if d.Key == m { mods = append(mods, m) } } } u.Modules = mods u.Active = r.FormValue("active") != "" || isNew if src := r.FormValue("source"); src == "ad" || src == "local" { u.Source = src } if u.Source == "ad" { u.BJRole = role // роль из bj — пока ни одна группа домена не сопоставлена } pw := r.FormValue("password") if u.Source == "local" { if pw != "" { if len(pw) < 8 { h.usersFlash(w, r, "Пользователи: пароль короче 8 символов.") return } hsh, err := hashPassword(pw) if err != nil { h.usersFlash(w, r, "Пользователи: "+err.Error()) return } u.PassHash = hsh } else if isNew { h.usersFlash(w, r, "Пользователи: для локального пользователя задайте пароль.") return } } // защита от потери последнего администратора if wasAdmin && (u.Role != "admin" || !u.Active) && globalUsers.AdminCount() <= 1 { h.usersFlash(w, r, "Пользователи: нельзя понизить или заблокировать последнего администратора.") return } if me != nil && strings.EqualFold(me.Login, u.Login) && (u.Role != "admin" || !u.Active) { h.usersFlash(w, r, "Пользователи: нельзя снять права администратора с самого себя.") return } if err := globalUsers.Put(u); err != nil { h.usersFlash(w, r, "Пользователи: "+err.Error()) return } if !u.Active || !isNew { if me == nil || !strings.EqualFold(me.Login, u.Login) { globalSessions.DropUser(u.Login) } } who := "—" if me != nil { who = me.Login } log.Printf("auth: пользователь %s сохранён (%s, роль %s, активен %v) кем: %s", u.Login, u.Source, u.Role, u.Active, who) if isNew && globalUsers.Count() == 1 { h.usersFlash(w, r, "Создан первый администратор "+u.Login+". Теперь вход обязателен — войдите под ним.") return } h.usersFlash(w, r, "Пользователь "+u.Login+" сохранён.") } func (h *setupHandlers) deleteUser(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } login := strings.TrimSpace(r.FormValue("login")) u := globalUsers.Get(login) if u == nil { h.usersFlash(w, r, "Пользователи: не найден.") return } me := currentUser(w) if me != nil && strings.EqualFold(me.Login, login) { h.usersFlash(w, r, "Пользователи: нельзя удалить самого себя.") return } if u.Role == "admin" && u.Active && globalUsers.AdminCount() <= 1 { h.usersFlash(w, r, "Пользователи: нельзя удалить последнего администратора.") return } _ = globalUsers.Delete(login) globalSessions.DropUser(login) h.usersFlash(w, r, "Пользователь "+login+" удалён.") } func (h *setupHandlers) dropSession(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } globalSessions.DropID(r.FormValue("id")) h.usersFlash(w, r, "Сессия завершена.") } func (h *setupHandlers) saveAuth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } a := h.rc.Snapshot().Auth fmt.Sscanf(r.FormValue("session_hours"), "%d", &a.SessionHours) a.ADEnabled = r.FormValue("ad_enabled") != "" a.ADServer = strings.TrimSpace(r.FormValue("ad_server")) a.ADDomain = strings.TrimSpace(strings.TrimPrefix(r.FormValue("ad_domain"), "@")) a.ADBaseDN = strings.TrimSpace(r.FormValue("ad_base_dn")) a.ADStartTLS = r.FormValue("ad_starttls") != "" a.ADSkipVerify = r.FormValue("ad_skip_verify") != "" a.ADCAPEM = strings.TrimSpace(r.FormValue("ad_ca_pem")) a.ADBindUser = strings.TrimSpace(r.FormValue("ad_bind_user")) if pw := r.FormValue("ad_bind_password"); pw != "" { a.ADBindPassword = pw // пустое поле не затирает сохранённый пароль } if a.ADBindUser == "" { a.ADBindPassword = "" } a.ADRecheckMinutes = 0 fmt.Sscanf(r.FormValue("ad_recheck_minutes"), "%d", &a.ADRecheckMinutes) a.ADDefaultRole = r.FormValue("ad_default_role") if _, ok := roleByKey(a.ADDefaultRole); !ok { a.ADDefaultRole = "" } a.ADGroups = nil groups, roles := r.Form["ad_group"], r.Form["ad_role"] for i := range groups { g := strings.TrimSpace(groups[i]) if g == "" || i >= len(roles) { continue } if _, ok := roleByKey(roles[i]); ok { a.ADGroups = append(a.ADGroups, ADGroupRole{Group: g, Role: roles[i]}) } } if err := h.rc.UpdateAuth(a); err != nil { h.usersFlash(w, r, "Вход: "+err.Error()) return } h.usersFlash(w, r, "Настройки входа сохранены.") } func (h *setupHandlers) testAD(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } a := h.rc.Snapshot().Auth login, pw := strings.TrimSpace(r.FormValue("test_login")), r.FormValue("test_password") if login == "" || pw == "" { msg, err := adProbe(a) if err != nil { var cert *adCertInfo if adCertUntrusted(err) { cert, _ = adFetchCert(a) } adDraftSet(a, adExplain(err, a, cert), cert, adForm{Server: a.ADServer, Domain: a.ADDomain}) h.usersFlash(w, r, "AD: "+adExplain(err, a, cert)) return } h.usersFlash(w, r, "AD: доступен ("+msg+"). Для проверки ролей введите логин и пароль пользователя домена.") return } res, err := adAuthenticate(a, login, pw) if err != nil { extra := "" if res != nil && len(res.Groups) > 0 { var names []string for _, g := range res.Groups { names = append(names, dnCN(g)) } extra = " Группы пользователя: " + strings.Join(names, ", ") + "." } h.usersFlash(w, r, "AD: "+err.Error()+"."+extra) return } var names []string for _, g := range res.Groups { names = append(names, dnCN(g)) } h.usersFlash(w, r, fmt.Sprintf("AD: вход OK — %s (%s), роль «%s» по «%s». Группы: %s.", res.Name, res.DN, roleTitle(res.Role), res.MatchedBy, strings.Join(names, ", "))) } func registerUsersSetup(mux *http.ServeMux, h *setupHandlers) { mux.HandleFunc("/admin/setup/users", h.saveUser) h.registerADWizard(mux) mux.HandleFunc("/admin/setup/users/delete", h.deleteUser) mux.HandleFunc("/admin/setup/users/session-drop", h.dropSession) mux.HandleFunc("/admin/setup/auth", h.saveAuth) mux.HandleFunc("/admin/setup/auth/test", h.testAD) mux.HandleFunc("/admin/setup/auth/recheck", h.recheckAD) mux.HandleFunc("/admin/dev-toggle", devToggle) // личный выключатель режима разработки } // ---------- функции шаблонов ---------- // authTemplateFuncs — заглушки, переопределяются в render на текущего пользователя. var authTemplateFuncs = template.FuncMap{ "curUser": func() *User { return nil }, "can": func(perm string) bool { return true }, "authOpen": authOpen, "devOn": func() bool { return devMode(globalRC) }, // режим действует для текущего пользователя "devLicensed": func() bool { return devMode(globalRC) }, // режим разрешён лицензией (для баннера) } // renderFor — выполняет layout с функциями, привязанными к текущему пользователю. func renderFor(w http.ResponseWriter, t *template.Template, data any) error { u := currentUser(w) t2, err := t.Clone() if err != nil { return t.ExecuteTemplate(w, "layout", data) } on := devOnFor(u) t2.Funcs(template.FuncMap{ "curUser": func() *User { return u }, "devOn": func() bool { return on }, "devLicensed": func() bool { return devMode(globalRC) }, "can": func(perm string) bool { if perm == "dev" { return on && (authOpen() || u.Can("settings")) } if authOpen() { return true } return u.Can(perm) }, }) return t2.ExecuteTemplate(w, "layout", data) } // ensureSubtle — используется для сравнения токенов в других местах. var _ = subtle.ConstantTimeCompare var _ = context.Background const loginHTML = `{{define "login"}}Пользователю {{.User.Login}} ({{.User.RoleTitle}}) недоступно: {{.Perm}}.
Права выдаёт администратор в разделе Настройка → Пользователи.
{{else}}