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>
200 lines
8.4 KiB
Go
200 lines
8.4 KiB
Go
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
|
||
}
|