Files
Bridge-and-Join-s/internal/lkgateway/lksession.go
T
zuevav 3bb5e79272 Безопасность: остаток аудита — L2b, L3, L6, L7, L8, L10, L11, M4
- L2b: CSRF для изменяющих запросов админки — проверка Origin (иначе Referer)
  против Host на всех не-GET под /admin (sameOriginRequest в authMiddleware);
  за loopback-прокси учитывается X-Forwarded-Host. Второй слой поверх
  SameSite=Lax, без правки десятков форм настроек.
- L3: /back-to-admin меняет состояние — только POST + csrfOK (форма уже с токеном,
  проверки в обработчике не было).
- L6: EXEC процедуры DEPO по имени из настроек — через execProc с проверкой
  идентификатора (sqlident.go, см. коммит строчных событий).
- L7: сессия просмотра (impersonate) снимает IsAdmin цели — сотрудник не действует
  от имени другого администратора; свои действия — из своей сессии.
- L8: bootstrap администратора кабинета под замком lkBootstrapMu — два
  параллельных запроса не заведут двух админов (TOCTOU).
- L10: allowlist перезапуска служб не пропускает пустое имя.
- L11: ретранслятор — лимит тела проверяется по Content-Length до чтения и
  чтение ограничено RELAY_MAX_BODY+1.
- M4: .htaccess дополнительно запрещает *.lock (код установки), если данные
  оказались в веб-корне; nginx-запрет /data уже в README.

L4 (привязка сессии к сети по умолчанию выкл.) и L9 (косметическая cookie
bj_lk_admin, арбитр — ядро) оставлены как есть, отмечены в отчёте.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-22 16:28:14 +03:00

302 lines
11 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
// 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)
}