Files
Bridge-and-Join-s/internal/lkgateway/lksession.go
T
zuevav 2a7442c935 Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные
- корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT),
  заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы»
- дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками,
  установщиком и инструкцией; ключ доступа на странице показан замаскированным

Связь без публичного адреса у ядра
- режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему,
  выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения,
  тот же ключ модуля в обе стороны, состояние канала в настройках
- сквозной тест: настоящий кабинет + настоящий цикл ядра

Защита (по итогам аудита)
- вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина,
  пароль от 10 символов
- сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных;
  карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный
  срок 24 ч; привязка к браузеру, к сети — по настройке
- вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение
- кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict,
  выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера,
  ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей
- шрифты вшиты в программу: страница не обращается к сторонним сайтам
- экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand
- исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash)

Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания),
живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 23:47:10 +03:00

298 lines
11 KiB
Go

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("учётная запись заблокирована")
}
// Сотрудник в кабинете клиента — только просмотр, что бы ни стояло в учётке.
if v.ImpersonatedBy != "" {
u.ImpersonatedBy = v.ImpersonatedBy
u.CanTransfer = 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)
}