Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита

Данные
- корпоративные действия по бумагам клиента (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:
zuevav
2026-09-17 23:47:10 +03:00
parent 4b18579956
commit 2a7442c935
33 changed files with 3062 additions and 209 deletions
+199
View File
@@ -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"})
})
}
+39
View File
@@ -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)))
})
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+140
View File
@@ -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
View File
@@ -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}}
`