package lkgateway // lksession.go — сессии кабинета клиента. // // На диске лежит не токен, а его отпечаток (sha256), и не карточка клиента, а только // ссылка на неё: идентификатор учётной записи, номер договора и признаки. Всё остальное — // ФИО, паспорт, телефон — дочитывается из lk.users на каждый запрос. Поэтому файл сессий // в резервной копии или в снимке диска не даёт ни персональных данных, ни живого токена, а // блокировка клиента и снятие прав администратора действуют сразу, а не через 12 часов. // // Срок жизни двойной: скользящий (клиент работает — сессия не истекает) и абсолютный, // после которого нужно войти заново, сколько бы запросов ни было. import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/http" "os" "strings" "sync" "time" ) const ( lkSessionTTL = 12 * time.Hour // скользящий срок: столько сессия живёт без обращений lkSessionMaxAge = 24 * time.Hour // абсолютный срок: дольше не живёт даже активная lkUserCacheTTL = 30 * time.Second ) // lkSessionsFile — сессии переживают перезапуск bj-server. Иначе любое обновление // выбрасывало бы всех клиентов с «сессия истекла, войдите заново». var lkSessionsFile = "/var/lib/bj/.bj/lk-sessions.json" // переменная — для тестов // lkSession — что храним о сессии. Персональных данных здесь нет. type lkSession struct { UserID int64 `json:"user_id,omitempty"` Login string `json:"login,omitempty"` Contract string `json:"contract,omitempty"` // Virtual — сессия без учётной записи: сотрудник смотрит кабинет клиента // депозитария. Читать из lk.users нечего, признаки берём отсюда. Virtual bool `json:"virtual,omitempty"` VirtualName string `json:"virtual_name,omitempty"` IsAdmin bool `json:"is_admin,omitempty"` CanTransfer bool `json:"can_transfer,omitempty"` ImpersonatedBy string `json:"impersonated_by,omitempty"` Device string `json:"device,omitempty"` // отпечаток браузера клиента IP string `json:"ip,omitempty"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } type lkSessions struct { mu sync.Mutex data map[string]lkSession // отпечаток токена → сессия cacheMu sync.Mutex cache map[int64]lkUserCached // bindNetwork — сверять ли сеть клиента. По умолчанию нет: у мобильных операторов // адрес меняется прямо во время работы, и клиента выбрасывало бы из кабинета. // Включается в настройках для депозитариев, чьи клиенты работают из офиса. bindNetwork func() bool } type lkUserCached struct { user LKUser at time.Time } func newLKSessions() *lkSessions { s := &lkSessions{data: map[string]lkSession{}, cache: map[int64]lkUserCached{}} s.load() return s } // lkTokenHash — отпечаток токена: в файле и в памяти держим только его. func lkTokenHash(tok string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(tok))) return hex.EncodeToString(sum[:]) } // lkDeviceFingerprint — грубый отпечаток устройства: браузер целиком и сеть /24. // Точный адрес меняется у мобильного клиента, поэтому сверяем только подсеть. func lkDeviceFingerprint(agent string) string { if strings.TrimSpace(agent) == "" { return "" } sum := sha256.Sum256([]byte(agent)) return hex.EncodeToString(sum[:8]) } func lkNetwork(ip string) string { ip = strings.TrimSpace(ip) if i := strings.LastIndex(ip, "."); i > 0 && strings.Count(ip, ".") == 3 { return ip[:i] // IPv4 → сеть /24 } return ip } func (s *lkSessions) load() { raw, err := os.ReadFile(lkSessionsFile) if err != nil { return } var m map[string]lkSession if json.Unmarshal(raw, &m) != nil { return // в том числе старый формат с карточками клиентов: такие сессии просто не поднимаем } now := time.Now() for h, v := range m { if now.Before(v.ExpiresAt) && now.Before(v.CreatedAt.Add(lkSessionMaxAge)) { s.data[h] = v } } } // saveLocked сбрасывает сессии на диск (вызывается под удержанным mu). func (s *lkSessions) saveLocked() { raw, err := json.Marshal(s.data) if err != nil { return } tmp := lkSessionsFile + ".tmp" if os.WriteFile(tmp, raw, 0o600) == nil { _ = os.Rename(tmp, lkSessionsFile) } } // newToken — 24 случайных байта. Если системный источник случайности отказал, // суррогат не выдаём: предсказуемый токен хуже отказа во входе. func newToken() (string, error) { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return "", errors.New("нет источника случайных чисел, вход временно невозможен") } return hex.EncodeToString(b), nil } // create — новая сессия; возвращает токен, который больше нигде не сохраняется. func (s *lkSessions) create(v lkSession, r *http.Request) (string, error) { tok, err := newToken() if err != nil { return "", err } now := time.Now() v.CreatedAt, v.ExpiresAt = now, now.Add(lkSessionTTL) if r != nil { v.Device = lkDeviceFingerprint(lkClientAgent(r)) v.IP = lkNetwork(lkClientAddr(r)) } s.mu.Lock() s.data[lkTokenHash(tok)] = v for k, old := range s.data { // заодно подчистим протухшие if now.After(old.ExpiresAt) || now.After(old.CreatedAt.Add(lkSessionMaxAge)) { delete(s.data, k) } } s.saveLocked() s.mu.Unlock() return tok, nil } // get — сессия по токену с проверкой сроков и привязки к устройству. func (s *lkSessions) get(tok string, r *http.Request) (lkSession, bool) { if strings.TrimSpace(tok) == "" { return lkSession{}, false } h := lkTokenHash(tok) s.mu.Lock() defer s.mu.Unlock() v, ok := s.data[h] now := time.Now() if !ok || now.After(v.ExpiresAt) || now.After(v.CreatedAt.Add(lkSessionMaxAge)) { if ok { delete(s.data, h) s.saveLocked() } return lkSession{}, false } // Сессия привязана к браузеру и сети клиента: украденный токен с другой // машины не подойдёт. Отпечаток пустой — кабинет старой версии, не придираемся. if r != nil && v.Device != "" { if d := lkDeviceFingerprint(lkClientAgent(r)); d != "" && d != v.Device { delete(s.data, h) s.saveLocked() return lkSession{}, false } } if r != nil && v.IP != "" && s.bindNetwork != nil && s.bindNetwork() { if ip := lkNetwork(lkClientAddr(r)); ip != "" && ip != v.IP { delete(s.data, h) s.saveLocked() return lkSession{}, false } } // Скользящий срок: пока клиент работает — сессия не истекает. На диск пишем // не чаще раза в 10 минут, чтобы не дёргать файл на каждый запрос. if time.Until(v.ExpiresAt) < lkSessionTTL-10*time.Minute { v.ExpiresAt = now.Add(lkSessionTTL) if v.ExpiresAt.After(v.CreatedAt.Add(lkSessionMaxAge)) { v.ExpiresAt = v.CreatedAt.Add(lkSessionMaxAge) } s.data[h] = v s.saveLocked() } return v, true } func (s *lkSessions) drop(tok string) { if strings.TrimSpace(tok) == "" { return } s.mu.Lock() delete(s.data, lkTokenHash(tok)) s.saveLocked() s.mu.Unlock() } // dropUser — погасить все сессии учётной записи (смена пароля, блокировка). func (s *lkSessions) dropUser(userID int64) { if userID == 0 { return } s.mu.Lock() for h, v := range s.data { if v.UserID == userID { delete(s.data, h) } } s.saveLocked() s.mu.Unlock() } // user — карточка клиента по сессии. Для обычной сессии читается из lk.users // (с коротким кэшем), поэтому блокировка учётной записи и снятие прав // администратора действуют сразу. func (s *lkSessions) user(ctx context.Context, rc *RuntimeConfig, v lkSession) (LKUser, error) { if v.Virtual { return LKUser{ Login: v.Login, Contract: v.Contract, FullName: v.VirtualName, Active: true, CanTransfer: false, ImpersonatedBy: v.ImpersonatedBy, }, nil } s.cacheMu.Lock() c, ok := s.cache[v.UserID] s.cacheMu.Unlock() var u LKUser if ok && time.Since(c.at) < lkUserCacheTTL { u = c.user } else { loaded, err := lkUserByID(ctx, rc, v.UserID) if err != nil { return LKUser{}, err } u = *loaded s.cacheMu.Lock() s.cache[v.UserID] = lkUserCached{user: u, at: time.Now()} s.cacheMu.Unlock() } if !u.Active { return LKUser{}, errors.New("учётная запись заблокирована") } // Сотрудник в кабинете клиента — только просмотр, что бы ни стояло в учётке. // IsAdmin тоже снимаем: иначе, открыв кабинет другого администратора, сотрудник // действовал бы от его имени с полными правами. Свои админ-действия он делает из // собственной сессии (bj_lk_admin_sess), а не из сессии просмотра. if v.ImpersonatedBy != "" { u.ImpersonatedBy = v.ImpersonatedBy u.CanTransfer = false u.IsAdmin = false } return u, nil } // forget — сбросить кэш карточки клиента (после правки учётной записи). func (s *lkSessions) forget(userID int64) { s.cacheMu.Lock() delete(s.cache, userID) s.cacheMu.Unlock() } // lkClientAgent / lkClientAddr — браузер и адрес клиента кабинета. Кабинет стоит на // отдельном сервере, поэтому передаёт их заголовками: сами заголовки приходят вместе // с ключом модуля, то есть от доверенной стороны. func lkClientAgent(r *http.Request) string { if v := strings.TrimSpace(r.Header.Get("X-BJ-Client-Agent")); v != "" { return v } return strings.TrimSpace(r.UserAgent()) } func lkClientAddr(r *http.Request) string { if v := strings.TrimSpace(r.Header.Get("X-BJ-Client-IP")); v != "" { return v } return clientIP(r) }