ed598281e8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
1644 lines
61 KiB
Go
1644 lines
61 KiB
Go
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
|
||
}
|
||
|
||
// Device — браузер и система одним словом («Chrome · Windows») вместо строки User-Agent.
|
||
func (v SessionView) Device() string {
|
||
ua := v.UA
|
||
var b, o string
|
||
switch {
|
||
case strings.Contains(ua, "YaBrowser"):
|
||
b = "Яндекс Браузер"
|
||
case strings.Contains(ua, "Edg/"):
|
||
b = "Edge"
|
||
case strings.Contains(ua, "Firefox/"):
|
||
b = "Firefox"
|
||
case strings.Contains(ua, "Chrome/"):
|
||
b = "Chrome"
|
||
case strings.Contains(ua, "Safari/"):
|
||
b = "Safari"
|
||
case strings.Contains(ua, "curl/"):
|
||
b = "curl"
|
||
}
|
||
switch {
|
||
case strings.Contains(ua, "Windows"):
|
||
o = "Windows"
|
||
case strings.Contains(ua, "iPhone"), strings.Contains(ua, "iPad"):
|
||
o = "iOS"
|
||
case strings.Contains(ua, "Mac OS"):
|
||
o = "macOS"
|
||
case strings.Contains(ua, "Android"):
|
||
o = "Android"
|
||
case strings.Contains(ua, "Linux"):
|
||
o = "Linux"
|
||
}
|
||
switch {
|
||
case b != "" && o != "":
|
||
return b + " · " + o
|
||
case b != "" || o != "":
|
||
return b + o
|
||
case ua == "":
|
||
return "браузер неизвестен"
|
||
}
|
||
return "другой браузер"
|
||
}
|
||
|
||
// 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, `<!doctype html><meta charset="utf-8"><title>Модуль не входит в лицензию</title><body style="font-family:system-ui,sans-serif;max-width:640px;margin:60px auto;padding:0 16px"><h2>Модуль не входит в лицензию</h2><p>«%s» не входит в лицензию этого сервера. Модуль можно добавить в лицензию у ZETIT — после этого раздел появится сам.</p><p><a href="/admin/">На главную</a> · <a href="/admin/setup#sec-license">Лицензия</a></p></body>`, 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
|
||
}
|
||
igateShareSyncSoon(h.rc) // доменный сотрудник — и к папкам шлюза «через домен»
|
||
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)
|
||
igateShareSyncSoon(h.rc)
|
||
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
|
||
}
|
||
igateShareSyncSoon(h.rc)
|
||
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"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} — Bridge&Join</title>
|
||
<style>
|
||
:root{--bg:#f4f5f9;--card:#fff;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--err:#c62828;--border:#e3e5ee}
|
||
@media (prefers-color-scheme:dark){:root{--bg:#141721;--card:#1d2130;--text:#e6e8f0;--muted:#9aa0b3;--accent:#7c8cff;--border:#2b3044}}
|
||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px}
|
||
.box{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:28px 30px;width:100%;max-width:380px;box-shadow:0 12px 40px rgba(0,0,0,.08)}
|
||
.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700;margin-bottom:4px}.brand em{font-style:normal;color:var(--accent)}
|
||
.sub{color:var(--muted);font-size:12.5px;margin-bottom:18px}
|
||
label{display:block;font-size:12px;color:var(--muted);margin:10px 0 4px}
|
||
input{width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text);font-size:14px}
|
||
input:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
|
||
button{margin-top:16px;width:100%;padding:10px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;font-size:14px;cursor:pointer}
|
||
.err{background:rgba(198,40,40,.1);color:var(--err);padding:8px 10px;border-radius:8px;font-size:13px;margin-top:12px}
|
||
.hint{color:var(--muted);font-size:12px;margin-top:12px}
|
||
a{color:var(--accent)}
|
||
</style></head><body><div class="box">
|
||
<div class="brand"><svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg><span>Bridge<em>&Join</em></span></div>
|
||
{{if .Forbidden}}
|
||
<div class="sub">Нет доступа</div>
|
||
<p>Пользователю <b>{{.User.Login}}</b> ({{.User.RoleTitle}}) недоступно: <b>{{.Perm}}</b>.</p>
|
||
<p class="hint">Права выдаёт администратор в разделе Настройка → Пользователи.</p>
|
||
<p><a href="{{.Next}}">На свою страницу</a> · <a href="/logout">Выйти</a></p>
|
||
{{else}}
|
||
<div class="sub">Вход в систему управления</div>
|
||
<form method="post" action="/login">
|
||
<input type="hidden" name="next" value="{{.Next}}">
|
||
<label>Логин</label><input type="text" name="login" value="{{.Login}}" autocomplete="username" autofocus required>
|
||
<label>Пароль</label><input type="password" name="password" autocomplete="current-password" required>
|
||
<button type="submit">Войти</button>
|
||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||
{{if .ADEnabled}}<div class="hint">Сотрудники домена входят доменной учётной записью{{if .ADDomain}} ({{.ADDomain}}){{end}}.</div>{{end}}
|
||
</form>
|
||
{{end}}
|
||
</div></body></html>{{end}}`
|
||
|
||
// adBindReason — причина отказа AD по подкоду «data NNN» ответа контроллера домена
|
||
// (иначе все случаи выглядят как «неверный пароль»).
|
||
func adBindReason(err error) string {
|
||
s := strings.ToLower(err.Error())
|
||
switch {
|
||
case strings.Contains(s, "data 775"):
|
||
return "учётная запись заблокирована в AD (слишком много неверных попыток) — разблокирует администратор домена"
|
||
case strings.Contains(s, "data 532"):
|
||
return "срок действия пароля AD истёк — смените пароль (Ctrl+Alt+Del на компьютере в домене)"
|
||
case strings.Contains(s, "data 773"):
|
||
return "AD требует сменить пароль при следующем входе — смените пароль на компьютере в домене"
|
||
case strings.Contains(s, "data 533"):
|
||
return "учётная запись отключена в AD"
|
||
case strings.Contains(s, "data 701"):
|
||
return "срок действия учётной записи AD истёк"
|
||
case strings.Contains(s, "data 530"), strings.Contains(s, "data 531"):
|
||
return "вход этой учётной записи сейчас запрещён политикой AD (время/компьютер)"
|
||
case strings.Contains(s, "data 525"):
|
||
return "пользователь не найден в AD"
|
||
}
|
||
return "неверный логин или пароль (AD)"
|
||
}
|