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>
298 lines
11 KiB
Go
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)
|
|
}
|