Files
Bridge-and-Join-s/internal/lkgateway/auth.go
T

1346 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// Пользователи, роли и вход в 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/subtle"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"fmt"
"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 (
usersFile = "/var/lib/bj/.bj/users.json"
sessionsFile = "/var/lib/bj/.bj/sessions.json"
sessionCookie = "bj_session"
sessionMaxLife = 24 * time.Hour
)
// ---------- роли и права ----------
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, тестирование M2M. Без настроек.", []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"`
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()
func loadUsers() *userStore {
s := &userStore{users: map[string]*User{}}
if b, err := os.ReadFile(usersFile); err == nil {
var list []*User
if json.Unmarshal(b, &list) == nil {
for _, u := range list {
s.users[strings.ToLower(u.Login)] = u
}
}
}
return s
}
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
}
return os.Rename(tmp, usersFile)
}
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 — режим первичной настройки: пользователей нет, вход не требуется.
func authOpen() bool { return globalUsers.Count() == 0 }
// ---------- сессии ----------
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()
}
func (s *sessionStore) Active() []*Session {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*Session, 0, len(s.list))
for _, x := range s.list {
if time.Since(x.Created) < sessionMaxLife {
cp := *x
out = append(out, &cp)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Seen.After(out[j].Seen) })
return out
}
// ---------- защита от перебора ----------
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("неверный логин или пароль (AD)")
}
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)
if err != nil {
return nil, err
}
key := login
if i := strings.Index(key, "@"); i > 0 {
key = key[:i]
}
if i := strings.Index(key, `\`); i > 0 {
key = key[i+1:]
}
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, "/api/"):
return true
case strings.HasPrefix(p, "/admin/npr/static/"):
return true
}
return false
}
// devOnlyPath — служебные разделы разработчиков (тестирование, имитации, прогоны с роботом НРД):
// доступны только в режиме разработки.
func devOnlyPath(p string) bool {
for _, pre := range []string{"/admin/m2m-testing", "/admin/incoming/depo-sim", "/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
}
// 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
}
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
}
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
}
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
}
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: r.TLS != nil, 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 []*Session
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) {
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg)+"#users", http.StatusSeeOther)
}
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 {
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
}
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.Drop(r.FormValue("token"))
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 {
h.usersFlash(w, r, "AD: недоступен — "+err.Error())
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)
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)
}
// ---------- функции шаблонов ----------
// authTemplateFuncs — заглушки, переопределяются в render на текущего пользователя.
var authTemplateFuncs = template.FuncMap{
"curUser": func() *User { return nil },
"can": func(perm string) bool { return true },
"authOpen": authOpen,
}
// 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)
}
t2.Funcs(template.FuncMap{
"curUser": func() *User { return u },
"can": func(perm string) bool {
if perm == "dev" {
return devMode(globalRC) && (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&amp;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>&amp;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}}`