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

1467 lines
52 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 (
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"`
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()
}
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 {
// прогоны в DEPO (/admin/incoming/depo-sim, /admin/outgoing/*/depo-sim) — не только для разработки:
// их использует депозитарий для проверки обработки трансферных таблиц на боевой базе (22.09.2026).
for _, pre := range []string{"/admin/m2m-testing", "/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
}
// 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
}
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 []*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)
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&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}}`