Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные - корпоративные действия по бумагам клиента (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>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user