Files
Bridge-and-Join-s/internal/lkgateway/lkusers.go
T
zuevav 2a7442c935 Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные
- корпоративные действия по бумагам клиента (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>
2026-09-17 23:47:10 +03:00

200 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
import (
"context"
"errors"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Пользователи Личного кабинета клиента. Хранятся в PostgreSQL bj (lk.users), пароль —
// только bcrypt-хеш. Клиент входит по логину/паролю, его портфель и заявки определяются
// номером договора (contract_number).
// LKUser — учётная запись клиента в Личном кабинете.
type LKUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
Contract string `json:"contract_number"`
FullName string `json:"full_name"`
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
MiddleName string `json:"middle_name"`
DocType string `json:"doc_type"`
DocSeries string `json:"doc_series"`
DocNumber string `json:"doc_number"`
Email string `json:"email"`
Phone string `json:"phone"`
Active bool `json:"active"`
IsAdmin bool `json:"is_admin"`
// CanTransfer — клиенту разрешена подача заявок на перевод. Для реальных
// депозитарных клиентов false (только просмотр); включаем точечно тестовым.
CanTransfer bool `json:"can_transfer"`
// ImpersonatedBy — логин администратора, если он вошёл от имени этого клиента.
ImpersonatedBy string `json:"impersonated_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at,omitempty"`
}
var errLKAuth = errors.New("неверный логин или пароль")
// lkCreateUser заводит пользователя ЛК (пароль хешируется bcrypt).
func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password string) error {
if strings.TrimSpace(u.Login) == "" || strings.TrimSpace(u.Contract) == "" {
return errors.New("логин и номер договора обязательны")
}
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
_, err = db.ExecContext(cctx, `INSERT INTO lk.users (login, password_hash, contract_number, full_name, email, phone, active,
last_name, first_name, middle_name, doc_type, doc_series, doc_number, is_admin, can_transfer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
u.Login, string(hash), 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)
return err
}
// lkSetPassword меняет пароль пользователя ЛК.
func lkSetPassword(ctx context.Context, rc *RuntimeConfig, login, password string) error {
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
res, err := db.ExecContext(cctx, "UPDATE lk.users SET password_hash=$1 WHERE login=$2", string(hash), login)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("пользователь не найден")
}
return nil
}
// lkAuth проверяет логин/пароль клиента. Возвращает пользователя при успехе.
func lkAuth(ctx context.Context, rc *RuntimeConfig, login, password string) (*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
var hash string
if err := db.QueryRowContext(cctx, `SELECT id, login, password_hash, 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 login=$1`, login).Scan(&u.ID, &u.Login, &hash, &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 {
// Логина нет. Всё равно считаем bcrypt по фиктивному хешу: иначе ответ
// приходит заметно быстрее и по времени можно перебрать существующие логины.
_ = bcrypt.CompareHashAndPassword([]byte(lkDummyHash), []byte(password))
return nil, errLKAuth // не раскрываем, что логина нет
}
if !u.Active {
return nil, errors.New("учётная запись заблокирована")
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return nil, errLKAuth
}
_, _ = db.ExecContext(cctx, "UPDATE lk.users SET last_login_at=now() WHERE id=$1", u.ID) // отметка входа, не критично
return &u, nil
}
// lkListUsers — список пользователей ЛК (для админки).
func lkListUsers(ctx context.Context, rc *RuntimeConfig) ([]LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
rows, err := db.QueryContext(cctx, `SELECT id, login, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active, is_admin, can_transfer
FROM lk.users ORDER BY login`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKUser
for rows.Next() {
var u LKUser
if err := rows.Scan(&u.ID, &u.Login, &u.Contract, &u.FullName, &u.Email, &u.Phone, &u.Active, &u.IsAdmin, &u.CanTransfer); err != nil {
return out, err
}
out = append(out, u)
}
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
}