Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные - корпоративные действия по бумагам клиента (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:
+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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user