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

Данные
- корпоративные действия по бумагам клиента (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
+55 -4
View File
@@ -45,8 +45,8 @@ func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password str
if strings.TrimSpace(u.Login) == "" || strings.TrimSpace(u.Contract) == "" {
return errors.New("логин и номер договора обязательны")
}
if len(password) < 6 {
return errors.New("пароль минимум 6 символов")
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -68,8 +68,8 @@ func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password str
// lkSetPassword меняет пароль пользователя ЛК.
func lkSetPassword(ctx context.Context, rc *RuntimeConfig, login, password string) error {
if len(password) < 6 {
return errors.New("пароль минимум 6 символов")
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -109,6 +109,9 @@ func lkAuth(ctx context.Context, rc *RuntimeConfig, login, password string) (*LK
&u.FullName, &u.Email, &u.Phone, &u.Active,
&u.LastName, &u.FirstName, &u.MiddleName,
&u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil {
// Логина нет. Всё равно считаем bcrypt по фиктивному хешу: иначе ответ
// приходит заметно быстрее и по времени можно перебрать существующие логины.
_ = bcrypt.CompareHashAndPassword([]byte(lkDummyHash), []byte(password))
return nil, errLKAuth // не раскрываем, что логина нет
}
if !u.Active {
@@ -146,3 +149,51 @@ func lkListUsers(ctx context.Context, rc *RuntimeConfig) ([]LKUser, error) {
}
return out, rows.Err()
}
// lkDummyHash — заведомо не подходящий bcrypt-хеш: по нему считаем проверку, когда
// логина нет, чтобы ответ занимал столько же времени, сколько при существующем логине.
const lkDummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// lkCheckPassword — требования к паролю кабинета: в кабинете видны паспортные данные
// и остатки на счетах депо, поэтому шести символов мало.
func lkCheckPassword(password string) error {
if len([]rune(password)) < 10 {
return errors.New("пароль минимум 10 символов")
}
var hasLetter, hasOther bool
for _, r := range password {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= 'а' && r <= 'я') || (r >= 'А' && r <= 'Я'):
hasLetter = true
default:
hasOther = true
}
}
if !hasLetter || !hasOther {
return errors.New("пароль должен содержать буквы и хотя бы одну цифру или знак")
}
return nil
}
// lkUserByID — карточка клиента по идентификатору. Нужна сессиям: на диске хранится
// только ссылка на учётную запись, а сами данные читаются отсюда на каждый запрос.
func lkUserByID(ctx context.Context, rc *RuntimeConfig, id int64) (*LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var u LKUser
if err := db.QueryRowContext(cctx, `SELECT id, login, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active,
COALESCE(last_name,''), COALESCE(first_name,''), COALESCE(middle_name,''),
COALESCE(doc_type,''), COALESCE(doc_series,''), COALESCE(doc_number,''), is_admin, can_transfer
FROM lk.users WHERE id=$1`, id).Scan(&u.ID, &u.Login, &u.Contract,
&u.FullName, &u.Email, &u.Phone, &u.Active,
&u.LastName, &u.FirstName, &u.MiddleName,
&u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil {
return nil, errors.New("учётная запись не найдена")
}
return &u, nil
}