Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные - корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT), заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы» - дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками, установщиком и инструкцией; ключ доступа на странице показан замаскированным Связь без публичного адреса у ядра - режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему, выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения, тот же ключ модуля в обе стороны, состояние канала в настройках - сквозной тест: настоящий кабинет + настоящий цикл ядра Защита (по итогам аудита) - вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина, пароль от 10 символов - сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных; карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный срок 24 ч; привязка к браузеру, к сети — по настройке - вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение - кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict, выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера, ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей - шрифты вшиты в программу: страница не обращается к сторонним сайтам - экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand - исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash) Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания), живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+30
-4
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+118
-123
@@ -1,14 +1,12 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,115 +20,10 @@ import (
|
||||
// GET /api/lk/claims — статусы заявок клиента
|
||||
// GET /api/lk/ping — проверка связи модуля с bj-server
|
||||
|
||||
// Сессии ЛК живут дольше рабочего дня и ПРОДЛЕВАЮТСЯ при каждом обращении
|
||||
// (скользящий срок), поэтому активного клиента из кабинета не выкидывает.
|
||||
const lkSessionTTL = 12 * time.Hour
|
||||
|
||||
// lkSessionsFile — сессии переживают перезапуск bj-server. Раньше они были
|
||||
// только в памяти, и любой рестарт (обновление, деплой) выбрасывал всех
|
||||
// клиентов с «сессия истекла, войдите заново».
|
||||
const lkSessionsFile = "/var/lib/bj/.bj/lk-sessions.json"
|
||||
|
||||
type lkSession struct {
|
||||
user LKUser
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type lkSessions struct {
|
||||
mu sync.Mutex
|
||||
data map[string]lkSession
|
||||
}
|
||||
|
||||
func newLKSessions() *lkSessions {
|
||||
s := &lkSessions{data: map[string]lkSession{}}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
// persisted — формат хранения сессии на диске.
|
||||
type lkSessionDisk struct {
|
||||
User LKUser `json:"user"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// load поднимает непротухшие сессии с диска при старте сервера.
|
||||
func (s *lkSessions) load() {
|
||||
raw, err := os.ReadFile(lkSessionsFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]lkSessionDisk
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for tok, v := range m {
|
||||
if now.Before(v.ExpiresAt) {
|
||||
s.data[tok] = lkSession{user: v.User, expiresAt: v.ExpiresAt}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save сбрасывает сессии на диск (вызывается под удержанным mu).
|
||||
func (s *lkSessions) saveLocked() {
|
||||
m := make(map[string]lkSessionDisk, len(s.data))
|
||||
for tok, v := range s.data {
|
||||
m[tok] = lkSessionDisk{User: v.user, ExpiresAt: v.expiresAt}
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := lkSessionsFile + ".tmp"
|
||||
if os.WriteFile(tmp, raw, 0o600) == nil {
|
||||
_ = os.Rename(tmp, lkSessionsFile)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *lkSessions) create(u LKUser) string {
|
||||
b := make([]byte, 24)
|
||||
_, _ = rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
s.mu.Lock()
|
||||
s.data[tok] = lkSession{user: u, expiresAt: time.Now().Add(lkSessionTTL)}
|
||||
// подчистим протухшие
|
||||
for k, v := range s.data {
|
||||
if time.Now().After(v.expiresAt) {
|
||||
delete(s.data, k)
|
||||
}
|
||||
}
|
||||
s.saveLocked()
|
||||
s.mu.Unlock()
|
||||
return tok
|
||||
}
|
||||
|
||||
func (s *lkSessions) get(tok string) (LKUser, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.data[tok]
|
||||
if !ok || time.Now().After(v.expiresAt) {
|
||||
return LKUser{}, false
|
||||
}
|
||||
// Скользящий срок: пока клиент работает — сессия не истекает.
|
||||
// Пишем на диск не чаще раза в 10 минут, чтобы не дёргать файл на каждый запрос.
|
||||
if until := time.Until(v.expiresAt); until < lkSessionTTL-10*time.Minute {
|
||||
v.expiresAt = time.Now().Add(lkSessionTTL)
|
||||
s.data[tok] = v
|
||||
s.saveLocked()
|
||||
}
|
||||
return v.user, true
|
||||
}
|
||||
|
||||
func (s *lkSessions) drop(tok string) {
|
||||
s.mu.Lock()
|
||||
delete(s.data, tok)
|
||||
s.saveLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// registerLKAPI вешает /api/lk/* — интерфейс для модуля личного кабинета.
|
||||
func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
sess := newLKSessions()
|
||||
sess.bindNetwork = func() bool { return rc.Snapshot().LK.BindNetwork }
|
||||
|
||||
// проверка ключа модуля
|
||||
auth := func(w http.ResponseWriter, r *http.Request) bool {
|
||||
@@ -146,14 +39,21 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
// текущий клиент по session-токену
|
||||
// текущий клиент по session-токену. Карточка читается из lk.users на каждом
|
||||
// запросе (с коротким кэшем), поэтому блокировка учётной записи и снятие прав
|
||||
// администратора действуют сразу, а не после истечения сессии.
|
||||
current := func(w http.ResponseWriter, r *http.Request) (LKUser, bool) {
|
||||
tok := r.Header.Get("X-BJ-Session")
|
||||
u, ok := sess.get(tok)
|
||||
v, ok := sess.get(r.Header.Get("X-BJ-Session"), r)
|
||||
if !ok {
|
||||
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": "сессия истекла, войдите заново"})
|
||||
return LKUser{}, false
|
||||
}
|
||||
u, err := sess.user(r.Context(), rc, v)
|
||||
if err != nil {
|
||||
sess.drop(r.Header.Get("X-BJ-Session"))
|
||||
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
||||
return LKUser{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
@@ -178,17 +78,38 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
return
|
||||
}
|
||||
var in struct{ Login, Password string }
|
||||
if json.NewDecoder(r.Body).Decode(&in) != nil {
|
||||
if json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&in) != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"})
|
||||
return
|
||||
}
|
||||
u, err := lkAuth(r.Context(), rc, strings.TrimSpace(in.Login), in.Password)
|
||||
login := strings.TrimSpace(in.Login)
|
||||
// Защита от перебора: логин клиента — это обычно номер договора, то есть
|
||||
// пространство перебора маленькое. Считаем неудачи и по адресу, и по логину.
|
||||
byIP := "lk-ip:" + lkClientAddr(r)
|
||||
byLogin := "lk-login:" + strings.ToLower(login)
|
||||
if loginFailCount(byIP) >= 10 || loginFailCount(byLogin) >= 8 {
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
lkJSON(w, http.StatusTooManyRequests, map[string]string{"error": "слишком много попыток входа, повторите через 15 минут"})
|
||||
return
|
||||
}
|
||||
u, err := lkAuth(r.Context(), rc, login, in.Password)
|
||||
if err != nil {
|
||||
loginFailAdd(byIP)
|
||||
loginFailAdd(byLogin)
|
||||
time.Sleep(300 * time.Millisecond) // и неудача, и перебор одинаково медленные
|
||||
lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
loginFailReset(byIP)
|
||||
loginFailReset(byLogin)
|
||||
tok, err := sess.create(lkSession{UserID: u.ID, Login: u.Login, Contract: u.Contract,
|
||||
IsAdmin: u.IsAdmin, CanTransfer: u.CanTransfer}, r)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"session": sess.create(*u),
|
||||
"session": tok,
|
||||
"full_name": u.FullName,
|
||||
"contract": u.Contract,
|
||||
"is_admin": u.IsAdmin,
|
||||
@@ -338,6 +259,72 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
lkJSON(w, http.StatusOK, map[string]any{"income": rows, "accrued": accrued, "paid": paid, "tax": tax})
|
||||
})
|
||||
|
||||
// Корпоративные действия по бумагам клиента.
|
||||
mux.HandleFunc("/api/lk/corpactions", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fancy := rc.Snapshot().Fancy
|
||||
var isins []string
|
||||
if dp, err := depoClientPositions(r.Context(), fancy, u.Contract); err == nil {
|
||||
for _, p := range depoPositionsAsClient(dp) {
|
||||
if p.ISIN != "" {
|
||||
isins = append(isins, p.ISIN)
|
||||
}
|
||||
}
|
||||
}
|
||||
acts, err := lkCorpActions(r.Context(), fancy, isins)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить корпоративные действия: " + err.Error()})
|
||||
return
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{"actions": acts})
|
||||
})
|
||||
|
||||
// Документы: готовые выписки депозитария и заявки клиента.
|
||||
mux.HandleFunc("/api/lk/documents", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
var in struct {
|
||||
Kind string `json:"kind"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Account string `json:"account"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не разобрали заявку"})
|
||||
return
|
||||
}
|
||||
if err := lkDocRequestAdd(r.Context(), rc, u.Contract, u.FullName, in.Kind, in.From, in.To, in.Account, in.Comment); err != nil {
|
||||
lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
notifyDocRequest(rc, u, in.Kind, in.From, in.To)
|
||||
lkJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "Заявка принята: депозитарий подготовит документ и он появится в этом разделе."})
|
||||
return
|
||||
}
|
||||
ready, err := lkReadyDocuments(r.Context(), rc.Snapshot().Fancy, u.Contract)
|
||||
if err != nil {
|
||||
log.Printf("lk: документы депозитария: %v", err)
|
||||
}
|
||||
reqs, err2 := lkDocRequests(r.Context(), rc, u.Contract)
|
||||
if err2 != nil {
|
||||
log.Printf("lk: заявки на документы: %v", err2)
|
||||
}
|
||||
lkJSON(w, http.StatusOK, map[string]any{"ready": ready, "requests": reqs, "kinds": lkDocKinds})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
@@ -414,12 +401,15 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
if i := strings.Index(full, " "); i > 0 {
|
||||
name = strings.TrimSpace(full[i:])
|
||||
}
|
||||
target := LKUser{
|
||||
Login: "depo:" + full, Contract: full, FullName: name,
|
||||
Active: true, ImpersonatedBy: u.Login,
|
||||
tok, err := sess.create(lkSession{Login: "depo:" + full, Contract: full,
|
||||
Virtual: true, VirtualName: name, ImpersonatedBy: u.Login}, r)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lkAuditImpersonate(r.Context(), rc, u.Login, "depo:"+full, full, lkClientAddr(r))
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"session": sess.create(target), "full_name": name,
|
||||
"session": tok, "full_name": name,
|
||||
"contract": full, "as_client": full, "by_admin": u.Login,
|
||||
})
|
||||
return
|
||||
@@ -431,10 +421,15 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
}
|
||||
for _, c := range list {
|
||||
if c.Login == in.Login {
|
||||
target := c
|
||||
target.ImpersonatedBy = u.Login // помечаем: вошёл админ
|
||||
tok, err := sess.create(lkSession{UserID: c.ID, Login: c.Login, Contract: c.Contract,
|
||||
ImpersonatedBy: u.Login}, r) // помечаем: вошёл сотрудник, только просмотр
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
lkAuditImpersonate(r.Context(), rc, u.Login, c.Login, c.Contract, lkClientAddr(r))
|
||||
lkJSON(w, http.StatusOK, map[string]any{
|
||||
"session": sess.create(target), "full_name": c.FullName,
|
||||
"session": tok, "full_name": c.FullName,
|
||||
"contract": c.Contract, "as_client": c.Login, "by_admin": u.Login,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
{{define "content"}}
|
||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Как разместить кабинет клиента на хостинге</h2>
|
||||
<p class="muted">Речь о нашем модуле <code>bj-lk</code> — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.</p>
|
||||
|
||||
<h3>Шаг 1. Ключ и архив</h3>
|
||||
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. <b>Внутри архива боевой ключ</b>: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.</p>
|
||||
|
||||
<h3>Шаг 2. Установка на сервере кабинета</h3>
|
||||
<pre>tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
|
||||
cd bj-lk
|
||||
sudo ./install.sh
|
||||
curl -s http://127.0.0.1:8099/health # {"ok":true}</pre>
|
||||
<p>Установщик заводит системного пользователя <code>bjlk</code>, кладёт программу в <code>/opt/bj-lk</code>, настройки — в <code>/etc/bj-lk/lk.env</code> (права 0600) и включает службу <code>bj-lk</code>. Сам кабинет слушает только <code>127.0.0.1:8099</code>.</p>
|
||||
|
||||
<h3>Шаг 3. Публикация наружу</h3>
|
||||
<p>Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.</p>
|
||||
|
||||
<h3>Шаг 4. Связь с ядром — два способа</h3>
|
||||
<p><b>Кабинет обращается к ядру</b> (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.</p>
|
||||
<p><b>Ядро подключается к кабинету.</b> Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.</p>
|
||||
<p>Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит <code>BJ_MODE=agent</code>, а <code>BJ_URL</code> не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.</p>
|
||||
<p>В обе стороны канал защищён тем же ключом модуля: запрос без ключа кабинет не отдаёт, ответ без ключа не принимает.</p>
|
||||
|
||||
<h3>Что с данными</h3>
|
||||
<ul>
|
||||
<li>Кабинет не хранит ни базы, ни файлов: портфель, операции и документы приходят из ядра на время показа страницы.</li>
|
||||
<li>Пароли клиентов лежат только в ядре и только в виде bcrypt-хеша; кабинет их не видит.</li>
|
||||
<li>Страница не обращается к сторонним сайтам — шрифты и оформление вшиты в программу.</li>
|
||||
<li>Вход сотрудника в кабинет клиента записывается в журнал доступа (<code>lk.audit</code>) и поднимает оповещение.</li>
|
||||
<li>Обновляется кабинет отдельно от ядра — через сервер обновлений, по лицензии модуля «Личный кабинет клиента».</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>API личного кабинета (ESIA Finance V1)</h2>
|
||||
<p class="muted">Bridge-and-Join-s реализует контракт ESIA Finance V1 на стороне back-office. ЛК клиента отправляет нам заявку, мы возвращаем подтверждение и потом колбэк со статусом. Полная спецификация: <code>docs/lk-contract/v1/openapi.yaml</code>.</p>
|
||||
|
||||
@@ -603,6 +603,64 @@ function hbBind(inp,box,list,onPick){
|
||||
<p class="muted" style="margin:-8px 0 16px">Интеграция с платформой ЛК <strong>ЕСИА Финанс</strong>: адрес для клиента, приём заявок M2M, возврат статуса и подключение к API (тест/бой). НРД настраивается отдельно — в разделе «НРД».</p>
|
||||
{{template "verline" (.Versions.Get "bj-lk")}}
|
||||
|
||||
<div class="card" id="sec-lk-dist">
|
||||
<h2><span class="dot {{if .Settings.LK.APIKey}}ok{{else}}warn{{end}}"></span>Кабинет клиента на своём сервере</h2>
|
||||
<p class="muted">Кабинет (<code>bj-lk</code>) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: <b>1)</b> создайте ключ доступа, <b>2)</b> выберите способ связи, <b>3)</b> скачайте архив и распакуйте его на сервере кабинета, <b>4)</b> выполните там <code>sudo ./install.sh</code>, <b>5)</b> опубликуйте кабинет через nginx с сертификатом (пример в README архива).</p>
|
||||
|
||||
<form method="post" action="/admin/setup/lk/dist" style="display:grid;gap:14px;max-width:780px;margin-top:12px">
|
||||
<input type="hidden" name="section" value="sec-lk-dist"><input type="hidden" name="core_url_set" value="1"><input type="hidden" name="mode_set" value="1">
|
||||
|
||||
<div>
|
||||
<label>Ключ доступа модуля</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;flex-wrap:wrap">
|
||||
<code style="flex:1;min-width:260px;overflow-wrap:anywhere">{{if .LKKeyMask}}{{.LKKeyMask}}{{else}}не создан — кабинет не сможет обращаться к ядру{{end}}</code>
|
||||
<button type="submit" name="op" value="newkey" class="btn btn-secondary">{{if .Settings.LK.APIKey}}Сменить ключ{{else}}Создать ключ{{end}}</button>
|
||||
{{if .Settings.LK.APIKey}}<button type="submit" name="op" value="clearkey" class="btn btn-secondary" onclick="return confirm('Удалить ключ? Кабинет перестанет получать данные, пока не впишете новый.')">Удалить</button>{{end}}
|
||||
</div>
|
||||
<span class="muted" style="font-size:12px">Ключ показан не полностью — целиком он попадает только в архив, в файл <code>/etc/bj-lk/lk.env</code> на сервере кабинета. При смене ключа скачайте архив заново или впишите новый ключ в этот файл и перезапустите службу кабинета.</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="margin-bottom:8px">Как кабинет и ядро находят друг друга</label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600"><input type="radio" name="mode" value="direct" {{if not .Settings.LK.AgentMode}}checked{{end}}> <span>Кабинет обращается к ядру <span class="muted" style="font-weight:400">— обычный случай: у ядра есть адрес, доступный серверу кабинета.</span></span></label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600;margin-top:6px"><input type="radio" name="mode" value="agent" {{if .Settings.LK.AgentMode}}checked{{end}}> <span>Ядро подключается к кабинету <span class="muted" style="font-weight:400">— если у ядра нет адреса в интернете. Ядро само звонит кабинету по его имени, забирает запросы клиентов и возвращает ответы; входящих соединений и открытых портов не требуется.</span></span></label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Адрес ядра для кабинета <span class="muted" style="font-weight:400">— для режима «кабинет обращается к ядру»</span></label>
|
||||
<input type="text" name="core_url" value="{{.Settings.LK.CoreURL}}" placeholder="https://bj.example.ru" style="margin-top:4px">
|
||||
<span class="muted" style="font-size:12px">Куда кабинет обращается за данными. Если кабинет и ядро в одной сети — внутренний адрес; если в разных — тот, что открыт межсетевым экраном. Только <code>https</code>: по этому каналу идут ключ и сессии клиентов.</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Адрес кабинета на хостинге <span class="muted" style="font-weight:400">— для режима «ядро подключается к кабинету»</span></label>
|
||||
<input type="text" name="cabinet_url" value="{{.Settings.LK.CabinetURL}}" placeholder="https://lk.example.ru" style="margin-top:4px">
|
||||
<span class="muted" style="font-size:12px">Имя, по которому ядро дозванивается до кабинета. Оно же — адрес, по которому входят клиенты.</span>
|
||||
</div>
|
||||
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600"><input type="checkbox" name="bind_network" value="1" {{if .Settings.LK.BindNetwork}}checked{{end}}> <span>Привязывать сессию клиента к его сети <span class="muted" style="font-weight:400">— к браузеру сессия привязана всегда; эта отметка добавляет сверку сети. Строже, но клиентов с мобильным интернетом будет выбрасывать при смене адреса оператором.</span></span></label>
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button type="submit" class="btn btn-ok">Сохранить</button>
|
||||
{{if .Settings.LK.APIKey}}<a class="btn" href="/admin/setup/lk/dist.tar.gz">Скачать дистрибутив кабинета</a>{{end}}
|
||||
<a class="btn btn-secondary" href="/admin/help/lk">Инструкция по размещению</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if .Settings.LK.AgentMode}}
|
||||
<div style="margin-top:14px;padding:12px 14px;border-radius:8px;background:var(--bg-soft,#f6f7f9)">
|
||||
<b>Связь с кабинетом</b> <span class="dot {{if .LKAgent.Connected}}ok{{else}}err{{end}}"></span>
|
||||
{{if .LKAgent.Connected}}ядро подключено к {{.LKAgent.Cabinet}}{{else}}ядро не дозвонилось до кабинета{{end}}
|
||||
<div class="muted" style="font-size:12px;margin-top:4px">
|
||||
{{if .LKAgent.LastOK}}Последний обмен: {{.LKAgent.LastOK}}. {{end}}Обслужено запросов: {{.LKAgent.Served}}.
|
||||
{{if .LKAgent.LastErr}}<br>Ошибка: {{.LKAgent.LastErr}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.LK.PublicURL}}ok{{else}}warn{{end}}"></span>Личный кабинет — интеграция</h2>
|
||||
<p class="muted">Адрес ЛК (стенд/бой), куда клиент подаёт заявку M2M. Заявка приходит к нам по контракту <code>POST /api/v1/back_office/claims/</code>, а мы возвращаем статус через <strong>Callback URL</strong>.</p>
|
||||
|
||||
@@ -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"})
|
||||
})
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+278
-64
@@ -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) {
|
||||
`<body style="font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px;line-height:1.5"><h1>%s</h1><p>%s</p></body></html>`,
|
||||
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"
|
||||
}
|
||||
|
||||
+140
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+76
-9
@@ -11,8 +11,7 @@ const pageTemplates = `
|
||||
{{define "head"}}<!DOCTYPE html><html lang="ru"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<link rel="stylesheet" href="/assets/fonts.css">
|
||||
<style>
|
||||
:root{--paper:#edebe4;--sheet:#f7f5ef;--ink:#191a1c;--ink-2:#45403a;--mut:#6a6357;
|
||||
--line:#ded8ca;--line-2:#c9c3b4;--board:#191a1c;--board-2:#1f2124;--board-3:#2b2d31;
|
||||
@@ -33,6 +32,7 @@ a{color:var(--acc)}a:hover{color:#1e3395}
|
||||
.top nav a.on{background:var(--board-3);color:#f3f0e7}
|
||||
.top .who{color:var(--dim);font-size:12px;white-space:nowrap}
|
||||
.top .exit{color:var(--dim);text-decoration:none}
|
||||
.lnk{background:none;border:0;padding:0;font:inherit;color:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}
|
||||
.cta{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;background:var(--amber);color:var(--ink);text-decoration:none;border:0;border-radius:3px;cursor:pointer;font:inherit}
|
||||
.cta:hover{filter:brightness(1.05);color:var(--ink)}
|
||||
.board{background:var(--board-2);color:#f3f0e7;border-bottom:3px solid var(--amber);padding:22px 20px}
|
||||
@@ -101,16 +101,17 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
<a href="/orders" class="lbl {{if eq .Nav "c"}}on{{end}}">Поручения</a>
|
||||
<a href="/operations" class="lbl {{if eq .Nav "o"}}on{{end}}">Операции</a>
|
||||
<a href="/income" class="lbl {{if eq .Nav "i"}}on{{end}}">Доходы</a>
|
||||
<a href="/documents" class="lbl {{if eq .Nav "d"}}on{{end}}">Документы</a>
|
||||
<a href="/profile" class="lbl {{if eq .Nav "u"}}on{{end}}">Профиль</a>
|
||||
</nav>
|
||||
<a href="/order" class="cta lbl"><svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>Новое поручение</a>
|
||||
<span class="who">{{with .FullName}}{{.}}{{end}}{{with .Contract}} · {{.}}{{end}}</span>
|
||||
<a href="/logout" class="exit lbl">Выход</a>
|
||||
<form method="post" action="/logout" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="exit lbl lnk" type="submit" style="text-decoration:none">Выход</button></form>
|
||||
</header>
|
||||
{{if .AsClient}}<div class="imp">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="#e8a33d" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v4M8 11h.01"/></svg>
|
||||
<span>Вы в кабинете клиента <b>{{.AsClient}}</b> от имени сотрудника. Поручение можно подготовить, подписывает его клиент.</span>
|
||||
<a href="/back-to-admin" style="color:#e8a33d">вернуться к списку клиентов</a></div>{{end}}{{end}}
|
||||
<form method="post" action="/back-to-admin" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="lnk" type="submit" style="color:#e8a33d">вернуться к списку клиентов</button></form></div>{{end}}{{end}}
|
||||
|
||||
{{define "login"}}{{template "head" .}}
|
||||
<div class="login">
|
||||
@@ -127,9 +128,10 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
<p class="mut" style="color:#8e8879;margin:0">Кабинет работает на сервере депозитария и обращается к учётной системе по защищённому каналу. Данные о вас хранятся в учётной системе, а не в кабинете.</p>
|
||||
</section>
|
||||
<section class="form">
|
||||
<form method="post" action="/login">
|
||||
<form method="post" action="/login"><input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<h1 style="margin:0">Вход</h1>
|
||||
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||
{{if .Flash}}<div class="msg ok">{{.Flash}}</div>{{end}}
|
||||
<div><label for="login">Логин или номер договора</label><input id="login" name="login" autocomplete="username" autofocus></div>
|
||||
<div><label for="password">Пароль</label><input id="password" name="password" type="password" autocomplete="current-password"></div>
|
||||
<button class="btn main" type="submit">Войти</button>
|
||||
@@ -222,7 +224,7 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
{{define "transfer"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/order" id="orderForm">
|
||||
<form method="post" action="/order" id="orderForm"><input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div class="sheet">
|
||||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:20px;border-bottom:2px solid var(--ink);padding-bottom:14px;flex-wrap:wrap">
|
||||
<div><h1 style="margin:0">Поручение депозитарию</h1>
|
||||
@@ -324,7 +326,7 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
<div class="row"><span>Вход в кабинет</span><span class="n">{{.Login}}</span></div>
|
||||
<div class="row"><span>Подача поручений</span><span>{{if .ReadOnly}}выключена депозитарием{{else}}доступна{{end}}</span></div>
|
||||
{{if .AsClient}}<div class="row"><span>Сейчас в кабинете</span><span>сотрудник депозитария</span></div>{{end}}
|
||||
<div class="row"><span>Завершить работу</span><a href="/logout">выйти</a></div>
|
||||
<div class="row"><span>Завершить работу</span><form method="post" action="/logout" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="lnk" type="submit">выйти</button></form></div>
|
||||
</div>
|
||||
<p class="mut" style="margin-top:14px">Контакты, паспортные данные и реквизиты для выплат меняет депозитарий: обратитесь к своему менеджеру, изменения появятся в кабинете после внесения в анкету.</p>
|
||||
</section>
|
||||
@@ -349,7 +351,7 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
<tbody>{{range .Clients}}<tr>
|
||||
<td><b>{{.FullName}}</b></td><td class="n">{{.Login}}</td><td class="n">{{.Contract}}</td>
|
||||
<td>{{if .Active}}<span class="chip ok">активна</span>{{else}}<span class="chip err">заблокирована</span>{{end}}</td>
|
||||
<td style="text-align:right"><form method="post" action="/impersonate" style="margin:0"><input type="hidden" name="login" value="{{.Login}}"><button class="btn sm" type="submit">Открыть кабинет</button></form></td>
|
||||
<td style="text-align:right"><form method="post" action="/impersonate" style="margin:0"><input type="hidden" name="login" value="{{.Login}}"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="btn sm" type="submit">Открыть кабинет</button></form></td>
|
||||
</tr>{{end}}</tbody></table></div>
|
||||
{{else}}<p class="empty">Учётных записей кабинета пока нет.</p>{{end}}
|
||||
</section>
|
||||
@@ -365,7 +367,7 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
<thead><tr><th>Клиент</th><th>Договор</th><th class="num">Бумаг</th><th></th></tr></thead>
|
||||
<tbody>{{range .Depo}}<tr>
|
||||
<td><b>{{.Name}}</b></td><td class="n">{{.Contract}}</td><td class="num">{{.Securities}}</td>
|
||||
<td style="text-align:right"><form method="post" action="/impersonate" style="margin:0"><input type="hidden" name="contract" value="{{.Contract}}"><button class="btn sm" type="submit">Открыть кабинет</button></form></td>
|
||||
<td style="text-align:right"><form method="post" action="/impersonate" style="margin:0"><input type="hidden" name="contract" value="{{.Contract}}"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="btn sm" type="submit">Открыть кабинет</button></form></td>
|
||||
</tr>{{end}}</tbody></table></div>
|
||||
{{else}}<p class="empty">Ничего не найдено.</p>{{end}}
|
||||
</section>
|
||||
@@ -486,5 +488,70 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
||||
</tr>{{end}}</tbody></table></div>
|
||||
<p class="mut" style="margin-top:18px">Суммы и удержанный налог — по данным учёта депозитария как налогового агента по выплатам эмитентов. По купонам государственных облигаций налог может удерживать брокер при выплате, тогда здесь он нулевой. Справку о доходах можно запросить у депозитария.</p>
|
||||
{{else}}<p class="empty">За выбранный период выплат дохода нет.</p>{{end}}
|
||||
|
||||
{{if .Actions}}<section class="sec" style="margin-top:30px">
|
||||
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Корпоративные действия по вашим бумагам</h2>
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Дата фиксации</th><th>Выпуск</th><th>Событие</th><th>Дата операции</th><th>Примечание</th></tr></thead>
|
||||
<tbody>{{range .Actions}}<tr>
|
||||
<td class="n">{{if .FixDate}}{{.FixDate}}{{else}}—{{end}}</td>
|
||||
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none">{{.Security}}</a><div class="n mut">{{.ISIN}}</div>{{else}}{{.Security}}{{end}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td class="n">{{if .OperDate}}{{.OperDate}}{{else}}—{{end}}</td>
|
||||
<td class="mut">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
|
||||
</tr>{{end}}</tbody></table></div>
|
||||
<p class="mut" style="margin-top:14px">События по бумагам вашего портфеля из справочника депозитария. Если по действию нужно ваше решение — например, предъявить бумаги к выкупу по оферте, — подайте заявление через раздел «Документы» или обратитесь к своему менеджеру.</p>
|
||||
</section>{{end}}
|
||||
</main></body></html>{{end}}
|
||||
{{define "documents"}}{{template "head" .}}{{template "nav" .}}
|
||||
<section class="board"><div class="board-row">
|
||||
<div><span class="lbl" style="color:var(--dim)">Документы депозитария</span>
|
||||
<div class="big" style="margin-top:8px;font-size:32px">{{len .Ready}}</div>
|
||||
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">готовых документов{{if .Requests}} · заявок {{len .Requests}}{{end}}</div></div>
|
||||
</div></section>
|
||||
<main>
|
||||
{{if .Flash}}<div class="msg ok">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||
|
||||
<section class="sec">
|
||||
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Готовые документы</h2>
|
||||
{{if .Ready}}<div class="tw"><table>
|
||||
<thead><tr><th>Дата</th><th>Документ</th><th>Период</th><th>Счёт депо</th><th>Состояние</th></tr></thead>
|
||||
<tbody>{{range .Ready}}<tr>
|
||||
<td class="n">{{.Date}}</td>
|
||||
<td>{{.Title}}{{with .Note}}<div class="n mut">№ {{.}}</div>{{end}}</td>
|
||||
<td class="n">{{if .Period}}{{.Period}}{{else}}—{{end}}</td>
|
||||
<td class="n" style="font-size:13px">{{.Account}}</td>
|
||||
<td>{{if eq .State "исполнено"}}<span class="chip ok">готов</span>{{else}}<span class="chip go">{{.State}}</span>{{end}}</td>
|
||||
</tr>{{end}}</tbody></table></div>
|
||||
<p class="mut" style="margin-top:14px">Файлы документов выдаёт депозитарий: бумажный экземпляр в офисе или по электронной почте. В кабинете виден факт подготовки и период документа.</p>
|
||||
{{else}}<p class="empty">Готовых документов пока нет.</p>{{end}}
|
||||
</section>
|
||||
|
||||
<div class="grid2">
|
||||
<section class="sec">
|
||||
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Мои заявки</h2>
|
||||
{{if .Requests}}<div class="rows">{{range .Requests}}
|
||||
<div class="row"><span>{{.Title}}<span class="n mut" style="display:block">{{.Date}}{{if .Period}} · {{.Period}}{{end}}</span>{{with .Note}}<span class="mut" style="display:block">{{.}}</span>{{end}}</span>
|
||||
<span class="chip {{if eq .State "готов"}}ok{{else if eq .State "отклонён"}}err{{else}}go{{end}}" style="align-self:center">{{.State}}</span></div>
|
||||
{{end}}</div>
|
||||
{{else}}<p class="empty">Заявок пока нет.</p>{{end}}
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Заказать документ</h2>
|
||||
<form method="post" action="/documents" class="sheet" style="display:grid;gap:12px;padding:18px 20px"><input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div><label for="kind">Вид документа</label>
|
||||
<select id="kind" name="kind">{{range .Kinds}}<option>{{.}}</option>{{end}}</select></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}"></div>
|
||||
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}"></div>
|
||||
</div>
|
||||
<div><label for="comment">Комментарий</label><input id="comment" name="comment" placeholder="необязательно"></div>
|
||||
<button class="btn main" type="submit">Отправить заявку</button>
|
||||
<p class="mut" style="margin:0">Заявка уходит сотруднику депозитария. Срок подготовки и стоимость — по тарифам депозитария; готовый документ появится в списке слева.</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main></body></html>{{end}}
|
||||
`
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Проверка защиты работающего кабинета снаружи. Ничего не меняет: только запросы к страницам.
|
||||
B=${1:-http://127.0.0.1:8099}
|
||||
pass=0; fail=0
|
||||
ok(){ echo " ✓ $1"; pass=$((pass+1)); }
|
||||
no(){ echo " ✗ $1"; fail=$((fail+1)); }
|
||||
chk(){ if [ "$2" = "$3" ]; then ok "$1"; else no "$1 (получено: $2, ожидали: $3)"; fi }
|
||||
H=$(curl -s -m8 -D- -o /dev/null $B/login)
|
||||
echo "Заголовки защиты:"
|
||||
for h in "X-Frame-Options: DENY" "X-Content-Type-Options: nosniff" "Referrer-Policy: same-origin" "frame-ancestors 'none'" "default-src 'none'" "no-store" "Permissions-Policy"; do
|
||||
echo "$H" | grep -qi -- "$h" && ok "$h" || no "нет: $h"; done
|
||||
echo "Куки:"
|
||||
echo "$H" | grep -i "set-cookie: bj_lk_csrf" | grep -qi "httponly" && ok "токен формы HttpOnly" || no "токен формы без HttpOnly"
|
||||
echo "$H" | grep -i "set-cookie: bj_lk_csrf" | grep -qi "samesite=strict" && ok "SameSite=Strict" || no "нет SameSite=Strict"
|
||||
HS=$(curl -s -m8 -D- -o /dev/null -H "X-Forwarded-Proto: https" -H "Host: lk.example.ru" $B/login)
|
||||
echo "$HS" | grep -i "set-cookie" | grep -qi "secure" && ok "за nginx с TLS куки получают Secure" || no "куки без Secure за TLS"
|
||||
echo "$HS" | grep -qi "strict-transport-security" && ok "HSTS за TLS" || no "нет HSTS за TLS"
|
||||
echo "Сторонние ресурсы:"
|
||||
P=$(curl -s -m8 $B/login)
|
||||
echo "$P" | grep -qiE "https?://[^\"' ]*(googleapis|gstatic|cdn|jsdelivr|unpkg)" && no "страница тянет сторонние ресурсы" || ok "сторонних адресов на странице нет"
|
||||
echo "$P" | grep -qi "<script" && no "на странице есть скрипты" || ok "скриптов на странице входа нет"
|
||||
echo "Страницы без входа:"
|
||||
for p in / /portfolio /orders /order /operations /income /documents /profile /clients /security?isin=RU0009029540; do
|
||||
c=$(curl -s -m8 -o /dev/null -w "%{http_code} %{redirect_url}" $B$p); echo "$c" | grep -q "^303 .*/login" && ok "$p → вход" || no "$p без входа: $c"; done
|
||||
echo "Формы без токена:"
|
||||
chk "POST /order без сессии" "$(curl -s -m8 -o /dev/null -w '%{http_code}' -X POST -d 'isin=RU0009029540' $B/order)" "303"
|
||||
c=$(curl -s -m8 -X POST -d "login=x&password=y" $B/login | grep -c "Форма устарела"); chk "POST /login без токена отклонён" "$c" "1"
|
||||
R=$(curl -s -m8 -D- -o /dev/null -b "bj_lk=abcdef0123456789abcdef0123456789abcdef0123456789" "$B/impersonate?contract=BR-1")
|
||||
echo "$R" | grep -i "^set-cookie: bj_lk=" | grep -qv "abcdef0123456789abcdef0123456789abcdef0123456789" && no "GET /impersonate выдал новую сессию" || ok "GET /impersonate не переключает сессию"
|
||||
echo "$R" | grep -qi "set-cookie: bj_lk_as=." && no "GET /impersonate включил режим просмотра" || ok "GET /impersonate не включает режим просмотра клиента"
|
||||
echo "Служебные точки:"
|
||||
chk "/health без ключа — только признак жизни" "$(curl -s -m8 $B/health)" '{"ok":true}'
|
||||
chk "/agent/tasks в прямом режиме закрыт" "$(curl -s -m8 -o /dev/null -w '%{http_code}' $B/agent/tasks)" "404"
|
||||
b=$(curl -s -m8 -L --path-as-is $B/assets/../lk.go | grep -c "^package lk"); chk "/assets/../lk.go — обход каталога не отдаёт файлы" "$b" "0"
|
||||
b=$(curl -s -m8 --path-as-is "$B/assets/%2e%2e/%2e%2e/etc/passwd" | grep -c "root:"); chk "/assets/%2e%2e/… — файлы системы недоступны" "$b" "0"
|
||||
chk "/assets/ — листинга нет" "$(curl -s -m8 -o /dev/null -w '%{http_code}' $B/assets/)" "404"
|
||||
echo "Размер запроса:"
|
||||
head -c 2500000 /dev/zero | tr '\0' 'a' > big.bin
|
||||
c=$(curl -s -m15 -o /dev/null -w '%{http_code}' -X POST --data-binary @big.bin -H "Content-Type: application/x-www-form-urlencoded" $B/login); rm -f big.bin
|
||||
[ "$c" != "303" ] && ok "тело 2,5 МБ не принято как вход (код $c)" || no "большое тело принято"
|
||||
echo "Подделка сессии:"
|
||||
c=$(curl -s -m8 -o /dev/null -w "%{http_code} %{redirect_url}" -b "bj_lk=0000000000000000000000000000000000000000000000000; bj_lk_admin=1" $B/clients)
|
||||
b=$(curl -s -m8 -b "bj_lk=0000000000000000000000000000000000000000000000000; bj_lk_admin=1" $B/clients | grep -c "Открыть кабинет")
|
||||
chk "кука bj_lk_admin=1 без сессии не даёт списка клиентов" "$b" "0"
|
||||
echo; echo "ИТОГО: пройдено $pass, не пройдено $fail"
|
||||
Reference in New Issue
Block a user