diff --git a/cmd/bj-lk/main.go b/cmd/bj-lk/main.go index 948f20b..552ab98 100644 --- a/cmd/bj-lk/main.go +++ b/cmd/bj-lk/main.go @@ -8,8 +8,12 @@ // // BJ_URL адрес bj-server (напр. https://bj.example.ru) // BJ_KEY ключ доступа модуля (выдаётся в админке bj-server) +// BJ_MODE direct | agent (agent — ядро подключается к кабинету само) // LISTEN адрес прослушивания (по умолчанию :8081) // LK_TITLE название кабинета в шапке (по умолчанию «Личный кабинет») +// +// Режим agent нужен, когда у ядра нет адреса, доступного из интернета: тогда адрес +// ядра не задаётся, а кабинет ждёт, пока ядро подключится к нему само. package main import ( @@ -18,6 +22,7 @@ import ( "log" "net/http" "os" + "strings" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk" @@ -27,8 +32,10 @@ import ( var version = "0.1.0" func main() { - cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE")} + cfg := lk.Config{BJURL: os.Getenv("BJ_URL"), BJKey: os.Getenv("BJ_KEY"), Title: os.Getenv("LK_TITLE"), + Agent: strings.EqualFold(strings.TrimSpace(os.Getenv("BJ_MODE")), "agent")} listen := os.Getenv("LISTEN") + flag.BoolVar(&cfg.Agent, "agent", cfg.Agent, "режим ожидания: ядро подключается к кабинету само") flag.StringVar(&cfg.BJURL, "bj-url", cfg.BJURL, "адрес bj-server (https://...)") flag.StringVar(&cfg.BJKey, "bj-key", cfg.BJKey, "ключ доступа модуля") flag.StringVar(&listen, "listen", listen, "адрес прослушивания (:8081)") @@ -47,10 +54,29 @@ func main() { log.Fatalf("bj-lk: %v", err) } if !srv.Configured() { - log.Printf("bj-lk: не заданы BJ_URL и BJ_KEY — кабинет показывает страницу настройки (см. /etc/bj/lk.env)") + log.Printf("bj-lk: не заданы BJ_KEY и адрес ядра — кабинет показывает страницу настройки (см. /etc/bj-lk/lk.env)") + } + if srv.InsecureCore() { + // Не отказываем в запуске (бывают закрытые сети со своим шифрованием), + // но это надо видеть в журнале: по такому каналу идут ключ и токены сессий. + log.Printf("bj-lk: ВНИМАНИЕ — адрес ядра задан по открытому http (%s): ключ модуля и сессии клиентов пойдут по сети незашифрованными. Используйте https.", cfg.BJURL) + } + if cfg.Agent { + log.Printf("bj-lk %s (Личный кабинет) слушает %s, режим ожидания: ядро подключается само", version, listen) + } else { + log.Printf("bj-lk %s (Личный кабинет) слушает %s, ядро: %s", version, listen, cfg.BJURL) + } + // Таймауты обязательны: кабинет стоит в интернете, и без них медленный клиент + // может держать соединения сколько угодно. Ответ живёт дольше запроса — в режиме + // ожидания страница ждёт, пока ядро заберёт запрос и ответит. + hs := &http.Server{ + Addr: listen, Handler: srv.Handler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 20 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 32 << 10, } - log.Printf("bj-lk %s (Личный кабинет) слушает %s, bj-server: %s", version, listen, cfg.BJURL) - hs := &http.Server{Addr: listen, Handler: srv.Handler(), ReadHeaderTimeout: 5 * time.Second} if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } diff --git a/internal/lkgateway/depodb.go b/internal/lkgateway/depodb.go index fc20226..1c300cc 100644 --- a/internal/lkgateway/depodb.go +++ b/internal/lkgateway/depodb.go @@ -48,7 +48,7 @@ func depoContractExists(ctx context.Context, s FancyMSSQLSettings, contract stri var full string err = conn.QueryRowContext(cctx, "SELECT TOP 1 PORT_NUM FROM OD_CLIENTS_PORTS WHERE PORT_NUM=@p1 OR PORT_NUM LIKE @p2", - contract, contract+" %").Scan(&full) + contract, likeEscape(contract)+" %").Scan(&full) if err != nil { return "", false } @@ -75,7 +75,7 @@ func depoClientPositions(ctx context.Context, s FancyMSSQLSettings, contract str WHERE (p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2) AND CAST(rst.K AS float) <> 0 ORDER BY v.NAME` - rows, err := conn.QueryContext(cctx, q, contract, contract+" %") + rows, err := conn.QueryContext(cctx, q, contract, likeEscape(contract)+" %") if err != nil { return nil, err } @@ -218,3 +218,11 @@ func depoFindContracts(ctx context.Context, s FancyMSSQLSettings, like string, l } return out, rows.Err() } + +// likeEscape — экранирование шаблона LIKE. Номер договора приходит от клиента, и +// знаки % _ [ в нём должны означать сами себя, а не подстановку: иначе договор +// вида «БР-1%» совпал бы с чужими счетами. +func likeEscape(v string) string { + r := strings.NewReplacer("[", "[[]", "%", "[%]", "_", "[_]") + return r.Replace(v) +} diff --git a/internal/lkgateway/lkagent.go b/internal/lkgateway/lkagent.go new file mode 100644 index 0000000..f0fac20 --- /dev/null +++ b/internal/lkgateway/lkagent.go @@ -0,0 +1,254 @@ +package lkgateway + +// lkagent.go — подключение ядра к кабинету, когда у ядра нет адреса в интернете. +// +// Обычный порядок: кабинет на хостинге сам обращается к bj-server. Но ядро депозитария +// часто стоит во внутренней сети без публичного имени, и «достучаться» до него снаружи +// нельзя. Тогда связь разворачивается: ядро само подключается к кабинету по его +// доменному имени, забирает накопившиеся запросы клиентов, выполняет их у себя и +// возвращает ответы. Наружу из сети депозитария идёт только исходящее соединение по +// TLS — открывать порты и пробрасывать адрес не нужно. +// +// Запрос выполняется тем же обработчиком /api/lk/*, что и в прямом режиме: ядро просто +// прогоняет его через свой mux, как если бы кабинет пришёл по сети. + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +// lkAgentState — состояние канала для страницы настроек. +type lkAgentStateT struct { + Connected atomic.Bool + LastOK atomic.Int64 // unix-время последнего успешного обращения + LastErr atomic.Value // string + Served atomic.Uint64 // сколько запросов кабинета выполнено +} + +var lkAgentState lkAgentStateT + +func (a *lkAgentStateT) fail(err error) { + a.Connected.Store(false) + a.LastErr.Store(err.Error()) +} + +func (a *lkAgentStateT) ok() { + a.Connected.Store(true) + a.LastOK.Store(time.Now().Unix()) + a.LastErr.Store("") +} + +// LKAgentStatus — что показать администратору в настройках. +type LKAgentStatus struct { + Enabled bool `json:"enabled"` + Cabinet string `json:"cabinet"` + Connected bool `json:"connected"` + LastOK string `json:"last_ok,omitempty"` + LastErr string `json:"last_error,omitempty"` + Served uint64 `json:"served"` +} + +func lkAgentStatus(s Settings) LKAgentStatus { + st := LKAgentStatus{ + Enabled: s.LK.AgentMode, Cabinet: strings.TrimSpace(s.LK.CabinetURL), + Connected: lkAgentState.Connected.Load(), Served: lkAgentState.Served.Load(), + } + if v := lkAgentState.LastOK.Load(); v > 0 { + st.LastOK = time.Unix(v, 0).Format("02.01.2006 15:04:05") + } + if v, _ := lkAgentState.LastErr.Load().(string); v != "" { + st.LastErr = v + } + return st +} + +// lkAgentLoop — вечный цикл: подключиться к кабинету, забрать запрос, выполнить, ответить. +// Запускается всегда, но работает только когда режим ожидания включён в настройках: +// так администратор может включить и выключить его без перезапуска службы. +func (s *Server) lkAgentLoop(ctx context.Context) { + // Несколько подключений сразу: запрос к учётной системе может идти десятки секунд, + // и один медленный клиент не должен задерживать остальных. + var wg sync.WaitGroup + for i := 0; i < lkAgentWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.lkAgentWorker(ctx) + }() + } + wg.Wait() +} + +// lkAgentWorkers — сколько запросов кабинета ядро обслуживает одновременно. +const lkAgentWorkers = 4 + +func (s *Server) lkAgentWorker(ctx context.Context) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("кабинет: сбой при обслуживании запроса: %v", rec) + } + }() + client := &http.Client{Timeout: 40 * time.Second} + for { + if ctx.Err() != nil { + return + } + cfg := s.rc.Snapshot().LK + cabinet := strings.TrimRight(strings.TrimSpace(cfg.CabinetURL), "/") + if !cfg.AgentMode || cabinet == "" || strings.TrimSpace(cfg.APIKey) == "" { + lkAgentState.Connected.Store(false) + if !sleepCtx(ctx, 15*time.Second) { + return + } + continue + } + task, err := lkAgentFetch(ctx, client, cabinet, cfg.APIKey) + if err != nil { + lkAgentState.fail(err) + log.Printf("кабинет: нет связи с %s: %v", cabinet, err) + if !sleepCtx(ctx, 20*time.Second) { // не долбим хостинг при аварии + return + } + continue + } + lkAgentState.ok() + if task == nil { + continue // запросов не было, кабинет отпустил соединение — подключаемся заново + } + status, body := s.lkAgentServe(ctx, task) + if err := lkAgentReply(ctx, client, cabinet, cfg.APIKey, task.ID, status, body); err != nil { + log.Printf("кабинет: ответ на запрос %s не доставлен: %v", task.ID, err) + continue + } + lkAgentState.Served.Add(1) + } +} + +// lkAgentTask — запрос, ожидающий в кабинете. +type lkAgentTask struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Session string `json:"session"` + Body string `json:"body"` // base64 + // Адрес и браузер посетителя кабинета — для привязки сессии и лимита попыток входа. + ClientIP string `json:"client_ip"` + ClientAgent string `json:"client_agent"` +} + +// lkAgentFetch — забрать очередной запрос. nil без ошибки — запросов не было. +func lkAgentFetch(ctx context.Context, c *http.Client, cabinet, key string) (*lkAgentTask, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cabinet+"/agent/tasks", nil) + if err != nil { + return nil, err + } + req.Header.Set("X-BJ-Key", key) + resp, err := c.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusNoContent: + return nil, nil + case http.StatusOK: + var t lkAgentTask + if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&t); err != nil { + return nil, err + } + if t.ID == "" || !strings.HasPrefix(t.Path, "/api/lk/") { + return nil, fmt.Errorf("кабинет прислал неизвестный запрос") + } + return &t, nil + case http.StatusForbidden: + return nil, fmt.Errorf("кабинет не принял ключ модуля: сверьте BJ_KEY в /etc/bj-lk/lk.env") + default: + return nil, fmt.Errorf("кабинет ответил %d", resp.StatusCode) + } +} + +// lkAgentServe — выполнить запрос кабинета у себя: тот же обработчик /api/lk/*. +func (s *Server) lkAgentServe(ctx context.Context, t *lkAgentTask) (int, []byte) { + body, _ := base64.StdEncoding.DecodeString(t.Body) + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(cctx, t.Method, t.Path, bytes.NewReader(body)) + if err != nil { + return http.StatusBadRequest, []byte(`{"error":"некорректный запрос кабинета"}`) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-BJ-Key", s.rc.Snapshot().LK.APIKey) // запрос пришёл по ключу, он же идёт дальше + if t.Session != "" { + req.Header.Set("X-BJ-Session", t.Session) + } + if t.ClientIP != "" { + req.Header.Set("X-BJ-Client-IP", t.ClientIP) + } + if t.ClientAgent != "" { + req.Header.Set("X-BJ-Client-Agent", t.ClientAgent) + } + req.RemoteAddr = "127.0.0.1:0" // обращение пришло по нашему исходящему каналу + rec := &lkAgentRecorder{status: http.StatusOK} + s.mux.ServeHTTP(rec, req) + return rec.status, rec.buf.Bytes() +} + +// lkAgentRecorder — приёмник ответа обработчика: пишем в память, а не в сеть. +type lkAgentRecorder struct { + status int + buf bytes.Buffer + hdr http.Header +} + +func (w *lkAgentRecorder) Header() http.Header { + if w.hdr == nil { + w.hdr = http.Header{} + } + return w.hdr +} +func (w *lkAgentRecorder) Write(b []byte) (int, error) { return w.buf.Write(b) } +func (w *lkAgentRecorder) WriteHeader(code int) { w.status = code } + +// lkAgentReply — вернуть кабинету ответ на его запрос. +func lkAgentReply(ctx context.Context, c *http.Client, cabinet, key, id string, status int, body []byte) error { + payload, _ := json.Marshal(map[string]any{"id": id, "status": status, + "body": base64.StdEncoding.EncodeToString(body)}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cabinet+"/agent/result", bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("X-BJ-Key", key) + req.Header.Set("Content-Type", "application/json") + resp, err := c.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusGone { + return fmt.Errorf("кабинет ответил %d", resp.StatusCode) + } + return nil +} + +// sleepCtx — пауза, прерываемая остановкой службы. +func sleepCtx(ctx context.Context, d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} diff --git a/internal/lkgateway/lkagent_test.go b/internal/lkgateway/lkagent_test.go new file mode 100644 index 0000000..6f93343 --- /dev/null +++ b/internal/lkgateway/lkagent_test.go @@ -0,0 +1,90 @@ +package lkgateway + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/modules/lk" +) + +// Режим ожидания целиком: настоящий кабинет (modules/lk) в режиме agent и настоящий цикл +// ядра. Кабинет ставит запрос в очередь, ядро своим исходящим подключением забирает его, +// выполняет обработчиком /api/lk/* и возвращает ответ. +func TestLKAgentEndToEnd(t *testing.T) { + const key = "lk-agent-test-key" + + cabinet, err := lk.New(lk.Config{BJKey: key, Agent: true}) + if err != nil { + t.Fatal(err) + } + cab := httptest.NewServer(cabinet.Handler()) + defer cab.Close() + + rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json") + if err != nil { + t.Fatal(err) + } + lkCfg := rc.Snapshot().LK + lkCfg.APIKey, lkCfg.AgentMode, lkCfg.CabinetURL = key, true, cab.URL + if err := rc.UpdateLK(lkCfg); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + registerLKAPI(mux, rc, nil) + core := &Server{rc: rc, mux: mux} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go core.lkAgentLoop(ctx) + + // Администратор проверяет связь: кабинет спрашивает ядро через очередь. + req, _ := http.NewRequest(http.MethodGet, cab.URL+"/health", nil) + req.Header.Set("X-BJ-Key", key) + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + OK bool `json:"ok"` + Mode string `json:"mode"` + BJ map[string]any `json:"bj"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK || !out.OK || out.Mode != "agent" || out.BJ["service"] != "bj-server" { + t.Fatalf("ответ ядра через очередь: код %d, %+v", resp.StatusCode, out) + } + if st := lkAgentStatus(rc.Snapshot()); !st.Connected || st.Served == 0 { + t.Fatalf("состояние канала: %+v", st) + } + + // Без ключа наружу — только признак жизни, без адресов и подробностей. + resp2, err := client.Get(cab.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + var pub map[string]any + _ = json.NewDecoder(resp2.Body).Decode(&pub) + if len(pub) != 1 || pub["ok"] != true { + t.Fatalf("/health без ключа раскрывает лишнее: %+v", pub) + } +} + +// Ядро выполняет через канал только запросы к API кабинета — ничего другого. +func TestLKAgentRejectsForeignPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"id": "1", "method": "GET", "path": "/admin/setup"}) + })) + defer srv.Close() + if task, err := lkAgentFetch(context.Background(), srv.Client(), srv.URL, "k"); err == nil || task != nil { + t.Fatalf("чужой путь принят: %+v", task) + } +} diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index 8615782..07cbce7 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -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 diff --git a/internal/lkgateway/lkaudit.go b/internal/lkgateway/lkaudit.go new file mode 100644 index 0000000..d11d240 --- /dev/null +++ b/internal/lkgateway/lkaudit.go @@ -0,0 +1,85 @@ +package lkgateway + +// lkaudit.go — журнал входов сотрудника в кабинет клиента. +// +// Сотрудник может открыть кабинет клиента, чтобы помочь ему или подготовить поручение. +// Это доступ к персональным данным, поэтому каждый такой вход записывается: кто, к кому, +// когда и с какого адреса. Запись нужна и для внутреннего контроля, и чтобы ответить на +// вопрос клиента «кто смотрел мои данные». + +import ( + "context" + "fmt" + "log" + "time" +) + +// lkAuditSchema — журнал доступа (создаётся вместе со схемой lk). +const lkAuditSchema = ` +CREATE TABLE IF NOT EXISTS lk.audit ( + id bigserial PRIMARY KEY, + at timestamptz NOT NULL DEFAULT now(), + event text NOT NULL, + actor text NOT NULL, + target_login text, + target_contract text, + ip text +); +CREATE INDEX IF NOT EXISTS ix_lk_audit_at ON lk.audit (at DESC); +CREATE INDEX IF NOT EXISTS ix_lk_audit_target ON lk.audit (target_contract, at DESC); +` + +// lkAuditImpersonate — записать вход сотрудника в кабинет клиента. +func lkAuditImpersonate(ctx context.Context, rc *RuntimeConfig, actor, targetLogin, contract, ip string) { + go func() { + bg, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + db, err := lkDB(rc) + if err != nil { + log.Printf("кабинет: журнал доступа недоступен: %v", err) + return + } + if _, err := db.ExecContext(bg, `INSERT INTO lk.audit (event, actor, target_login, target_contract, ip) + VALUES ('impersonate', $1, $2, $3, $4)`, actor, targetLogin, contract, ip); err != nil { + log.Printf("кабинет: не записан вход в кабинет клиента: %v", err) + } + }() + raiseSystemAlert(rc, "Кабинет: вход сотрудника в кабинет клиента", + fmt.Sprintf("Сотрудник %s открыл кабинет клиента %s (договор %s) с адреса %s. Доступ только на просмотр, запись в журнале lk.audit.", + actor, targetLogin, contract, ip)) +} + +// lkAuditRecord — строка журнала для страницы настроек. +type lkAuditRecord struct { + At time.Time `json:"at"` + Event string `json:"event"` + Actor string `json:"actor"` + Target string `json:"target"` + Contract string `json:"contract"` + IP string `json:"ip"` +} + +// lkAuditList — последние записи журнала доступа. +func lkAuditList(ctx context.Context, rc *RuntimeConfig, limit int) ([]lkAuditRecord, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + db, err := lkDB(rc) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, `SELECT at, event, actor, COALESCE(target_login,''), COALESCE(target_contract,''), COALESCE(ip,'') + FROM lk.audit ORDER BY at DESC LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []lkAuditRecord + for rows.Next() { + var a lkAuditRecord + if rows.Scan(&a.At, &a.Event, &a.Actor, &a.Target, &a.Contract, &a.IP) == nil { + out = append(out, a) + } + } + return out, rows.Err() +} diff --git a/internal/lkgateway/lkcoverage_live_test.go b/internal/lkgateway/lkcoverage_live_test.go new file mode 100644 index 0000000..c7880c8 --- /dev/null +++ b/internal/lkgateway/lkcoverage_live_test.go @@ -0,0 +1,120 @@ +//go:build live + +package lkgateway + +import ( + "context" + "strings" + "testing" + "time" +) + +// Покрытие данных кабинета на реальных договорах: что клиент увидит на каждой странице. +// Только чтение. В вывод идут номера договоров без ФИО. +func TestLiveCabinetCoverage(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + globalRC = rc + fs := rc.Snapshot().Fancy + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) + defer cancel() + + contracts, err := depoListContracts(ctx, fs, "", 60) + if err != nil { + t.Fatalf("список договоров: %v", err) + } + if len(contracts) == 0 { + t.Fatal("в депозитарии нет договоров с остатками") + } + type agg struct{ clients, withData, rows int } + var positions, priced, ops, income, corp, docs agg + var posTotal, posPriced int + from := time.Now().AddDate(-2, 0, 0).Format("2006-01-02") + for _, c := range contracts { + num := c.Contract + if i := strings.Index(num, " "); i > 0 { + num = num[:i] // без ФИО + } + pos, err := depoClientPositions(ctx, fs, c.Contract) + if err != nil { + t.Errorf("%s: портфель: %v", num, err) + continue + } + positions.clients++ + if len(pos) > 0 { + positions.withData++ + positions.rows += len(pos) + } + isins := make([]string, 0, len(pos)) + for _, p := range pos { + if p.ISIN != "" { + isins = append(isins, p.ISIN) + } + } + px := lkPrices(ctx, rc, isins) + got := 0 + for _, i := range isins { + if p, ok := px[strings.ToUpper(i)]; ok && p.Price > 0 { + got++ + } + } + posTotal += len(isins) + posPriced += got + priced.clients++ + if got > 0 { + priced.withData++ + } + + o, err := lkOperations(ctx, fs, c.Contract, from, "", 200) + if err != nil { + t.Errorf("%s: операции: %v", num, err) + } + ops.clients++ + if len(o) > 0 { + ops.withData++ + ops.rows += len(o) + } + inc, _, err := lkIncome(ctx, fs, c.Contract, from, "") + if err != nil { + t.Errorf("%s: доходы: %v", num, err) + } + income.clients++ + if len(inc) > 0 { + income.withData++ + income.rows += len(inc) + } + ca, err := lkCorpActions(ctx, fs, isins) + if err != nil { + t.Errorf("%s: корпоративные действия: %v", num, err) + } + corp.clients++ + if len(ca) > 0 { + corp.withData++ + corp.rows += len(ca) + } + d, err := lkReadyDocuments(ctx, fs, c.Contract) + if err != nil { + t.Errorf("%s: документы: %v", num, err) + } + docs.clients++ + if len(d) > 0 { + docs.withData++ + docs.rows += len(d) + } + } + line := func(name string, a agg) { + t.Logf("%-28s данные есть у %3d из %3d клиентов, строк всего %d", name, a.withData, a.clients, a.rows) + } + t.Logf("Выборка: %d договоров, период операций и доходов — с %s", len(contracts), from) + line("Портфель (остатки)", positions) + line("Оценка по ценам QUIK", priced) + if posTotal > 0 { + t.Logf("%-28s оценено %d из %d позиций (%.0f%%)", " доля позиций с ценой", posPriced, posTotal, 100*float64(posPriced)/float64(posTotal)) + } + line("Операции по счёту депо", ops) + line("Доходы (купоны, дивиденды)", income) + line("Корпоративные действия", corp) + line("Готовые выписки", docs) +} diff --git a/internal/lkgateway/lkdist.go b/internal/lkgateway/lkdist.go new file mode 100644 index 0000000..9937fa1 --- /dev/null +++ b/internal/lkgateway/lkdist.go @@ -0,0 +1,311 @@ +package lkgateway + +// lkdist.go — дистрибутив кабинета клиента для отдельного веб-сервера. +// +// Кабинет (bj-lk) ставится не рядом с ядром, а на веб-сервер депозитария: своей базы +// у него нет, он обращается к ядру по адресу и ключу. Здесь bj собирает архив с +// исполняемым файлом, юнитом systemd, файлом настроек, установщиком и инструкцией — +// администратору остаётся распаковать архив и запустить установку. + +import ( + "archive/tar" + "compress/gzip" + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +// lkDistBinary — где лежит исполняемый файл кабинета на этом сервере (переменная — для тестов). +var lkDistBinary = "/opt/bj/bj-lk" + +// lkCoreURL — адрес ядра, по которому кабинет будет за ним ходить. Если в настройках +// не задан — подставляем адрес этого сервера, чтобы инструкция не была пустой. +func lkCoreURL(s Settings, r *http.Request) string { + if u := strings.TrimSpace(s.LK.CoreURL); u != "" { + return strings.TrimRight(u, "/") + } + host := strings.TrimSpace(r.Host) + if host == "" { + host = "bj.example.ru" + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return scheme + "://" + host +} + +// lkNewAPIKey — новый ключ доступа модуля. Если источник случайных чисел отказал, +// возвращаем ошибку: предсказуемый ключ хуже, чем его отсутствие. +func lkNewAPIKey() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("нет источника случайных чисел, ключ не создан: %v", err) + } + return "lk-" + hex.EncodeToString(b), nil +} + +// maskSecret — показать ключ так, чтобы его нельзя было списать со скриншота, +// но можно было сверить с тем, что стоит у кабинета. +func maskSecret(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if len(v) <= 12 { + return strings.Repeat("•", len(v)) + } + return v[:7] + strings.Repeat("•", 12) + v[len(v)-4:] +} + +// lkDistFiles — содержимое архива: имя файла → содержимое и права. +func lkDistFiles(s Settings, coreURL string) ([]struct { + Name string + Mode int64 + Data []byte +}, error) { + bin, err := os.ReadFile(lkDistBinary) + if err != nil { + return nil, fmt.Errorf("исполняемый файл кабинета не найден (%s): %v", lkDistBinary, err) + } + title := strings.TrimSpace(orgName(s)) + if title == "" { + title = "Личный кабинет" + } else { + title += " · личный кабинет" + } + mode, urlLine := "direct", "BJ_URL="+coreURL + if s.LK.AgentMode { + // Режим ожидания: адрес ядра кабинету не нужен и не должен быть задан — + // ядро подключается к кабинету само по имени этого сервера. + mode, urlLine = "agent", "# BJ_URL не нужен: ядро подключается к кабинету само" + } + env := fmt.Sprintf(`# Настройки кабинета клиента (bj-lk). Файл читает служба, права 0600. +# Режим связи с ядром: direct — кабинет обращается к ядру; agent — ядро подключается сюда. +BJ_MODE=%s +# Адрес ядра bj, к которому обращается кабинет (только для режима direct): +%s +# Ключ доступа модуля — тот же, что в настройках ядра, раздел «Личный кабинет». +BJ_KEY=%s +# Адрес и порт, на которых кабинет слушает (за ним обычно стоит nginx с TLS): +LISTEN=127.0.0.1:8099 +# Заголовок кабинета: +LK_TITLE=%s +`, mode, urlLine, s.LK.APIKey, title) + + unit := `[Unit] +Description=Личный кабинет клиента депозитария (bj-lk) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=bjlk +Group=bjlk +EnvironmentFile=/etc/bj-lk/lk.env +ExecStart=/opt/bj-lk/bj-lk +Restart=on-failure +RestartSec=5 +StandardOutput=append:/var/log/bj-lk/bj-lk.log +StandardError=append:/var/log/bj-lk/bj-lk.log +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/log/bj-lk + +[Install] +WantedBy=multi-user.target +` + + install := `#!/bin/sh +# Установка кабинета клиента на этот сервер. Запускать от root: sudo ./install.sh +set -eu +DIR=$(cd "$(dirname "$0")" && pwd) +id bjlk >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin bjlk +install -d -m 755 /opt/bj-lk +install -d -m 750 -o bjlk -g bjlk /var/log/bj-lk +install -d -m 750 /etc/bj-lk +install -m 755 "$DIR/bj-lk" /opt/bj-lk/bj-lk +if [ -f /etc/bj-lk/lk.env ]; then + echo "Файл /etc/bj-lk/lk.env уже есть — оставляю как есть." +else + install -m 600 "$DIR/lk.env" /etc/bj-lk/lk.env +fi +install -m 644 "$DIR/bj-lk.service" /etc/systemd/system/bj-lk.service +systemctl daemon-reload +systemctl enable --now bj-lk +sleep 2 +systemctl --no-pager --full status bj-lk | head -n 12 +echo +echo "Проверка: curl -s http://127.0.0.1:8099/health" +` + + readme := fmt.Sprintf(`Личный кабинет клиента депозитария (bj-lk) +========================================== + +Что это. Кабинет — отдельная программа для веб-сервера депозитария. Своей базы у неё +нет: все данные она запрашивает у ядра bj по защищённому каналу и не хранит дольше +сессии пользователя. Поэтому кабинет можно держать в защищённом сегменте (DMZ), а +учётные данные клиентов оставить внутри. + +Что в архиве: + bj-lk — исполняемый файл кабинета (Linux, x86-64) + bj-lk.service — служба systemd + lk.env — настройки: адрес ядра, ключ доступа, порт, заголовок + install.sh — установка одной командой + README.txt — этот файл + +Установка: + 1. Распакуйте архив на сервере кабинета. + 2. sudo ./install.sh + 3. Проверьте: curl -s http://127.0.0.1:8099/health — должно вернуться "ok":true. + +Настройки (файл /etc/bj-lk/lk.env): + BJ_URL — адрес ядра bj: %s + BJ_KEY — ключ доступа модуля, он же в настройках ядра, раздел «Личный кабинет» + LISTEN — адрес и порт кабинета (по умолчанию 127.0.0.1:8099) + LK_TITLE — заголовок кабинета: %s +После изменения файла: sudo systemctl restart bj-lk + +Публикация в интернет. Поставьте перед кабинетом nginx с сертификатом TLS: + + server { + listen 443 ssl; + server_name lk.example.ru; + ssl_certificate /etc/ssl/certs/lk.crt; + ssl_certificate_key /etc/ssl/private/lk.key; + location / { + proxy_pass http://127.0.0.1:8099; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } + +Режим связи (BJ_MODE в lk.env). Сейчас в архиве: %s. + direct — кабинет сам обращается к ядру по адресу BJ_URL. Подходит, когда у ядра + есть адрес, доступный серверу кабинета. + agent — у ядра нет адреса в интернете. Тогда кабинет ничего никуда не звонит: он + ждёт, пока ядро само подключится к нему по имени %s, заберёт запросы + клиентов и вернёт ответы. Наружу из сети депозитария идёт только + исходящее соединение, порты открывать не нужно. В настройках ядра + («Личный кабинет» → связь) выберите «Ядро подключается к кабинету» и + укажите адрес этого сервера. + +Сеть и безопасность: + • наружу открыт только 443 сервера кабинета; + • в режиме direct кабинет обращается к ядру на %s — разрешите этот доступ + в межсетевом экране; в режиме agent входящий доступ к ядру не нужен вовсе; + • ключ доступа хранится только в /etc/bj-lk/lk.env (права 0600, владелец root); + • ключ можно сменить в настройках ядра, после чего заменить его в lk.env и + перезапустить службу; + • кабинет не хранит данные клиентов: ни базы, ни файлов — всё берётся у ядра + на время запроса. Шрифты и оформление вшиты в программу, сторонние сайты + страница не запрашивает; + • ВАЖНО: в этом архиве лежит боевой ключ доступа. После установки удалите + архив с рабочего компьютера и не пересылайте его почтой или в мессенджере. + +Обновления. Кабинет обновляется отдельно от ядра: новую версию скачивайте на +странице настроек ядра, раздел «Личный кабинет», и ставьте тем же install.sh — +настройки при этом сохраняются. Версии кабинета и ядра должны быть совместимы; +если ядро старше, кабинет покажет предупреждение администратору. + +Лицензия. Кабинет входит в модуль «Личный кабинет клиента» и работает, пока он есть +в лицензии депозитария. +`, coreURL, title, mode, cabinetHint(s), coreURL) + + return []struct { + Name string + Mode int64 + Data []byte + }{ + {"bj-lk/bj-lk", 0o755, bin}, + {"bj-lk/bj-lk.service", 0o644, []byte(unit)}, + {"bj-lk/lk.env", 0o600, []byte(env)}, + {"bj-lk/install.sh", 0o755, []byte(install)}, + {"bj-lk/README.txt", 0o644, []byte(readme)}, + }, nil +} + +// lkDistDownload — GET /admin/setup/lk/dist.tar.gz: архив для установки кабинета. +func (h *setupHandlers) lkDistDownload(w http.ResponseWriter, r *http.Request) { + s := h.rc.Snapshot() + if strings.TrimSpace(s.LK.APIKey) == "" { + setupFlash(w, r, "Сначала создайте ключ доступа кабинета — кнопка «Создать ключ».") + return + } + files, err := lkDistFiles(s, lkCoreURL(s, r)) + if err != nil { + setupFlash(w, r, "Дистрибутив кабинета: "+err.Error()) + return + } + name := fmt.Sprintf("bj-lk-%s.tar.gz", time.Now().Format("2006-01-02")) + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) + gz := gzip.NewWriter(w) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + now := time.Now() + for _, f := range files { + if err := tw.WriteHeader(&tar.Header{Name: f.Name, Mode: f.Mode, Size: int64(len(f.Data)), ModTime: now, Typeflag: tar.TypeReg}); err != nil { + return + } + if _, err := tw.Write(f.Data); err != nil { + return + } + } +} + +// lkSaveDist — POST /admin/setup/lk/dist: адрес ядра для кабинета и ключ доступа. +func (h *setupHandlers) lkSaveDist(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().LK + if u := strings.TrimSpace(r.FormValue("core_url")); u != "" || r.FormValue("core_url_set") != "" { + s.CoreURL = strings.TrimRight(u, "/") + } + // Режим связи: кабинет ходит к ядру или ядро подключается к кабинету само. + if r.FormValue("mode_set") != "" { + s.BindNetwork = r.FormValue("bind_network") == "1" + s.AgentMode = r.FormValue("mode") == "agent" + s.CabinetURL = strings.TrimRight(strings.TrimSpace(r.FormValue("cabinet_url")), "/") + if s.AgentMode && s.CabinetURL == "" { + setupFlash(w, r, "Кабинет: для режима ожидания укажите адрес кабинета на хостинге (https://lk.example.ru).") + return + } + } + msg := "Кабинет: настройки сохранены" + switch r.FormValue("op") { + case "newkey": + key, err := lkNewAPIKey() + if err != nil { + setupFlash(w, r, "Кабинет: "+err.Error()) + return + } + s.APIKey = key + msg = "Создан новый ключ доступа кабинета. Скачайте дистрибутив заново или впишите ключ в /etc/bj-lk/lk.env и перезапустите службу кабинета." + case "clearkey": + s.APIKey = "" + msg = "Ключ удалён: кабинет больше не сможет обращаться к ядру." + } + if err := h.rc.UpdateLK(s); err != nil { + setupFlash(w, r, "Кабинет: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, msg) +} + +// cabinetHint — как в инструкции назвать адрес самого кабинета. +func cabinetHint(s Settings) string { + if u := strings.TrimSpace(s.LK.CabinetURL); u != "" { + return u + } + return "https://lk.example.ru" +} diff --git a/internal/lkgateway/lkdist_test.go b/internal/lkgateway/lkdist_test.go new file mode 100644 index 0000000..91e3f86 --- /dev/null +++ b/internal/lkgateway/lkdist_test.go @@ -0,0 +1,91 @@ +package lkgateway + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Дистрибутив кабинета: файлы, подстановка адреса ядра и ключа, ключ не пустой. +func TestLKDistFiles(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "bj-lk") + if err := os.WriteFile(bin, []byte("двоичный файл"), 0o755); err != nil { + t.Fatal(err) + } + saved := lkDistBinary + lkDistBinary = bin + defer func() { lkDistBinary = saved }() + + var s Settings + s.LK.APIKey = "lk-testkey" + s.NSD.OrgName = "Депозитарий «Пример»" + files, err := lkDistFiles(s, "https://bj.example.ru") + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, f := range files { + got[f.Name] = string(f.Data) + } + for _, want := range []string{"bj-lk/bj-lk", "bj-lk/bj-lk.service", "bj-lk/lk.env", "bj-lk/install.sh", "bj-lk/README.txt"} { + if _, ok := got[want]; !ok { + t.Errorf("в архиве нет %s", want) + } + } + env := got["bj-lk/lk.env"] + if !strings.Contains(env, "BJ_URL=https://bj.example.ru") || !strings.Contains(env, "BJ_KEY=lk-testkey") { + t.Errorf("настройки кабинета: %s", env) + } + if !strings.Contains(env, "Депозитарий «Пример» · личный кабинет") { + t.Errorf("заголовок кабинета не подставлен: %s", env) + } + if !strings.Contains(got["bj-lk/README.txt"], "https://bj.example.ru") || !strings.Contains(got["bj-lk/install.sh"], "systemctl enable --now bj-lk") { + t.Error("инструкция или установщик собраны неправильно") + } + k1, err1 := lkNewAPIKey() + k2, err2 := lkNewAPIKey() + if err1 != nil || err2 != nil || k1 == k2 || !strings.HasPrefix(k1, "lk-") || len(k1) < 20 { + t.Errorf("ключи доступа: %q %q (%v, %v)", k1, k2, err1, err2) + } + if m := maskSecret(k1); strings.Contains(m, k1[8:20]) || !strings.HasPrefix(m, k1[:7]) { + t.Errorf("ключ показан не замаскированным: %q", m) + } +} + +// TestLKDistAgentMode — в режиме ожидания адрес ядра в настройках кабинета не задаётся. +func TestLKDistAgentMode(t *testing.T) { + lkDistBinary = writeFakeBinary(t) + s := Settings{} + s.LK.APIKey = "lk-test-key" + s.LK.AgentMode = true + s.LK.CabinetURL = "https://lk.example.ru" + files, err := lkDistFiles(s, "https://bj.example.ru") + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, f := range files { + got[f.Name] = string(f.Data) + } + env := got["bj-lk/lk.env"] + if !strings.Contains(env, "BJ_MODE=agent") || strings.Contains(env, "BJ_URL=https://bj.example.ru") { + t.Errorf("настройки для режима ожидания собраны неправильно:\n%s", env) + } + if !strings.Contains(got["bj-lk/README.txt"], "https://lk.example.ru") { + t.Error("в инструкции нет адреса кабинета, к которому подключается ядро") + } +} + +// writeFakeBinary — подложный файл кабинета для сборки архива в тестах. +func writeFakeBinary(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "bj-lk") + if err := os.WriteFile(bin, []byte("двоичный файл"), 0o755); err != nil { + t.Fatal(err) + } + saved := lkDistBinary + t.Cleanup(func() { lkDistBinary = saved }) + return bin +} diff --git a/internal/lkgateway/lkdocs.go b/internal/lkgateway/lkdocs.go new file mode 100644 index 0000000..af0c548 --- /dev/null +++ b/internal/lkgateway/lkdocs.go @@ -0,0 +1,278 @@ +package lkgateway + +// lkdocs.go — корпоративные действия по бумагам клиента и документы депозитария: +// готовые выписки из учётной системы (U_OP_INF_EXTRACT) плюс заявки клиента на +// документ, которые кабинет кладёт в схему lk и показывает сотрудникам. +// +// Кабинет не создаёт документы в учётной системе: заявка — это поручение сотруднику, +// он готовит документ у себя и выкладывает клиенту. + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// LKCorpAction — корпоративное действие по бумаге клиента. +type LKCorpAction struct { + FixDate string `json:"fix_date,omitempty"` // дата фиксации + OperDate string `json:"oper_date,omitempty"` // дата операции + Kind string `json:"kind"` // расшифровка вида + Code string `json:"code,omitempty"` // код вида в учёте + ISIN string `json:"isin,omitempty"` + Security string `json:"security,omitempty"` + Note string `json:"note,omitempty"` +} + +// caKindRu — вид корпоративного действия по коду учёта (коды ISO 20022). +func caKindRu(code string) string { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "DVCA": + return "дивиденды" + case "INTR": + return "выплата купона" + case "REDM": + return "погашение" + case "MCAL": + return "досрочное погашение" + case "PCAL": + return "частичное погашение" + case "BPUT": + return "оферта: предъявление к выкупу" + case "TEND": + return "добровольное предложение о выкупе" + case "MEET", "XMET", "OMET": + return "собрание владельцев" + case "CONV": + return "конвертация" + case "SPLF", "SPLR": + return "дробление или консолидация" + case "MRGR": + return "реорганизация" + case "EXOF": + return "обмен бумаг" + case "DRCA": + return "выплата по депозитарной расписке" + } + return "корпоративное действие" +} + +// lkCorpActions — ближайшие корпоративные действия по бумагам клиента. +func lkCorpActions(ctx context.Context, s FancyMSSQLSettings, isins []string) ([]LKCorpAction, error) { + if strings.TrimSpace(s.Server) == "" || len(isins) == 0 { + return nil, nil + } + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + // Список ISIN подставляем параметрами: значения приходят из портфеля клиента. + names := make([]string, 0, len(isins)) + args := make([]any, 0, len(isins)) + for i, isin := range isins { + names = append(names, fmt.Sprintf("@p%d", i+1)) + args = append(args, sql.Named(fmt.Sprintf("p%d", i+1), strings.ToUpper(strings.TrimSpace(isin)))) + } + q := fmt.Sprintf(`SELECT TOP 40 ISNULL(a.KD_TYPE,''), ISNULL(v.ISIN,''), ISNULL(v.NAME,''), a.FIX_DATE, a.OPER_DATE, ISNULL(a.OFFER_NAME,'') + FROM U_CA_ACTS a JOIN OD_VALUES v ON v.ID = a.SHARE + WHERE UPPER(v.ISIN) IN (%s) + AND ISNULL(a.FIX_DATE, a.OPER_DATE) >= DATEADD(month, -1, GETDATE()) + ORDER BY ISNULL(a.FIX_DATE, a.OPER_DATE)`, strings.Join(names, ",")) + rows, err := conn.QueryContext(cctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LKCorpAction + for rows.Next() { + var code, isin, name, offer string + var fix, oper sql.NullTime + if rows.Scan(&code, &isin, &name, &fix, &oper, &offer) != nil { + continue + } + a := LKCorpAction{Code: strings.TrimSpace(code), Kind: caKindRu(code), ISIN: isin, Security: name, Note: strings.TrimSpace(offer)} + if fix.Valid && fix.Time.Year() < 9000 { + a.FixDate = fix.Time.Format("02.01.2006") + } + if oper.Valid && oper.Time.Year() > 2000 && oper.Time.Year() < 9000 { + a.OperDate = oper.Time.Format("02.01.2006") + } + out = append(out, a) + } + return out, rows.Err() +} + +// LKDocument — документ депозитария, готовый или заказанный клиентом. +type LKDocument struct { + ID int64 `json:"id,omitempty"` + Date string `json:"date"` // дата готовности или заказа + Title string `json:"title"` // что за документ + Period string `json:"period,omitempty"` // за какой период + Account string `json:"account,omitempty"` + State string `json:"state"` // готов | в работе | отклонён + Note string `json:"note,omitempty"` // комментарий депозитария + Ordered bool `json:"ordered"` // заявка клиента (а не готовый документ учёта) + Kind string `json:"kind,omitempty"` // вид заявки +} + +// lkDocSchema — таблица заявок на документы (создаётся вместе со схемой lk). +const lkDocSchema = ` +CREATE TABLE IF NOT EXISTS lk.doc_requests ( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now(), + contract_number text NOT NULL, + client_name text, + kind text NOT NULL, + period_from date, + period_to date, + account text, + comment text, + status text NOT NULL DEFAULT 'new', + staff_note text, + done_at timestamptz +); +CREATE INDEX IF NOT EXISTS ix_lk_doc_requests_contract ON lk.doc_requests (contract_number, created_at DESC); +` + +// lkDocKinds — виды документов, которые клиент может заказать в кабинете. +var lkDocKinds = []string{ + "Выписка по счёту депо", + "Справка об остатках на дату", + "Отчёт об операциях за период", + "Справка о доходах", +} + +// lkDocKindAllowed — вид заявки из списка (чужие значения не принимаем). +func lkDocKindAllowed(kind string) bool { + for _, k := range lkDocKinds { + if k == kind { + return true + } + } + return false +} + +// lkDocRequestAdd — заявка клиента на документ. +func lkDocRequestAdd(ctx context.Context, rc *RuntimeConfig, contract, clientName, kind, from, to, account, comment string) error { + if !lkDocKindAllowed(kind) { + return fmt.Errorf("неизвестный вид документа") + } + db, err := lkDB(rc) + if err != nil { + return err + } + var fromP, toP any + if t, err := time.Parse("2006-01-02", strings.TrimSpace(from)); err == nil { + fromP = t + } + if t, err := time.Parse("2006-01-02", strings.TrimSpace(to)); err == nil { + toP = t + } + _, err = db.ExecContext(ctx, `INSERT INTO lk.doc_requests (contract_number, client_name, kind, period_from, period_to, account, comment) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, contract, clientName, kind, fromP, toP, strings.TrimSpace(account), strings.TrimSpace(comment)) + return err +} + +// lkDocRequests — заявки клиента на документы. +func lkDocRequests(ctx context.Context, rc *RuntimeConfig, contract string) ([]LKDocument, error) { + db, err := lkDB(rc) + if err != nil { + return nil, err + } + rows, err := db.QueryContext(ctx, `SELECT id, created_at, kind, period_from, period_to, COALESCE(account,''), status, COALESCE(staff_note,'') + FROM lk.doc_requests WHERE contract_number=$1 ORDER BY created_at DESC LIMIT 50`, contract) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LKDocument + for rows.Next() { + var d LKDocument + var created time.Time + var from, to *time.Time + var status string + if err := rows.Scan(&d.ID, &created, &d.Kind, &from, &to, &d.Account, &status, &d.Note); err != nil { + continue + } + d.Ordered, d.Title, d.Date = true, d.Kind, created.Format("02.01.2006") + switch status { + case "done": + d.State = "готов" + case "rejected": + d.State = "отклонён" + default: + d.State = "в работе" + } + if from != nil && to != nil { + d.Period = from.Format("02.01.2006") + " — " + to.Format("02.01.2006") + } + out = append(out, d) + } + return out, rows.Err() +} + +// lkReadyDocuments — готовые выписки по счетам клиента из учётной системы. +func lkReadyDocuments(ctx context.Context, s FancyMSSQLSettings, contract string) ([]LKDocument, error) { + if strings.TrimSpace(s.Server) == "" { + return nil, nil + } + ids, _, accounts, err := depoClientIDs(ctx, s, contract) + if err != nil || len(ids) == 0 { + return nil, err + } + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + q := fmt.Sprintf(`SELECT TOP 30 d.R_DATE, ISNULL(d.NUM,''), e.B_DATE, e.E_DATE, e.DEPO, d.STATE, ISNULL(e.IS_TURNS,0) + FROM U_OP_INF_EXTRACT e JOIN OD_DOCS d ON d.ID = e.DOC + WHERE e.DEPO IN (%s) ORDER BY d.R_DATE DESC`, inClause(ids)) + rows, err := conn.QueryContext(cctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LKDocument + for rows.Next() { + var rd, bd, ed sql.NullTime + var num string + var acc int64 + var state, turns sql.NullInt64 + if rows.Scan(&rd, &num, &bd, &ed, &acc, &state, &turns) != nil { + continue + } + d := LKDocument{Title: "Выписка по счёту депо", Account: accounts[acc], State: docStateRu(state, sql.NullTime{}), Note: num} + if turns.Valid && turns.Int64 == 1 { + d.Title = "Выписка по счёту депо с оборотами" + } + if rd.Valid { + d.Date = rd.Time.Format("02.01.2006") + } + if bd.Valid && ed.Valid { + d.Period = bd.Time.Format("02.01.2006") + " — " + ed.Time.Format("02.01.2006") + } else if ed.Valid { + d.Period = "на " + ed.Time.Format("02.01.2006") + } + out = append(out, d) + } + return out, rows.Err() +} + +// notifyDocRequest — сообщить сотрудникам депозитария, что клиент заказал документ. +func notifyDocRequest(rc *RuntimeConfig, u LKUser, kind, from, to string) { + period := "" + if from != "" || to != "" { + period = fmt.Sprintf(" за период %s — %s", pick(from, "…"), pick(to, "…")) + } + go raiseSystemAlert(rc, "Кабинет: заказан документ", + fmt.Sprintf("Клиент %s (договор %s) заказал документ «%s»%s. Заявка видна в кабинете клиента и в журнале lk.doc_requests.", + pick(u.FullName, u.Login), u.Contract, kind, period)) +} diff --git a/internal/lkgateway/lkhistory.go b/internal/lkgateway/lkhistory.go index cc0ea39..612b0d4 100644 --- a/internal/lkgateway/lkhistory.go +++ b/internal/lkgateway/lkhistory.go @@ -56,7 +56,7 @@ func depoClientIDs(ctx context.Context, s FancyMSSQLSettings, contract string) ( defer cancel() rows, err := conn.QueryContext(cctx, `SELECT DISTINCT c.ID, ISNULL(c.NUMBER,''), ISNULL(c.FACE,0) FROM OD_CLIENTS c JOIN OD_CLIENTS_PORTS p ON p.CLIENT = c.ID - WHERE p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2`, contract, contract+" %") + WHERE p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2`, contract, likeEscape(contract)+" %") if err != nil { return nil, 0, nil, err } diff --git a/internal/lkgateway/lksecurity_test.go b/internal/lkgateway/lksecurity_test.go new file mode 100644 index 0000000..d63b655 --- /dev/null +++ b/internal/lkgateway/lksecurity_test.go @@ -0,0 +1,187 @@ +package lkgateway + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Тесты безопасности кабинета на стороне ядра: что лежит на диске, как живут сессии, +// как ядро отвечает на перебор и на запросы без ключа. + +func sessionsInTemp(t *testing.T) *lkSessions { + t.Helper() + saved := lkSessionsFile + lkSessionsFile = filepath.Join(t.TempDir(), "lk-sessions.json") + t.Cleanup(func() { lkSessionsFile = saved }) + return newLKSessions() +} + +func reqFrom(ip, agent string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/lk/portfolio", nil) + r.Header.Set("X-BJ-Client-IP", ip) + r.Header.Set("X-BJ-Client-Agent", agent) + return r +} + +// На диске нет ни токена, ни персональных данных клиента. +func TestLKSessionFileHasNoTokenOrPII(t *testing.T) { + s := sessionsInTemp(t) + tok, err := s.create(lkSession{UserID: 7, Login: "ivanov", Contract: "БР-100", CanTransfer: true}, reqFrom("203.0.113.5", "Firefox")) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(lkSessionsFile) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte(tok)) { + t.Fatal("в файле сессий лежит живой токен") + } + for _, leak := range []string{"full_name", "doc_series", "doc_number", "email", "phone", "last_name"} { + if bytes.Contains(raw, []byte(leak)) { + t.Errorf("в файле сессий есть поле с персональными данными: %s", leak) + } + } + if st, _ := os.Stat(lkSessionsFile); st.Mode().Perm() != 0o600 { + t.Errorf("права файла сессий %o, нужны 0600", st.Mode().Perm()) + } + // после перезапуска сессия поднимается по отпечатку + if _, ok := newLKSessions().get(tok, reqFrom("203.0.113.5", "Firefox")); !ok { + t.Fatal("сессия не пережила перезапуск") + } +} + +// Украденный токен с другого браузера или из другой сети не подходит и гасит сессию. +func TestLKSessionBoundToDevice(t *testing.T) { + s := sessionsInTemp(t) + s.bindNetwork = func() bool { return true } + tok, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox")) + if _, ok := s.get(tok, reqFrom("203.0.113.77", "Firefox")); !ok { + t.Fatal("тот же клиент в той же сети /24 должен проходить") + } + if _, ok := s.get(tok, reqFrom("203.0.113.5", "curl/8")); ok { + t.Fatal("токен принят с другого браузера") + } + if _, ok := s.get(tok, reqFrom("203.0.113.5", "Firefox")); ok { + t.Fatal("после попытки с чужого устройства сессия должна быть погашена") + } + tok2, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox")) + if _, ok := s.get(tok2, reqFrom("198.51.100.9", "Firefox")); ok { + t.Fatal("токен принят из другой сети при включённой привязке к сети") + } + // без привязки к сети мобильный клиент со сменившимся адресом остаётся в кабинете + s.bindNetwork = nil + tok3, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox")) + if _, ok := s.get(tok3, reqFrom("198.51.100.9", "Firefox")); !ok { + t.Fatal("по умолчанию смена адреса не должна выбрасывать клиента") + } +} + +// Сессия не живёт дольше абсолютного срока, даже если ей всё время пользуются. +func TestLKSessionAbsoluteLifetime(t *testing.T) { + s := sessionsInTemp(t) + r := reqFrom("203.0.113.5", "Firefox") + tok, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, r) + h := lkTokenHash(tok) + s.mu.Lock() + v := s.data[h] + v.CreatedAt = time.Now().Add(-lkSessionMaxAge - time.Minute) + v.ExpiresAt = time.Now().Add(time.Hour) // скользящий срок ещё не вышел + s.data[h] = v + s.mu.Unlock() + if _, ok := s.get(tok, r); ok { + t.Fatal("сессия пережила абсолютный срок") + } + if _, ok := s.get("", r); ok { + t.Fatal("пустой токен принят") + } +} + +// Сотрудник в кабинете клиента — только просмотр, что бы ни стояло в учётной записи. +func TestLKVirtualSessionIsReadOnly(t *testing.T) { + s := sessionsInTemp(t) + u, err := s.user(nil, nil, lkSession{Virtual: true, Contract: "БР-5", VirtualName: "Клиент", ImpersonatedBy: "manager", CanTransfer: true}) + if err != nil { + t.Fatal(err) + } + if u.CanTransfer || u.ImpersonatedBy != "manager" || u.Contract != "БР-5" { + t.Fatalf("сессия сотрудника: %+v", u) + } +} + +// API кабинета: без ключа, с чужим ключом, перебор пароля. +func TestLKAPIKeyAndBruteForce(t *testing.T) { + sessionsInTemp(t) + rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json") + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + registerLKAPI(mux, rc, nil) + call := func(method, path, key, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if key != "" { + r.Header.Set("X-BJ-Key", key) + } + r.Header.Set("X-BJ-Client-IP", "203.0.113.200") + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + return w + } + // ключа нет в настройках — API выключен целиком + if w := call("GET", "/api/lk/ping", "что-угодно", ""); w.Code != http.StatusServiceUnavailable { + t.Fatalf("API без ключа в настройках: %d", w.Code) + } + cfg := rc.Snapshot().LK + cfg.APIKey = "lk-правильный-ключ" + _ = rc.UpdateLK(cfg) + for _, path := range []string{"/api/lk/ping", "/api/lk/portfolio", "/api/lk/operations", "/api/lk/income", + "/api/lk/documents", "/api/lk/corpactions", "/api/lk/claims", "/api/lk/admin/clients"} { + if w := call("GET", path, "", ""); w.Code != http.StatusUnauthorized { + t.Errorf("%s без ключа: код %d", path, w.Code) + } + if w := call("GET", path, "lk-чужой-ключ", ""); w.Code != http.StatusUnauthorized { + t.Errorf("%s с чужим ключом: код %d", path, w.Code) + } + } + // с ключом, но без сессии клиента — данных нет + for _, path := range []string{"/api/lk/portfolio", "/api/lk/operations", "/api/lk/income", "/api/lk/documents", "/api/lk/admin/clients"} { + if w := call("GET", path, cfg.APIKey, ""); w.Code != http.StatusUnauthorized { + t.Errorf("%s без сессии: код %d", path, w.Code) + } + } + // перебор: после серии неудач ядро перестаёт проверять пароль + body, _ := json.Marshal(map[string]string{"login": "brute-test-" + t.Name(), "password": "неверный-пароль"}) + var blocked bool + for i := 0; i < 12; i++ { + if w := call("POST", "/api/lk/login", cfg.APIKey, string(body)); w.Code == http.StatusTooManyRequests { + blocked = true + break + } + } + if !blocked { + t.Fatal("перебор пароля не ограничен") + } +} + +// Требования к паролю и экранирование шаблона LIKE. +func TestLKPasswordPolicyAndLikeEscape(t *testing.T) { + for _, bad := range []string{"", "123456", "короткий1", "толькобуквыбезцифр", "1234567890123"} { + if lkCheckPassword(bad) == nil { + t.Errorf("слабый пароль принят: %q", bad) + } + } + if err := lkCheckPassword("Надёжный-пароль-2026"); err != nil { + t.Errorf("хороший пароль отклонён: %v", err) + } + if got := likeEscape("БР-1%_[x"); got != "БР-1[%][_][[]x" { + t.Errorf("likeEscape: %q", got) + } +} diff --git a/internal/lkgateway/lksession.go b/internal/lkgateway/lksession.go new file mode 100644 index 0000000..343f4a1 --- /dev/null +++ b/internal/lkgateway/lksession.go @@ -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) +} diff --git a/internal/lkgateway/lkstore.go b/internal/lkgateway/lkstore.go index ed6f16a..4b6b33a 100644 --- a/internal/lkgateway/lkstore.go +++ b/internal/lkgateway/lkstore.go @@ -99,6 +99,9 @@ var lkSchemaDDL = []string{ } // ensureLKSchema — схема lk и таблицы модуля ЛК (идемпотентно), при старте bj. +// lkSchemaExtra — таблицы, добавленные позже основной схемы. +func lkSchemaExtra() []string { return []string{lkDocSchema, lkAuditSchema} } + func ensureLKSchema(ctx context.Context, rc *RuntimeConfig) error { db, err := lkDB(rc) if err != nil { @@ -106,7 +109,7 @@ func ensureLKSchema(ctx context.Context, rc *RuntimeConfig) error { } cctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - for _, q := range lkSchemaDDL { + for _, q := range append(append([]string{}, lkSchemaDDL...), lkSchemaExtra()...) { if _, err := db.ExecContext(cctx, q); err != nil { return err } diff --git a/internal/lkgateway/lkunpriced_live_test.go b/internal/lkgateway/lkunpriced_live_test.go new file mode 100644 index 0000000..56ec854 --- /dev/null +++ b/internal/lkgateway/lkunpriced_live_test.go @@ -0,0 +1,98 @@ +//go:build live + +package lkgateway + +import ( + "context" + "sort" + "strings" + "testing" + "time" +) + +// Почему у части позиций нет цены: какие это бумаги и есть ли они вообще в экспорте QUIK. +func TestLiveUnpricedBreakdown(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + globalRC = rc + fs := rc.Snapshot().Fancy + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) + defer cancel() + contracts, err := depoListContracts(ctx, fs, "", 60) + if err != nil { + t.Fatal(err) + } + type info struct { + name string + balAcc int + n int + } + un := map[string]*info{} + for _, c := range contracts { + pos, err := depoClientPositions(ctx, fs, c.Contract) + if err != nil { + continue + } + var isins []string + for _, p := range pos { + if p.ISIN != "" { + isins = append(isins, p.ISIN) + } + } + px := lkPrices(ctx, rc, isins) + for _, p := range pos { + if p.ISIN == "" { + k := "(без ISIN) " + p.SecurityName + if un[k] == nil { + un[k] = &info{name: p.SecurityName, balAcc: p.BalAcc} + } + un[k].n++ + continue + } + if v, ok := px[strings.ToUpper(p.ISIN)]; ok && v.Price > 0 { + continue + } + if un[p.ISIN] == nil { + un[p.ISIN] = &info{name: p.SecurityName, balAcc: p.BalAcc} + } + un[p.ISIN].n++ + } + } + // есть ли такие бумаги в экспорте вообще (любой класс, даже без торгов) + db, err := nprDB(rc) + if err != nil { + t.Fatalf("база экспорта: %v", err) + } + var keys []string + for k := range un { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return un[keys[i]].n > un[keys[j]].n }) + var foreign, noISIN, inQuikNoTrade, absent int + for _, k := range keys { + i := un[k] + switch { + case strings.HasPrefix(k, "(без ISIN)"): + noISIN += i.n + t.Logf("без ISIN ×%d счёт %d %s", i.n, i.balAcc, i.name) + continue + case !strings.HasPrefix(strings.ToUpper(k), "RU"): + foreign += i.n + t.Logf("иностранная ×%d счёт %d %s %s", i.n, i.balAcc, k, i.name) + continue + } + var classes string + _ = db.QueryRow(ctx, `SELECT COALESCE(string_agg(DISTINCT "ClassCode", ','), '') FROM "Securities" WHERE upper("ISINCode") = $1`, strings.ToUpper(k)).Scan(&classes) + if classes == "" { + absent += i.n + t.Logf("нет в экспорте ×%d счёт %d %s %s", i.n, i.balAcc, k, i.name) + } else { + inQuikNoTrade += i.n + t.Logf("есть, без цены ×%d счёт %d %s %s классы: %s", i.n, i.balAcc, k, i.name, classes) + } + } + t.Logf("ИТОГО без цены: иностранные %d · без ISIN %d · российские вне экспорта QUIK %d · есть в экспорте, но без цены %d", + foreign, noISIN, absent, inQuikNoTrade) +} diff --git a/internal/lkgateway/lkusers.go b/internal/lkgateway/lkusers.go index 8be276c..520df5f 100644 --- a/internal/lkgateway/lkusers.go +++ b/internal/lkgateway/lkusers.go @@ -45,8 +45,8 @@ func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password str if strings.TrimSpace(u.Login) == "" || strings.TrimSpace(u.Contract) == "" { return errors.New("логин и номер договора обязательны") } - if len(password) < 6 { - return errors.New("пароль минимум 6 символов") + if err := lkCheckPassword(password); err != nil { + return err } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -68,8 +68,8 @@ func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password str // lkSetPassword меняет пароль пользователя ЛК. func lkSetPassword(ctx context.Context, rc *RuntimeConfig, login, password string) error { - if len(password) < 6 { - return errors.New("пароль минимум 6 символов") + if err := lkCheckPassword(password); err != nil { + return err } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -109,6 +109,9 @@ func lkAuth(ctx context.Context, rc *RuntimeConfig, login, password string) (*LK &u.FullName, &u.Email, &u.Phone, &u.Active, &u.LastName, &u.FirstName, &u.MiddleName, &u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil { + // Логина нет. Всё равно считаем bcrypt по фиктивному хешу: иначе ответ + // приходит заметно быстрее и по времени можно перебрать существующие логины. + _ = bcrypt.CompareHashAndPassword([]byte(lkDummyHash), []byte(password)) return nil, errLKAuth // не раскрываем, что логина нет } if !u.Active { @@ -146,3 +149,51 @@ func lkListUsers(ctx context.Context, rc *RuntimeConfig) ([]LKUser, error) { } return out, rows.Err() } + +// lkDummyHash — заведомо не подходящий bcrypt-хеш: по нему считаем проверку, когда +// логина нет, чтобы ответ занимал столько же времени, сколько при существующем логине. +const lkDummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" + +// lkCheckPassword — требования к паролю кабинета: в кабинете видны паспортные данные +// и остатки на счетах депо, поэтому шести символов мало. +func lkCheckPassword(password string) error { + if len([]rune(password)) < 10 { + return errors.New("пароль минимум 10 символов") + } + var hasLetter, hasOther bool + for _, r := range password { + switch { + case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= 'а' && r <= 'я') || (r >= 'А' && r <= 'Я'): + hasLetter = true + default: + hasOther = true + } + } + if !hasLetter || !hasOther { + return errors.New("пароль должен содержать буквы и хотя бы одну цифру или знак") + } + return nil +} + +// lkUserByID — карточка клиента по идентификатору. Нужна сессиям: на диске хранится +// только ссылка на учётную запись, а сами данные читаются отсюда на каждый запрос. +func lkUserByID(ctx context.Context, rc *RuntimeConfig, id int64) (*LKUser, error) { + db, err := lkDB(rc) + if err != nil { + return nil, err + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + var u LKUser + if err := db.QueryRowContext(cctx, `SELECT id, login, contract_number, + COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active, + COALESCE(last_name,''), COALESCE(first_name,''), COALESCE(middle_name,''), + COALESCE(doc_type,''), COALESCE(doc_series,''), COALESCE(doc_number,''), is_admin, can_transfer + FROM lk.users WHERE id=$1`, id).Scan(&u.ID, &u.Login, &u.Contract, + &u.FullName, &u.Email, &u.Phone, &u.Active, + &u.LastName, &u.FirstName, &u.MiddleName, + &u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil { + return nil, errors.New("учётная запись не найдена") + } + return &u, nil +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 8689814..bb8a67c 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -247,6 +247,17 @@ type LKSettings struct { // Модуль ставится на веб-сервер клиента и обращается к bj-server по этому // ключу (заголовок X-BJ-Key). Пустой ключ = модульный API выключен. APIKey string `json:"lk_api_key"` + // CoreURL — адрес ядра bj, по которому кабинет к нему обращается (кабинет стоит + // на отдельном веб-сервере). Пусто — подставляем адрес текущего сервера. + CoreURL string `json:"lk_core_url,omitempty"` + // AgentMode — режим ожидания: у ядра нет адреса, доступного из интернета, + // поэтому не кабинет обращается к ядру, а ядро само подключается к кабинету. + AgentMode bool `json:"lk_agent_mode,omitempty"` + // CabinetURL — адрес кабинета на хостинге, куда подключается ядро в режиме ожидания. + CabinetURL string `json:"lk_cabinet_url,omitempty"` + // BindNetwork — привязывать сессию клиента не только к браузеру, но и к сети /24. + // Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором. + BindNetwork bool `json:"lk_bind_network,omitempty"` // Интеграция с ЛК (потребление back_office API платформы): два окружения. ActiveEnv string `json:"lk_active_env"` // "test" | "prod" diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 29192ff..777b135 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -292,6 +292,10 @@ func (s *Server) Run(ctx context.Context) error { // Отказы шлюза по заявкам ЛК: ждём ответ Fancy (DEPO) и только тогда ставим «Отклонена» в ЛК. go superviseLoop(ctx, "lk-flow", s.rc, s.lkFlowLoop) + // Кабинет клиента в режиме ожидания: ядро само подключается к кабинету на + // хостинге и обслуживает запросы клиентов через исходящее соединение. + go superviseLoop(ctx, "lk-agent", s.rc, s.lkAgentLoop) + // Поллер заявок ЛК ЕСИА Финанс: раз в минуту забирает новые depo_m2m и // отправляет в НРД (или имитатору). Интервал 1 мин — время ответов регламентировано. go superviseLoop(ctx, "poll-lk", s.rc, s.pollLKClaims) diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 5b8a763..2d5b2d2 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -86,6 +86,8 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/nsd", h.saveNSD) mux.HandleFunc("/admin/setup/org", h.saveOrg) mux.HandleFunc("/admin/setup/lk", h.saveLK) + mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist) + mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload) mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration) mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn) mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate) @@ -311,6 +313,8 @@ type SetupData struct { DepoJob DepoJobView DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO QuikExport QuikExportView // модуль «Экспорт биржевой информации» + LKAgent LKAgentStatus // канал кабинета клиента (режим ожидания) + LKKeyMask string // ключ кабинета в маскированном виде Versions SystemVersions // версии компонентов bj и внешних систем Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак) Features []LicenseFeatureView // признаки лицензии для карточки @@ -375,6 +379,8 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl DepoJob: depoJobView(s.Fancy), DepoTables: depoTablesView(s.Fancy), QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"), + LKAgent: lkAgentStatus(s), + LKKeyMask: maskSecret(s.LK.APIKey), Versions: systemVersions(h.rc), Modules: modulesAllowed(h.rc), Features: licenseFeatureViews(h.rc), diff --git a/internal/lkgateway/web/templates/admin_help_lk.html b/internal/lkgateway/web/templates/admin_help_lk.html index 3dd2d8d..5dacac9 100644 --- a/internal/lkgateway/web/templates/admin_help_lk.html +++ b/internal/lkgateway/web/templates/admin_help_lk.html @@ -1,6 +1,39 @@ {{define "content"}}

← все инструкции

+
+

Как разместить кабинет клиента на хостинге

+

Речь о нашем модуле bj-lk — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.

+ +

Шаг 1. Ключ и архив

+

Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. Внутри архива боевой ключ: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.

+ +

Шаг 2. Установка на сервере кабинета

+
tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
+cd bj-lk
+sudo ./install.sh
+curl -s http://127.0.0.1:8099/health   # {"ok":true}
+

Установщик заводит системного пользователя bjlk, кладёт программу в /opt/bj-lk, настройки — в /etc/bj-lk/lk.env (права 0600) и включает службу bj-lk. Сам кабинет слушает только 127.0.0.1:8099.

+ +

Шаг 3. Публикация наружу

+

Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.

+ +

Шаг 4. Связь с ядром — два способа

+

Кабинет обращается к ядру (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.

+

Ядро подключается к кабинету. Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.

+

Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит BJ_MODE=agent, а BJ_URL не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.

+

В обе стороны канал защищён тем же ключом модуля: запрос без ключа кабинет не отдаёт, ответ без ключа не принимает.

+ +

Что с данными

+ +
+

API личного кабинета (ESIA Finance V1)

Bridge-and-Join-s реализует контракт ESIA Finance V1 на стороне back-office. ЛК клиента отправляет нам заявку, мы возвращаем подтверждение и потом колбэк со статусом. Полная спецификация: docs/lk-contract/v1/openapi.yaml.

diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index c9b36fa..5195033 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -603,6 +603,64 @@ function hbBind(inp,box,list,onPick){

Интеграция с платформой ЛК ЕСИА Финанс: адрес для клиента, приём заявок M2M, возврат статуса и подключение к API (тест/бой). НРД настраивается отдельно — в разделе «НРД».

{{template "verline" (.Versions.Get "bj-lk")}} +
+

Кабинет клиента на своём сервере

+

Кабинет (bj-lk) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: 1) создайте ключ доступа, 2) выберите способ связи, 3) скачайте архив и распакуйте его на сервере кабинета, 4) выполните там sudo ./install.sh, 5) опубликуйте кабинет через nginx с сертификатом (пример в README архива).

+ +
+ + +
+ +
+ {{if .LKKeyMask}}{{.LKKeyMask}}{{else}}не создан — кабинет не сможет обращаться к ядру{{end}} + + {{if .Settings.LK.APIKey}}{{end}} +
+ Ключ показан не полностью — целиком он попадает только в архив, в файл /etc/bj-lk/lk.env на сервере кабинета. При смене ключа скачайте архив заново или впишите новый ключ в этот файл и перезапустите службу кабинета. +
+ +
+ + + +
+ +
+ + + Куда кабинет обращается за данными. Если кабинет и ядро в одной сети — внутренний адрес; если в разных — тот, что открыт межсетевым экраном. Только https: по этому каналу идут ключ и сессии клиентов. +
+ +
+ + + Имя, по которому ядро дозванивается до кабинета. Оно же — адрес, по которому входят клиенты. +
+ + + +
+ + {{if .Settings.LK.APIKey}}Скачать дистрибутив кабинета{{end}} + Инструкция по размещению +
+
+ + {{if .Settings.LK.AgentMode}} +
+ Связь с кабинетом + {{if .LKAgent.Connected}}ядро подключено к {{.LKAgent.Cabinet}}{{else}}ядро не дозвонилось до кабинета{{end}} +
+ {{if .LKAgent.LastOK}}Последний обмен: {{.LKAgent.LastOK}}. {{end}}Обслужено запросов: {{.LKAgent.Served}}. + {{if .LKAgent.LastErr}}
Ошибка: {{.LKAgent.LastErr}}{{end}} +
+
+ {{end}} + +

В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.

+
+

Личный кабинет — интеграция

Адрес ЛК (стенд/бой), куда клиент подаёт заявку M2M. Заявка приходит к нам по контракту POST /api/v1/back_office/claims/, а мы возвращаем статус через Callback URL.

diff --git a/modules/lk/agent.go b/modules/lk/agent.go new file mode 100644 index 0000000..cfe6aa0 --- /dev/null +++ b/modules/lk/agent.go @@ -0,0 +1,199 @@ +package lk + +// agent.go — режим ожидания для случая, когда у ядра нет адреса, доступного кабинету. +// +// Обычно кабинет сам ходит в ядро: BJ_URL указывает на bj-server. Но ядро часто стоит во +// внутренней сети депозитария без публичного имени, а кабинет — на хостинге. Тогда связь +// разворачивается наоборот: кабинет держит очередь запросов, а ядро само подключается к +// кабинету, забирает запрос, выполняет его у себя и возвращает ответ. Наружу из сети +// депозитария идёт только исходящее соединение. +// +// клиент → страница кабинета → очередь → (ядро забирает) → ответ → страница +// +// Оба обращения ядра защищены тем же ключом модуля, что и прямой режим. + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "sync" + "time" +) + +// agentTask — один запрос кабинета к ядру, ожидающий ответа. +type agentTask struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Body []byte `json:"body,omitempty"` + Session string `json:"session,omitempty"` + // Адрес и браузер посетителя: ядро привязывает к ним сессию и считает по адресу + // попытки входа. В прямом режиме они идут заголовками, здесь — полями запроса. + ClientIP string `json:"client_ip,omitempty"` + ClientAgent string `json:"client_agent,omitempty"` + + done chan agentResult +} + +// agentResult — ответ ядра. +type agentResult struct { + Status int `json:"status"` + Body []byte `json:"body,omitempty"` +} + +// agentQueue — очередь запросов к ядру и ожидающие ответа. +type agentQueue struct { + mu sync.Mutex + queue []*agentTask + waiting map[string]*agentTask + signal chan struct{} + seq uint64 +} + +func newAgentQueue() *agentQueue { + return &agentQueue{waiting: map[string]*agentTask{}, signal: make(chan struct{}, 1)} +} + +// push — поставить запрос в очередь и ждать ответа ядра. +func (q *agentQueue) push(ctx context.Context, method, path string, body []byte, session string, wait time.Duration) (agentResult, error) { + q.mu.Lock() + q.seq++ + t := &agentTask{ID: idFromSeq(q.seq), Method: method, Path: path, Body: body, Session: session, done: make(chan agentResult, 1)} + if v, ok := ctx.Value(visitorKey{}).(visitor); ok { + t.ClientIP, t.ClientAgent = v.ip, v.agent + } + q.queue = append(q.queue, t) + q.waiting[t.ID] = t + q.mu.Unlock() + select { + case q.signal <- struct{}{}: + default: + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case res := <-t.done: + return res, nil + case <-ctx.Done(): + q.forget(t.ID) + return agentResult{}, ctx.Err() + case <-timer.C: + q.forget(t.ID) + return agentResult{}, errors.New("ядро не ответило: проверьте, что bj-server подключён к кабинету") + } +} + +func (q *agentQueue) forget(id string) { + q.mu.Lock() + delete(q.waiting, id) + q.mu.Unlock() +} + +// next — выдать ядру очередной запрос; ждёт до wait, если очередь пуста. +func (q *agentQueue) next(ctx context.Context, wait time.Duration) *agentTask { + for { + q.mu.Lock() + if len(q.queue) > 0 { + t := q.queue[0] + q.queue = q.queue[1:] + q.mu.Unlock() + return t + } + q.mu.Unlock() + timer := time.NewTimer(wait) + select { + case <-q.signal: + timer.Stop() + case <-timer.C: + return nil + case <-ctx.Done(): + timer.Stop() + return nil + } + } +} + +// reply — вернуть ответ ядра ожидающему запросу. +func (q *agentQueue) reply(id string, res agentResult) bool { + q.mu.Lock() + t, ok := q.waiting[id] + delete(q.waiting, id) + q.mu.Unlock() + if !ok { + return false + } + select { + case t.done <- res: + default: + } + return true +} + +func idFromSeq(n uint64) string { + const alphabet = "0123456789abcdef" + buf := make([]byte, 0, 16) + for i := 0; i < 16; i++ { + buf = append(buf, alphabet[(n>>(uint(i)*4))&0xf]) + } + return string(buf) +} + +// registerAgent — точки, куда подключается ядро (только в режиме ожидания). +func (s *Server) registerAgent(mux *http.ServeMux) { + keyOK := func(w http.ResponseWriter, r *http.Request) bool { + if !secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey) { + http.Error(w, "неверный ключ модуля", http.StatusForbidden) + return false + } + return true + } + // Ядро забирает очередной запрос; если запросов нет — ответ 204 через несколько секунд. + mux.HandleFunc("/agent/tasks", func(w http.ResponseWriter, r *http.Request) { + if !keyOK(w, r) { + return + } + wait := 25 * time.Second + t := s.agent.next(r.Context(), wait) + if t == nil { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": t.ID, "method": t.Method, "path": t.Path, "session": t.Session, + "client_ip": t.ClientIP, "client_agent": t.ClientAgent, + "body": base64.StdEncoding.EncodeToString(t.Body), + }) + }) + // Ядро возвращает ответ на запрос. + mux.HandleFunc("/agent/result", func(w http.ResponseWriter, r *http.Request) { + if !keyOK(w, r) { + return + } + var in struct { + ID string `json:"id"` + Status int `json:"status"` + Body string `json:"body"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<20)).Decode(&in) != nil || in.ID == "" { + http.Error(w, "не разобрали ответ", http.StatusBadRequest) + return + } + body, _ := base64.StdEncoding.DecodeString(in.Body) + if !s.agent.reply(in.ID, agentResult{Status: in.Status, Body: body}) { + w.WriteHeader(http.StatusGone) // запрос уже не ждут: клиент ушёл или истёк срок + return + } + w.WriteHeader(http.StatusNoContent) + }) + // Состояние связи для страницы «не настроено» и проверки администратором. + mux.HandleFunc("/agent/state", func(w http.ResponseWriter, r *http.Request) { + if !keyOK(w, r) { + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "mode": "agent"}) + }) +} diff --git a/modules/lk/assets.go b/modules/lk/assets.go new file mode 100644 index 0000000..c432abe --- /dev/null +++ b/modules/lk/assets.go @@ -0,0 +1,39 @@ +package lk + +// assets.go — шрифты кабинета внутри программы. +// +// Кабинет показывает персональные данные клиентов, поэтому страница не должна тянуть +// ничего со сторонних сайтов: иначе адреса клиентов и заголовок Referer уходят третьей +// стороне, строгие правила безопасности (CSP) становятся невозможны, а в закрытом +// сегменте страница ещё и ждёт недоступную сеть. Шрифты вшиты в файл программы. + +import ( + "embed" + "net/http" + "strings" + "time" +) + +//go:embed assets +var assetsFS embed.FS + +// assetsHandler — отдаёт /assets/*: только вшитые файлы, ничего с диска. +func assetsHandler() http.Handler { + start := time.Now() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/") + data, err := assetsFS.ReadFile(name) + if err != nil { + http.NotFound(w, r) + return + } + switch { + case strings.HasSuffix(name, ".css"): + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case strings.HasSuffix(name, ".woff2"): + w.Header().Set("Content-Type", "font/woff2") + } + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + http.ServeContent(w, r, name, start, strings.NewReader(string(data))) + }) +} diff --git a/modules/lk/assets/fonts.css b/modules/lk/assets/fonts.css new file mode 100644 index 0000000..11a5b39 --- /dev/null +++ b/modules/lk/assets/fonts.css @@ -0,0 +1,4 @@ +@font-face{font-family:'Golos Text';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/golos-cyrillic.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116} +@font-face{font-family:'Golos Text';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/golos-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/mono-cyrillic.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116} +@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/mono-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} diff --git a/modules/lk/assets/golos-cyrillic.woff2 b/modules/lk/assets/golos-cyrillic.woff2 new file mode 100644 index 0000000..f8fef0c Binary files /dev/null and b/modules/lk/assets/golos-cyrillic.woff2 differ diff --git a/modules/lk/assets/golos-latin.woff2 b/modules/lk/assets/golos-latin.woff2 new file mode 100644 index 0000000..2d554c8 Binary files /dev/null and b/modules/lk/assets/golos-latin.woff2 differ diff --git a/modules/lk/assets/mono-cyrillic.woff2 b/modules/lk/assets/mono-cyrillic.woff2 new file mode 100644 index 0000000..de39acb Binary files /dev/null and b/modules/lk/assets/mono-cyrillic.woff2 differ diff --git a/modules/lk/assets/mono-latin.woff2 b/modules/lk/assets/mono-latin.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/modules/lk/assets/mono-latin.woff2 differ diff --git a/modules/lk/lk.go b/modules/lk/lk.go index c2179bc..bb7e221 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -27,16 +27,20 @@ const cookieTTL = 12 * 3600 // Config — подключение к bj-server и оформление. type Config struct { - BJURL string // адрес bj-server (https://...) + BJURL string // адрес bj-server (https://...) — в прямом режиме BJKey string // ключ доступа модуля (выдаётся в настройках bj-server) Title string // название кабинета в шапке + // Agent — режим ожидания: у ядра нет адреса, доступного кабинету, поэтому + // не кабинет ходит в ядро, а ядро само подключается сюда за запросами. + Agent bool } // Server — кабинет. type Server struct { - cfg Config - bj *bjClient - tpl *template.Template + cfg Config + bj *bjClient + tpl *template.Template + agent *agentQueue } // New — кабинет с разобранными шаблонами. @@ -97,27 +101,52 @@ func New(cfg Config) (*Server, error) { if err != nil { return nil, err } - return &Server{cfg: cfg, bj: &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}, tpl: tpl}, nil + srv := &Server{cfg: cfg, tpl: tpl} + if cfg.Agent { + srv.agent = newAgentQueue() + } + srv.bj = &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}, agent: srv.agent} + return srv, nil } -// Configured — заданы адрес bj-server и ключ доступа. -func (s *Server) Configured() bool { return s.cfg.BJURL != "" && s.cfg.BJKey != "" } +// Configured — кабинету есть с чем работать: ключ модуля и, в прямом режиме, адрес ядра. +func (s *Server) Configured() bool { + if s.cfg.BJKey == "" { + return false + } + if s.cfg.Agent { + return true // адрес ядра не нужен: ядро подключается само + } + return s.cfg.BJURL != "" +} + +// InsecureCore — ядро задано по открытому http не на этой же машине: ключ модуля и +// токены сессий клиентов пойдут по сети в открытом виде. +func (s *Server) InsecureCore() bool { + if s.cfg.Agent || s.cfg.BJURL == "" || strings.HasPrefix(s.cfg.BJURL, "https://") { + return false + } + rest := strings.TrimPrefix(s.cfg.BJURL, "http://") + return !strings.HasPrefix(rest, "127.0.0.1") && !strings.HasPrefix(rest, "localhost") +} // ---- клиент к bj-server ---- type bjClient struct { - cfg Config - hc *http.Client + cfg Config + hc *http.Client + agent *agentQueue // задан в режиме ожидания } func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error { - var rdr *bytes.Reader + var raw []byte if body != nil { - raw, _ := json.Marshal(body) - rdr = bytes.NewReader(raw) - } else { - rdr = bytes.NewReader(nil) + raw, _ = json.Marshal(body) } + if b.agent != nil { + return b.callAgent(ctx, method, path, session, raw, out) + } + rdr := bytes.NewReader(raw) req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr) if err != nil { return err @@ -126,6 +155,12 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body if session != "" { req.Header.Set("X-BJ-Session", session) } + if v, ok := ctx.Value(visitorKey{}).(visitor); ok { + // Ядро привязывает сессию к браузеру и сети клиента, а вход считает по адресу: + // кабинет — единственный, кто их знает, поэтому передаёт вместе с ключом. + req.Header.Set("X-BJ-Client-IP", v.ip) + req.Header.Set("X-BJ-Client-Agent", v.agent) + } if body != nil { req.Header.Set("Content-Type", "application/json") } @@ -148,6 +183,37 @@ func (b *bjClient) call(ctx context.Context, method, path, session string, body return nil } +// callAgent — тот же запрос, но через очередь: его заберёт ядро, когда подключится. +func (b *bjClient) callAgent(ctx context.Context, method, path, session string, raw []byte, out any) error { + res, err := b.agent.push(ctx, method, path, raw, session, 40*time.Second) + if err != nil { + return err + } + if res.Status >= 400 { + var e struct{ Error string } + _ = json.Unmarshal(res.Body, &e) + if e.Error == "" { + e.Error = fmt.Sprintf("ошибка сервера (%d)", res.Status) + } + return fmt.Errorf("%s", e.Error) + } + if out != nil && len(res.Body) > 0 { + return json.Unmarshal(res.Body, out) + } + return nil +} + +// visitor — кто пришёл в кабинет: адрес и браузер. Передаём ядру, чтобы сессия +// была привязана к устройству, а перебор пароля считался по адресу посетителя. +type visitor struct{ ip, agent string } + +type visitorKey struct{} + +// reqCtx — контекст запроса вместе с данными посетителя. +func reqCtx(r *http.Request) context.Context { + return context.WithValue(r.Context(), visitorKey{}, visitor{ip: clientIP(r), agent: r.UserAgent()}) +} + // ---- модели ответов ---- type position struct { @@ -249,6 +315,29 @@ type incomeRow struct { Account string `json:"account"` } +// corpAction — корпоративное действие по бумаге клиента. +type corpAction struct { + FixDate string `json:"fix_date"` + OperDate string `json:"oper_date"` + Kind string `json:"kind"` + ISIN string `json:"isin"` + Security string `json:"security"` + Note string `json:"note"` +} + +// documentRow — документ депозитария или заявка клиента. +type documentRow struct { + ID int64 `json:"id"` + Date string `json:"date"` + Title string `json:"title"` + Period string `json:"period"` + Account string `json:"account"` + State string `json:"state"` + Note string `json:"note"` + Ordered bool `json:"ordered"` + Kind string `json:"kind"` +} + // securityCard — карточка бумаги из ядра. type securityCard struct { ISIN string `json:"isin"` @@ -281,7 +370,7 @@ type securityCard struct { // portfolio — реестр клиента из ядра. func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) { var out portfolioResp - err := s.bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out) + err := s.bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out) return out, err } @@ -304,23 +393,59 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat if c, err := r.Cookie("bj_lk_as"); err == nil && c.Value != "" { data["AsClient"] = c.Value } + data["CSRF"] = csrfToken(w, r) + if _, ok := data["Flash"]; !ok { + if f := takeFlash(w, r); f != "" { + data["Flash"] = f + } + } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tpl.ExecuteTemplate(w, name, data); err != nil { log.Printf("шаблон %s: %v", name, err) } } -func setFlag(w http.ResponseWriter, name, val string) { +// setFlag — служебная кука кабинета. HttpOnly обязателен: в bj_lk_admin_sess лежит +// живой токен сотрудника, и скрипт на странице не должен уметь его прочитать. +func setFlag(w http.ResponseWriter, r *http.Request, name, val string) { age := cookieTTL if val == "" { age = -1 } - http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) } -func setSession(w http.ResponseWriter, val string) { +func setSession(w http.ResponseWriter, r *http.Request, val string) { http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: val, Path: "/", - HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL}) + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode, MaxAge: cookieTTL}) +} + +// flash — сообщение посетителю до следующей страницы. Раньше текст ехал в адресе +// (?m=…), то есть попадал в журнал nginx и в историю браузера вместе с ФИО и +// текстами ошибок; теперь он живёт одну страницу в куке. +func setFlashCookie(w http.ResponseWriter, r *http.Request, msg string) { + http.SetCookie(w, &http.Cookie{Name: "bj_lk_flash", Value: url.QueryEscape(msg), Path: "/", MaxAge: 60, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) +} + +func takeFlash(w http.ResponseWriter, r *http.Request) string { + c, err := r.Cookie("bj_lk_flash") + if err != nil || c.Value == "" { + return "" + } + http.SetCookie(w, &http.Cookie{Name: "bj_lk_flash", Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) + msg, _ := url.QueryUnescape(c.Value) + return msg +} + +// clearAll — погасить все куки кабинета (выход, сбой сессии). +func clearAll(w http.ResponseWriter, r *http.Request) { + for _, n := range []string{"bj_lk", "bj_lk_admin", "bj_lk_as", "bj_lk_admin_sess", "bj_lk_flash", csrfCookie} { + http.SetCookie(w, &http.Cookie{Name: n, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) + } } // sessionW — как session, но заодно продлевает куку, чтобы у активного @@ -330,7 +455,7 @@ func sessionW(w http.ResponseWriter, r *http.Request) string { if err != nil || c.Value == "" { return "" } - setSession(w, c.Value) + setSession(w, r, c.Value) return c.Value } @@ -345,7 +470,7 @@ func session(r *http.Request) string { // Handler — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки. func (s *Server) Handler() http.Handler { if !s.Configured() { - return http.HandlerFunc(s.notConfigured) + return harden(http.HandlerFunc(s.notConfigured)) } bj := s.bj mux := http.NewServeMux() @@ -353,36 +478,46 @@ func (s *Server) Handler() http.Handler { // Вход mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { - s.render(w, r, "login", map[string]any{"Flash": r.URL.Query().Get("e")}) + s.render(w, r, "login", nil) + return + } + if !csrfOK(r) { + s.render(w, r, "login", map[string]any{"Error": "Форма устарела, попробуйте войти ещё раз."}) return } - _ = r.ParseForm() var out struct { Session string `json:"session"` FullName string `json:"full_name"` Contract string `json:"contract"` IsAdmin bool `json:"is_admin"` } - err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{ + err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/login", "", map[string]string{ "login": r.FormValue("login"), "password": r.FormValue("password"), }, &out) if err != nil { - s.render(w, r, "login", map[string]any{"Flash": err.Error()}) + // Текст ошибки приходит из ядра: «неверный логин или пароль» либо + // «слишком много попыток входа» — показываем его как есть. + s.render(w, r, "login", map[string]any{"Error": err.Error()}) return } - setSession(w, out.Session) + setSession(w, r, out.Session) if out.IsAdmin { - setFlag(w, "bj_lk_admin", "1") + setFlag(w, r, "bj_lk_admin", "1") http.Redirect(w, r, "/clients", http.StatusSeeOther) return } - setFlag(w, "bj_lk_admin", "") + setFlag(w, r, "bj_lk_admin", "") http.Redirect(w, r, "/", http.StatusSeeOther) }) + // Выход гасит и клиентскую сессию, и сохранённую админскую: иначе после + // «выхода» в браузере оставался действующий токен сотрудника. mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { - _ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil) - http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1}) + _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil) + if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { + _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", c.Value, nil, nil) + } + clearAll(w, r) http.Redirect(w, r, "/login", http.StatusSeeOther) }) @@ -406,7 +541,7 @@ func (s *Server) Handler() http.Handler { var cl struct { Claims []claimRow `json:"claims"` } - _ = bj.call(r.Context(), http.MethodGet, "/api/lk/claims", sess, nil, &cl) + _ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &cl) active, _ := claimsView(cl.Claims) pos := pf.Positions if len(pos) > 5 { @@ -418,7 +553,7 @@ func (s *Server) Handler() http.Handler { } s.render(w, r, "board", map[string]any{ "Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName, - "Positions": pos, "Claims": claims, "Flash": r.URL.Query().Get("m"), + "Positions": pos, "Claims": claims, "Total": total, "Free": free, "Blocked": blocked, "Active": active, "Value": pf.TotalValue, "Priced": pf.Priced, }) @@ -443,8 +578,8 @@ func (s *Server) Handler() http.Handler { } s.render(w, r, "portfolio", map[string]any{ "Nav": "p", "Contract": pf.Contract, "FullName": pf.FullName, - "Positions": pf.Positions, "Flash": r.URL.Query().Get("m"), - "Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated, + "Positions": pf.Positions, + "Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated, "Total": total, "Free": free, "Blocked": blocked, "Value": pf.TotalValue, "Priced": pf.Priced, }) @@ -465,7 +600,7 @@ func (s *Server) Handler() http.Handler { var out struct { Security securityCard `json:"security"` } - if err := bj.call(r.Context(), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil { s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin}) return } @@ -490,7 +625,7 @@ func (s *Server) Handler() http.Handler { Operations []operationRow `json:"operations"` } path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to) - if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to}) return } @@ -527,12 +662,62 @@ func (s *Server) Handler() http.Handler { Tax float64 `json:"tax"` } path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to) - if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to}) return } + var ca struct { + Actions []corpAction `json:"actions"` + } + _ = bj.call(reqCtx(r), http.MethodGet, "/api/lk/corpactions", sess, nil, &ca) + acts := ca.Actions + if len(acts) > 8 { + acts = acts[:8] + } s.render(w, r, "income", map[string]any{"Nav": "i", "Income": out.Income, "From": from, "To": to, - "Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax}) + "Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax, "Actions": acts}) + }) + + // Документы: готовые выписки, заявки клиента и заказ нового документа + mux.HandleFunc("/documents", func(w http.ResponseWriter, r *http.Request) { + sess := sessionW(w, r) + if sess == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if r.Method == http.MethodPost { + _ = r.ParseForm() + var res struct { + Message string `json:"message"` + } + err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{ + "kind": r.FormValue("kind"), + "from": r.FormValue("from"), + "to": r.FormValue("to"), + "account": r.FormValue("account"), + "comment": strings.TrimSpace(r.FormValue("comment")), + }, &res) + if err != nil { + setFlashCookie(w, r, err.Error()) + http.Redirect(w, r, "/documents", http.StatusSeeOther) + return + } + setFlashCookie(w, r, res.Message) + http.Redirect(w, r, "/documents", http.StatusSeeOther) + return + } + var out struct { + Ready []documentRow `json:"ready"` + Requests []documentRow `json:"requests"` + Kinds []string `json:"kinds"` + } + if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil { + s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()}) + return + } + s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready, "Requests": out.Requests, + "Kinds": out.Kinds, + "From": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), "To": time.Now().Format("2006-01-02")}) }) // Профиль: договор, счета депо, доступ @@ -576,7 +761,7 @@ func (s *Server) Handler() http.Handler { Positions []position `json:"positions"` FullName string `json:"full_name"` } - if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil { s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")}) return } @@ -605,7 +790,7 @@ func (s *Server) Handler() http.Handler { Message string `json:"message"` GUID string `json:"guid"` } - err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", sess, map[string]any{ + err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/transfer", sess, map[string]any{ "receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")), "receiving_name": strings.TrimSpace(r.FormValue("receiving_name")), "account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")), @@ -616,7 +801,8 @@ func (s *Server) Handler() http.Handler { s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")}) return } - http.Redirect(w, r, "/orders?m="+url.QueryEscape(res.Message), http.StatusSeeOther) + setFlashCookie(w, r, res.Message) + http.Redirect(w, r, "/orders", http.StatusSeeOther) }) // Поручения — журнал всех видов @@ -632,12 +818,12 @@ func (s *Server) Handler() http.Handler { var out struct { Claims []claimRow `json:"claims"` } - if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil { s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()}) return } active, done := claimsView(out.Claims) - s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m"), + s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Total": len(out.Claims), "Active": active, "Done": done}) }) @@ -669,13 +855,12 @@ func (s *Server) Handler() http.Handler { if search != "" { path += "?search=" + url.QueryEscape(search) } - if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil { + if err := bj.call(reqCtx(r), http.MethodGet, path, sess, nil, &out); err != nil { s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()}) return } s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients, - "Depo": out.Depo, "DepoError": out.DepoError, "Search": search, - "Flash": r.URL.Query().Get("m")}) + "Depo": out.Depo, "DepoError": out.DepoError, "Search": search}) }) // Администратор: войти в кабинет клиента @@ -685,53 +870,74 @@ func (s *Server) Handler() http.Handler { http.Redirect(w, r, "/login", http.StatusSeeOther) return } + // Только POST с формой кабинета: иначе достаточно было заманить сотрудника + // на страницу с картинкой /impersonate?contract=… , чтобы его сессия молча + // переключилась в кабинет чужого клиента. + if r.Method != http.MethodPost || !csrfOK(r) { + setFlashCookie(w, r, "Форма устарела, откройте список клиентов заново.") + http.Redirect(w, r, "/clients", http.StatusSeeOther) + return + } login := r.FormValue("login") - if login == "" { - login = r.URL.Query().Get("login") - } contract := r.FormValue("contract") - if contract == "" { - contract = r.URL.Query().Get("contract") - } var out struct { Session string `json:"session"` AsClient string `json:"as_client"` } - if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", sess, + if err := bj.call(reqCtx(r), http.MethodPost, "/api/lk/admin/impersonate", sess, map[string]string{"login": login, "contract": contract}, &out); err != nil { - http.Redirect(w, r, "/clients?m="+url.QueryEscape(err.Error()), http.StatusSeeOther) + setFlashCookie(w, r, err.Error()) + http.Redirect(w, r, "/clients", http.StatusSeeOther) return } // сохраняем админскую сессию, чтобы можно было вернуться - setFlag(w, "bj_lk_admin_sess", sess) - setSession(w, out.Session) - setFlag(w, "bj_lk_as", out.AsClient) + setFlag(w, r, "bj_lk_admin_sess", sess) + setSession(w, r, out.Session) + setFlag(w, r, "bj_lk_as", out.AsClient) http.Redirect(w, r, "/", http.StatusSeeOther) }) // Администратор: вернуться из кабинета клиента mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) { + // Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику + // токены чужих кабинетов копились бы и жили ещё 12 часов. if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { - setSession(w, c.Value) + _ = bj.call(reqCtx(r), http.MethodPost, "/api/lk/logout", session(r), nil, nil) + setSession(w, r, c.Value) } - setFlag(w, "bj_lk_as", "") - setFlag(w, "bj_lk_admin_sess", "") + setFlag(w, r, "bj_lk_as", "") + setFlag(w, r, "bj_lk_admin_sess", "") http.Redirect(w, r, "/clients", http.StatusSeeOther) }) // Проверка связи с bj-server (для админа при установке) + // Проверка связи с ядром. Наружу отдаём только признак жизни: подробности + // (внутренний адрес ядра, состояние базы) — администратору по ключу модуля. mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + full := secureEqual(r.Header.Get("X-BJ-Key"), s.cfg.BJKey) var out map[string]any - err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out) + err := bj.call(reqCtx(r), http.MethodGet, "/api/lk/ping", "", nil, &out) w.Header().Set("Content-Type", "application/json; charset=utf-8") if err != nil { w.WriteHeader(http.StatusBadGateway) - _ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": s.cfg.BJURL}) + resp := map[string]any{"ok": false} + if full { + resp["error"], resp["bj_url"], resp["mode"] = err.Error(), s.cfg.BJURL, s.mode() + } + _ = json.NewEncoder(w).Encode(resp) return } - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": s.cfg.BJURL}) + resp := map[string]any{"ok": true} + if full { + resp["bj"], resp["bj_url"], resp["mode"] = out, s.cfg.BJURL, s.mode() + } + _ = json.NewEncoder(w).Encode(resp) }) - return mux + if s.agent != nil { + s.registerAgent(mux) + } + mux.Handle("/assets/", assetsHandler()) + return harden(mux) } // notConfigured — кабинет запущен без адреса bj-server или ключа: подсказка вместо падения службы. @@ -749,3 +955,11 @@ func (s *Server) notConfigured(w http.ResponseWriter, r *http.Request) { `

%s

%s

`, template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg)) } + +// mode — режим связи с ядром, для страницы состояния и инструкций администратору. +func (s *Server) mode() string { + if s.cfg.Agent { + return "agent" + } + return "direct" +} diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index 6e3d9fe..40a33ac 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -1,12 +1,15 @@ package lk import ( + "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" ) // fakeBJ — API bj-server для кабинета: проверка ключа, вход и портфель. @@ -91,9 +94,12 @@ func TestLoginPortfolioHealth(t *testing.T) { } h := s.Handler() - form := url.Values{"login": {"client"}, "password": {"secret"}} + // Форма входа подписана токеном CSRF: без него запрос не выполняется. + csrf := csrfFromPage(t, h) + form := url.Values{"login": {"client"}, "password": {"secret"}, "csrf": {csrf.Value}} req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(csrf) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { @@ -242,3 +248,136 @@ func TestBoardPages(t *testing.T) { } } } + +// csrfFromPage — открыть страницу входа и взять с неё токен формы. +func csrfFromPage(t *testing.T, h http.Handler) *http.Cookie { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + for _, c := range rec.Result().Cookies() { + if c.Name == csrfCookie { + return c + } + } + t.Fatal("страница входа не выдала токен формы") + return nil +} + +// TestSecurityHeaders — кабинет отдаёт заголовки защиты и не разрешает кэшировать страницы с данными. +func TestSecurityHeaders(t *testing.T) { + bj := fakeBJ(t) + s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + for h, want := range map[string]string{ + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", + } { + if got := rec.Header().Get(h); got != want { + t.Errorf("%s = %q, ожидали %q", h, got, want) + } + } + if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") { + t.Errorf("CSP без запрета встраивания: %q", csp) + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") { + t.Errorf("Cache-Control = %q", cc) + } + if body := rec.Body.String(); strings.Contains(body, "fonts.googleapis.com") || strings.Contains(body, "fonts.gstatic.com") { + t.Error("страница тянет шрифты со стороннего сайта") + } +} + +// TestImpersonateNeedsPost — вход сотрудника в кабинет клиента не выполняется по ссылке. +func TestImpersonateNeedsPost(t *testing.T) { + bj := fakeBJ(t) + s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"}) + if err != nil { + t.Fatal(err) + } + h := s.Handler() + req := httptest.NewRequest(http.MethodGet, "/impersonate?contract=BR-1", nil) + req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if loc := rec.Header().Get("Location"); loc != "/clients" { + t.Fatalf("GET /impersonate: код %d, Location %q — должен отправлять к списку клиентов без переключения", rec.Code, loc) + } + for _, c := range rec.Result().Cookies() { + if c.Name == "bj_lk" && c.Value != "" && c.Value != "sess-1" { + t.Fatal("GET /impersonate переключил сессию") + } + } +} + +// TestAgentMode — режим ожидания: запрос страницы попадает в очередь, ядро забирает +// его своим подключением и возвращает ответ. +func TestAgentMode(t *testing.T) { + s, err := New(Config{BJKey: "lk-key", Agent: true}) + if err != nil { + t.Fatal(err) + } + if !s.Configured() { + t.Fatal("в режиме ожидания адрес ядра не нужен") + } + h := s.Handler() + + // «ядро»: подключается к кабинету, забирает запрос и отвечает + done := make(chan string, 1) + go func() { + for i := 0; i < 50; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/agent/tasks", nil) + req.Header.Set("X-BJ-Key", "lk-key") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + time.Sleep(10 * time.Millisecond) + continue + } + var task struct{ ID, Method, Path string } + if json.Unmarshal(rec.Body.Bytes(), &task) != nil { + continue + } + done <- task.Path + body := base64.StdEncoding.EncodeToString([]byte(`{"session":"sess-1","full_name":"Клиент","contract":"BR-1"}`)) + payload := fmt.Sprintf(`{"id":%q,"status":200,"body":%q}`, task.ID, body) + rr := httptest.NewRecorder() + rq := httptest.NewRequest(http.MethodPost, "/agent/result", strings.NewReader(payload)) + rq.Header.Set("X-BJ-Key", "lk-key") + h.ServeHTTP(rr, rq) + return + } + }() + + csrf := csrfFromPage(t, h) + form := url.Values{"login": {"client"}, "password": {"secret"}, "csrf": {csrf.Value}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(csrf) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("вход через очередь: код %d, тело %s", rec.Code, rec.Body.String()) + } + select { + case path := <-done: + if path != "/api/lk/login" { + t.Fatalf("ядро получило %q", path) + } + default: + t.Fatal("ядро не получило запрос") + } + + // чужой ключ к каналу не подпускаем + rec = httptest.NewRecorder() + bad := httptest.NewRequest(http.MethodGet, "/agent/tasks", nil) + bad.Header.Set("X-BJ-Key", "не тот ключ") + h.ServeHTTP(rec, bad) + if rec.Code != http.StatusForbidden { + t.Fatalf("чужой ключ принят: код %d", rec.Code) + } +} diff --git a/modules/lk/security.go b/modules/lk/security.go new file mode 100644 index 0000000..10f59a2 --- /dev/null +++ b/modules/lk/security.go @@ -0,0 +1,140 @@ +package lk + +// security.go — защита кабинета: заголовки, куки, CSRF, ограничение тела запроса и +// подстраховка от паники. Кабинет стоит в интернете и показывает персональные данные, +// поэтому всё это включено всегда, отдельной настройки «выключить» нет. + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "log" + "net" + "net/http" + "strings" +) + +// secureEqual — сравнение секретов за постоянное время. +func secureEqual(a, b string) bool { + if a == "" || b == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// httpsRequest — запрос пришёл по TLS (напрямую или через nginx). +func httpsRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https") +} + +// localRequest — обращение на сам сервер (проверка при установке, автотесты). +// Только для таких запросов куки выставляются без признака Secure, иначе браузер +// их не вернёт по http и войти в кабинет на стенде будет нельзя. +func localRequest(r *http.Request) bool { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.ToLower(strings.TrimSpace(host)) + if host == "localhost" || strings.HasSuffix(host, ".local") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// secureCookies — ставить ли признак Secure на куки этого запроса. +func secureCookies(r *http.Request) bool { return httpsRequest(r) || !localRequest(r) } + +// clientIP — адрес посетителя кабинета (за nginx — из X-Real-IP / X-Forwarded-For). +func clientIP(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" { + return v + } + if v := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); v != "" { + return strings.TrimSpace(strings.Split(v, ",")[0]) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// ---- CSRF ---- + +const csrfCookie = "bj_lk_csrf" + +// csrfToken — токен этого посетителя: лежит в куке, недоступной скриптам, и +// подставляется в каждую форму. Запрос без совпадающего поля мы не выполняем, +// поэтому чужая страница не сможет отправить форму от имени клиента. +func csrfToken(w http.ResponseWriter, r *http.Request) string { + if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 { + return c.Value + } + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "" // без источника случайности форму не подписываем: пусть лучше не отправится + } + tok := hex.EncodeToString(b) + http.SetCookie(w, &http.Cookie{Name: csrfCookie, Value: tok, Path: "/", MaxAge: cookieTTL, + HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode}) + return tok +} + +// csrfOK — проверка формы: метод POST и поле совпадает с кукой. +func csrfOK(r *http.Request) bool { + c, err := r.Cookie(csrfCookie) + if err != nil || c.Value == "" { + return false + } + _ = r.ParseForm() + return secureEqual(r.FormValue("csrf"), c.Value) +} + +// ---- обёртка над всеми страницами ---- + +// maxBody — сколько принимаем от посетителя. Формы кабинета маленькие; исключение — +// канал ядра (/agent/), где ответ ядра может быть большим. +const maxBody = 1 << 20 + +func securityHeaders(w http.ResponseWriter, r *http.Request) { + h := w.Header() + // Своих скриптов у кабинета нет, шрифты вшиты в программу — поэтому + // правила строгие: ничего стороннего страница загрузить не может. + h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") + h.Set("X-Frame-Options", "DENY") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()") + if httpsRequest(r) { + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } +} + +// harden — заголовки защиты, запрет кэширования страниц с данными клиента, +// ограничение тела запроса и подстраховка от паники в обработчике. +func harden(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("кабинет: сбой при обработке %s %s: %v", r.Method, r.URL.Path, rec) + http.Error(w, "Внутренняя ошибка кабинета", http.StatusInternalServerError) + } + }() + securityHeaders(w, r) + if !strings.HasPrefix(r.URL.Path, "/agent/") { + r.Body = http.MaxBytesReader(w, r.Body, maxBody) + if !strings.HasPrefix(r.URL.Path, "/assets/") { + // Портфель, доходы, паспортные данные не должны оседать в кэше + // браузера на общем компьютере и в промежуточных прокси. + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") + w.Header().Set("Pragma", "no-cache") + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/modules/lk/templates.go b/modules/lk/templates.go index e9bb38e..1a7e3a1 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -11,8 +11,7 @@ const pageTemplates = ` {{define "head"}} {{.Title}} - - +