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

Данные
- корпоративные действия по бумагам клиента (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
+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}}
`