Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные - корпоративные действия по бумагам клиента (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:
+118
-123
@@ -1,14 +1,12 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,115 +20,10 @@ import (
|
||||
// GET /api/lk/claims — статусы заявок клиента
|
||||
// GET /api/lk/ping — проверка связи модуля с bj-server
|
||||
|
||||
// Сессии ЛК живут дольше рабочего дня и ПРОДЛЕВАЮТСЯ при каждом обращении
|
||||
// (скользящий срок), поэтому активного клиента из кабинета не выкидывает.
|
||||
const lkSessionTTL = 12 * time.Hour
|
||||
|
||||
// lkSessionsFile — сессии переживают перезапуск bj-server. Раньше они были
|
||||
// только в памяти, и любой рестарт (обновление, деплой) выбрасывал всех
|
||||
// клиентов с «сессия истекла, войдите заново».
|
||||
const lkSessionsFile = "/var/lib/bj/.bj/lk-sessions.json"
|
||||
|
||||
type lkSession struct {
|
||||
user LKUser
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type lkSessions struct {
|
||||
mu sync.Mutex
|
||||
data map[string]lkSession
|
||||
}
|
||||
|
||||
func newLKSessions() *lkSessions {
|
||||
s := &lkSessions{data: map[string]lkSession{}}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
// persisted — формат хранения сессии на диске.
|
||||
type lkSessionDisk struct {
|
||||
User LKUser `json:"user"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// load поднимает непротухшие сессии с диска при старте сервера.
|
||||
func (s *lkSessions) load() {
|
||||
raw, err := os.ReadFile(lkSessionsFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]lkSessionDisk
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for tok, v := range m {
|
||||
if now.Before(v.ExpiresAt) {
|
||||
s.data[tok] = lkSession{user: v.User, expiresAt: v.ExpiresAt}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save сбрасывает сессии на диск (вызывается под удержанным mu).
|
||||
func (s *lkSessions) saveLocked() {
|
||||
m := make(map[string]lkSessionDisk, len(s.data))
|
||||
for tok, v := range s.data {
|
||||
m[tok] = lkSessionDisk{User: v.user, ExpiresAt: v.expiresAt}
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := lkSessionsFile + ".tmp"
|
||||
if os.WriteFile(tmp, raw, 0o600) == nil {
|
||||
_ = os.Rename(tmp, lkSessionsFile)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *lkSessions) create(u LKUser) string {
|
||||
b := make([]byte, 24)
|
||||
_, _ = rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
s.mu.Lock()
|
||||
s.data[tok] = lkSession{user: u, expiresAt: time.Now().Add(lkSessionTTL)}
|
||||
// подчистим протухшие
|
||||
for k, v := range s.data {
|
||||
if time.Now().After(v.expiresAt) {
|
||||
delete(s.data, k)
|
||||
}
|
||||
}
|
||||
s.saveLocked()
|
||||
s.mu.Unlock()
|
||||
return tok
|
||||
}
|
||||
|
||||
func (s *lkSessions) get(tok string) (LKUser, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.data[tok]
|
||||
if !ok || time.Now().After(v.expiresAt) {
|
||||
return LKUser{}, false
|
||||
}
|
||||
// Скользящий срок: пока клиент работает — сессия не истекает.
|
||||
// Пишем на диск не чаще раза в 10 минут, чтобы не дёргать файл на каждый запрос.
|
||||
if until := time.Until(v.expiresAt); until < lkSessionTTL-10*time.Minute {
|
||||
v.expiresAt = time.Now().Add(lkSessionTTL)
|
||||
s.data[tok] = v
|
||||
s.saveLocked()
|
||||
}
|
||||
return v.user, true
|
||||
}
|
||||
|
||||
func (s *lkSessions) drop(tok string) {
|
||||
s.mu.Lock()
|
||||
delete(s.data, tok)
|
||||
s.saveLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// registerLKAPI вешает /api/lk/* — интерфейс для модуля личного кабинета.
|
||||
func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
sess := newLKSessions()
|
||||
sess.bindNetwork = func() bool { return rc.Snapshot().LK.BindNetwork }
|
||||
|
||||
// проверка ключа модуля
|
||||
auth := func(w http.ResponseWriter, r *http.Request) bool {
|
||||
@@ -146,14 +39,21 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
// текущий клиент по session-токену
|
||||
// текущий клиент по session-токену. Карточка читается из lk.users на каждом
|
||||
// запросе (с коротким кэшем), поэтому блокировка учётной записи и снятие прав
|
||||
// администратора действуют сразу, а не после истечения сессии.
|
||||
current := func(w http.ResponseWriter, r *http.Request) (LKUser, bool) {
|
||||
tok := r.Header.Get("X-BJ-Session")
|
||||
u, ok := sess.get(tok)
|
||||
v, ok := sess.get(r.Header.Get("X-BJ-Session"), r)
|
||||
if !ok {
|
||||
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": "сессия истекла, войдите заново"})
|
||||
return LKUser{}, false
|
||||
}
|
||||
u, err := sess.user(r.Context(), rc, v)
|
||||
if err != nil {
|
||||
sess.drop(r.Header.Get("X-BJ-Session"))
|
||||
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
||||
return LKUser{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
@@ -178,17 +78,38 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
return
|
||||
}
|
||||
var in struct{ Login, Password string }
|
||||
if json.NewDecoder(r.Body).Decode(&in) != nil {
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&in) != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"})
|
||||
return
|
||||
}
|
||||
u, err := lkAuth(r.Context(), rc, strings.TrimSpace(in.Login), in.Password)
|
||||
login := strings.TrimSpace(in.Login)
|
||||
// Защита от перебора: логин клиента — это обычно номер договора, то есть
|
||||
// пространство перебора маленькое. Считаем неудачи и по адресу, и по логину.
|
||||
byIP := "lk-ip:" + lkClientAddr(r)
|
||||
byLogin := "lk-login:" + strings.ToLower(login)
|
||||
if loginFailCount(byIP) >= 10 || loginFailCount(byLogin) >= 8 {
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
lkJSON(w, http.StatusTooManyRequests, map[string]string{"error": "слишком много попыток входа, повторите через 15 минут"})
|
||||
return
|
||||
}
|
||||
u, err := lkAuth(r.Context(), rc, login, in.Password)
|
||||
if err != nil {
|
||||
loginFailAdd(byIP)
|
||||
loginFailAdd(byLogin)
|
||||
time.Sleep(300 * time.Millisecond) // и неудача, и перебор одинаково медленные
|
||||
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
loginFailReset(byIP)
|
||||
loginFailReset(byLogin)
|
||||
tok, err := sess.create(lkSession{UserID: u.ID, Login: u.Login, Contract: u.Contract,
|
||||
IsAdmin: u.IsAdmin, CanTransfer: u.CanTransfer}, r)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"session": sess.create(*u),
|
||||
"session": tok,
|
||||
"full_name": u.FullName,
|
||||
"contract": u.Contract,
|
||||
"is_admin": u.IsAdmin,
|
||||
@@ -338,6 +259,72 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
lkJSON(w, http.StatusOK, map[string]any{"income": rows, "accrued": accrued, "paid": paid, "tax": tax})
|
||||
})
|
||||
|
||||
// Корпоративные действия по бумагам клиента.
|
||||
mux.HandleFunc("/api/lk/corpactions", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fancy := rc.Snapshot().Fancy
|
||||
var isins []string
|
||||
if dp, err := depoClientPositions(r.Context(), fancy, u.Contract); err == nil {
|
||||
for _, p := range depoPositionsAsClient(dp) {
|
||||
if p.ISIN != "" {
|
||||
isins = append(isins, p.ISIN)
|
||||
}
|
||||
}
|
||||
}
|
||||
acts, err := lkCorpActions(r.Context(), fancy, isins)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить корпоративные действия: " + err.Error()})
|
||||
return
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{"actions": acts})
|
||||
})
|
||||
|
||||
// Документы: готовые выписки депозитария и заявки клиента.
|
||||
mux.HandleFunc("/api/lk/documents", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
var in struct {
|
||||
Kind string `json:"kind"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Account string `json:"account"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не разобрали заявку"})
|
||||
return
|
||||
}
|
||||
if err := lkDocRequestAdd(r.Context(), rc, u.Contract, u.FullName, in.Kind, in.From, in.To, in.Account, in.Comment); err != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
notifyDocRequest(rc, u, in.Kind, in.From, in.To)
|
||||
lkJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "Заявка принята: депозитарий подготовит документ и он появится в этом разделе."})
|
||||
return
|
||||
}
|
||||
ready, err := lkReadyDocuments(r.Context(), rc.Snapshot().Fancy, u.Contract)
|
||||
if err != nil {
|
||||
log.Printf("lk: документы депозитария: %v", err)
|
||||
}
|
||||
reqs, err2 := lkDocRequests(r.Context(), rc, u.Contract)
|
||||
if err2 != nil {
|
||||
log.Printf("lk: заявки на документы: %v", err2)
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{"ready": ready, "requests": reqs, "kinds": lkDocKinds})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
@@ -414,12 +401,15 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
if i := strings.Index(full, " "); i > 0 {
|
||||
name = strings.TrimSpace(full[i:])
|
||||
}
|
||||
target := LKUser{
|
||||
Login: "depo:" + full, Contract: full, FullName: name,
|
||||
Active: true, ImpersonatedBy: u.Login,
|
||||
tok, err := sess.create(lkSession{Login: "depo:" + full, Contract: full,
|
||||
Virtual: true, VirtualName: name, ImpersonatedBy: u.Login}, r)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lkAuditImpersonate(r.Context(), rc, u.Login, "depo:"+full, full, lkClientAddr(r))
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"session": sess.create(target), "full_name": name,
|
||||
"session": tok, "full_name": name,
|
||||
"contract": full, "as_client": full, "by_admin": u.Login,
|
||||
})
|
||||
return
|
||||
@@ -431,10 +421,15 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
}
|
||||
for _, c := range list {
|
||||
if c.Login == in.Login {
|
||||
target := c
|
||||
target.ImpersonatedBy = u.Login // помечаем: вошёл админ
|
||||
tok, err := sess.create(lkSession{UserID: c.ID, Login: c.Login, Contract: c.Contract,
|
||||
ImpersonatedBy: u.Login}, r) // помечаем: вошёл сотрудник, только просмотр
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lkAuditImpersonate(r.Context(), rc, u.Login, c.Login, c.Contract, lkClientAddr(r))
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"session": sess.create(target), "full_name": c.FullName,
|
||||
"session": tok, "full_name": c.FullName,
|
||||
"contract": c.Contract, "as_client": c.Login, "by_admin": u.Login,
|
||||
})
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user