Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные - корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT), заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы» - дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками, установщиком и инструкцией; ключ доступа на странице показан замаскированным Связь без публичного адреса у ядра - режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему, выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения, тот же ключ модуля в обе стороны, состояние канала в настройках - сквозной тест: настоящий кабинет + настоящий цикл ядра Защита (по итогам аудита) - вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина, пароль от 10 символов - сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных; карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный срок 24 ч; привязка к браузеру, к сети — по настройке - вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение - кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict, выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера, ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей - шрифты вшиты в программу: страница не обращается к сторонним сайтам - экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand - исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash) Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания), живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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}}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user