3bb5e79272
- 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
302 lines
11 KiB
Go
302 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("учётная запись заблокирована")
|
||
}
|
||
// Сотрудник в кабинете клиента — только просмотр, что бы ни стояло в учётке.
|
||
// 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)
|
||
}
|