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

Данные
- корпоративные действия по бумагам клиента (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
@@ -1,6 +1,39 @@
{{define "content"}}
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
<div class="card">
<h2>Как разместить кабинет клиента на хостинге</h2>
<p class="muted">Речь о нашем модуле <code>bj-lk</code> — кабинете, который клиенты открывают в браузере. Он ставится отдельно от ядра: на веб-сервер депозитария или на обычный хостинг с выделенным именем. Своей базы у кабинета нет, данные клиентов он не хранит — берёт у ядра на время запроса.</p>
<h3>Шаг 1. Ключ и архив</h3>
<p>Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: создайте ключ доступа и скачайте архив. В архиве — исполняемый файл, служба systemd, файл настроек, установщик и README. <b>Внутри архива боевой ключ</b>: после установки удалите архив с рабочего компьютера, не пересылайте его почтой.</p>
<h3>Шаг 2. Установка на сервере кабинета</h3>
<pre>tar xzf bj-lk-ГГГГ-ММ-ДД.tar.gz
cd bj-lk
sudo ./install.sh
curl -s http://127.0.0.1:8099/health # {"ok":true}</pre>
<p>Установщик заводит системного пользователя <code>bjlk</code>, кладёт программу в <code>/opt/bj-lk</code>, настройки — в <code>/etc/bj-lk/lk.env</code> (права 0600) и включает службу <code>bj-lk</code>. Сам кабинет слушает только <code>127.0.0.1:8099</code>.</p>
<h3>Шаг 3. Публикация наружу</h3>
<p>Перед кабинетом ставится nginx с сертификатом TLS — пример конфигурации есть в README архива. Наружу открыт только 443 порт сервера кабинета.</p>
<h3>Шаг 4. Связь с ядром — два способа</h3>
<p><b>Кабинет обращается к ядру</b> (обычный случай). В настройках ядра укажите его адрес, кабинет ходит туда за данными. Требуется, чтобы сервер кабинета мог открыть соединение к ядру по https.</p>
<p><b>Ядро подключается к кабинету.</b> Если у ядра нет адреса в интернете — а так чаще всего и бывает, ядро стоит во внутренней сети депозитария, — порядок разворачивается. Кабинет ничего никуда не звонит: он копит запросы клиентов и ждёт. Ядро само подключается к кабинету по его доменному имени, забирает запрос, выполняет его у себя и возвращает ответ. Из сети депозитария идёт только исходящее соединение: ни белого адреса, ни проброса портов, ни правил для входящего трафика не нужно.</p>
<p>Включается в настройках ядра: «Личный кабинет» → «Как кабинет и ядро находят друг друга» → «Ядро подключается к кабинету», рядом — адрес кабинета. В файле кабинета при этом стоит <code>BJ_MODE=agent</code>, а <code>BJ_URL</code> не задан. Состояние связи видно там же: подключено ли ядро, когда был последний обмен, сколько запросов обслужено.</p>
<p>В обе стороны канал защищён тем же ключом модуля: запрос без ключа кабинет не отдаёт, ответ без ключа не принимает.</p>
<h3>Что с данными</h3>
<ul>
<li>Кабинет не хранит ни базы, ни файлов: портфель, операции и документы приходят из ядра на время показа страницы.</li>
<li>Пароли клиентов лежат только в ядре и только в виде bcrypt-хеша; кабинет их не видит.</li>
<li>Страница не обращается к сторонним сайтам — шрифты и оформление вшиты в программу.</li>
<li>Вход сотрудника в кабинет клиента записывается в журнал доступа (<code>lk.audit</code>) и поднимает оповещение.</li>
<li>Обновляется кабинет отдельно от ядра — через сервер обновлений, по лицензии модуля «Личный кабинет клиента».</li>
</ul>
</div>
<div class="card">
<h2>API личного кабинета (ESIA Finance V1)</h2>
<p class="muted">Bridge-and-Join-s реализует контракт ESIA Finance V1 на стороне back-office. ЛК клиента отправляет нам заявку, мы возвращаем подтверждение и потом колбэк со статусом. Полная спецификация: <code>docs/lk-contract/v1/openapi.yaml</code>.</p>
@@ -603,6 +603,64 @@ function hbBind(inp,box,list,onPick){
<p class="muted" style="margin:-8px 0 16px">Интеграция с платформой ЛК <strong>ЕСИА Финанс</strong>: адрес для клиента, приём заявок M2M, возврат статуса и подключение к API (тест/бой). НРД настраивается отдельно — в разделе «НРД».</p>
{{template "verline" (.Versions.Get "bj-lk")}}
<div class="card" id="sec-lk-dist">
<h2><span class="dot {{if .Settings.LK.APIKey}}ok{{else}}warn{{end}}"></span>Кабинет клиента на своём сервере</h2>
<p class="muted">Кабинет (<code>bj-lk</code>) — отдельная программа для веб-сервера депозитария или хостинга. Своей базы у неё нет: данные она берёт у ядра по защищённому каналу и не хранит дольше запроса. Порядок: <b>1)</b> создайте ключ доступа, <b>2)</b> выберите способ связи, <b>3)</b> скачайте архив и распакуйте его на сервере кабинета, <b>4)</b> выполните там <code>sudo ./install.sh</code>, <b>5)</b> опубликуйте кабинет через nginx с сертификатом (пример в README архива).</p>
<form method="post" action="/admin/setup/lk/dist" style="display:grid;gap:14px;max-width:780px;margin-top:12px">
<input type="hidden" name="section" value="sec-lk-dist"><input type="hidden" name="core_url_set" value="1"><input type="hidden" name="mode_set" value="1">
<div>
<label>Ключ доступа модуля</label>
<div style="display:flex;gap:8px;align-items:center;margin-top:4px;flex-wrap:wrap">
<code style="flex:1;min-width:260px;overflow-wrap:anywhere">{{if .LKKeyMask}}{{.LKKeyMask}}{{else}}не создан — кабинет не сможет обращаться к ядру{{end}}</code>
<button type="submit" name="op" value="newkey" class="btn btn-secondary">{{if .Settings.LK.APIKey}}Сменить ключ{{else}}Создать ключ{{end}}</button>
{{if .Settings.LK.APIKey}}<button type="submit" name="op" value="clearkey" class="btn btn-secondary" onclick="return confirm('Удалить ключ? Кабинет перестанет получать данные, пока не впишете новый.')">Удалить</button>{{end}}
</div>
<span class="muted" style="font-size:12px">Ключ показан не полностью — целиком он попадает только в архив, в файл <code>/etc/bj-lk/lk.env</code> на сервере кабинета. При смене ключа скачайте архив заново или впишите новый ключ в этот файл и перезапустите службу кабинета.</span>
</div>
<div>
<label style="margin-bottom:8px">Как кабинет и ядро находят друг друга</label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600"><input type="radio" name="mode" value="direct" {{if not .Settings.LK.AgentMode}}checked{{end}}> <span>Кабинет обращается к ядру <span class="muted" style="font-weight:400">— обычный случай: у ядра есть адрес, доступный серверу кабинета.</span></span></label>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600;margin-top:6px"><input type="radio" name="mode" value="agent" {{if .Settings.LK.AgentMode}}checked{{end}}> <span>Ядро подключается к кабинету <span class="muted" style="font-weight:400">— если у ядра нет адреса в интернете. Ядро само звонит кабинету по его имени, забирает запросы клиентов и возвращает ответы; входящих соединений и открытых портов не требуется.</span></span></label>
</div>
<div>
<label>Адрес ядра для кабинета <span class="muted" style="font-weight:400">— для режима «кабинет обращается к ядру»</span></label>
<input type="text" name="core_url" value="{{.Settings.LK.CoreURL}}" placeholder="https://bj.example.ru" style="margin-top:4px">
<span class="muted" style="font-size:12px">Куда кабинет обращается за данными. Если кабинет и ядро в одной сети — внутренний адрес; если в разных — тот, что открыт межсетевым экраном. Только <code>https</code>: по этому каналу идут ключ и сессии клиентов.</span>
</div>
<div>
<label>Адрес кабинета на хостинге <span class="muted" style="font-weight:400">— для режима «ядро подключается к кабинету»</span></label>
<input type="text" name="cabinet_url" value="{{.Settings.LK.CabinetURL}}" placeholder="https://lk.example.ru" style="margin-top:4px">
<span class="muted" style="font-size:12px">Имя, по которому ядро дозванивается до кабинета. Оно же — адрес, по которому входят клиенты.</span>
</div>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:600"><input type="checkbox" name="bind_network" value="1" {{if .Settings.LK.BindNetwork}}checked{{end}}> <span>Привязывать сессию клиента к его сети <span class="muted" style="font-weight:400">— к браузеру сессия привязана всегда; эта отметка добавляет сверку сети. Строже, но клиентов с мобильным интернетом будет выбрасывать при смене адреса оператором.</span></span></label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button type="submit" class="btn btn-ok">Сохранить</button>
{{if .Settings.LK.APIKey}}<a class="btn" href="/admin/setup/lk/dist.tar.gz">Скачать дистрибутив кабинета</a>{{end}}
<a class="btn btn-secondary" href="/admin/help/lk">Инструкция по размещению</a>
</div>
</form>
{{if .Settings.LK.AgentMode}}
<div style="margin-top:14px;padding:12px 14px;border-radius:8px;background:var(--bg-soft,#f6f7f9)">
<b>Связь с кабинетом</b> <span class="dot {{if .LKAgent.Connected}}ok{{else}}err{{end}}"></span>
{{if .LKAgent.Connected}}ядро подключено к {{.LKAgent.Cabinet}}{{else}}ядро не дозвонилось до кабинета{{end}}
<div class="muted" style="font-size:12px;margin-top:4px">
{{if .LKAgent.LastOK}}Последний обмен: {{.LKAgent.LastOK}}. {{end}}Обслужено запросов: {{.LKAgent.Served}}.
{{if .LKAgent.LastErr}}<br>Ошибка: {{.LKAgent.LastErr}}{{end}}
</div>
</div>
{{end}}
<p class="muted" style="font-size:12px;margin-top:10px">В архиве: исполняемый файл, служба systemd (отдельный пользователь, ограничения доступа к файловой системе), файл настроек с правами 0600, установщик и README. Внутри архива лежит боевой ключ — после установки удалите архив с рабочего компьютера. Кабинет входит в модуль «Личный кабинет клиента» и обновляется отдельно от ядра.</p>
</div>
<div class="card">
<h2><span class="dot {{if .Settings.LK.PublicURL}}ok{{else}}warn{{end}}"></span>Личный кабинет — интеграция</h2>
<p class="muted">Адрес ЛК (стенд/бой), куда клиент подаёт заявку M2M. Заявка приходит к нам по контракту <code>POST /api/v1/back_office/claims/</code>, а мы возвращаем статус через <strong>Callback URL</strong>.</p>