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 } // lkFilterUsers — учётки кабинета, подходящие под строку поиска сотрудника // (часть логина, номера договора, ФИО или почты; регистр не важен). func lkFilterUsers(list []LKUser, search string) []LKUser { q := strings.ToLower(strings.TrimSpace(search)) if q == "" { return list } out := make([]LKUser, 0, len(list)) for _, u := range list { hay := strings.ToLower(u.Login + " " + u.Contract + " " + u.FullName + " " + u.Email) if strings.Contains(hay, q) { out = append(out, u) } } return out } // lkTimeRU — время для кабинета: «18.09.2026 19:40», пусто — если не было. func lkTimeRU(t time.Time) string { if t.IsZero() { return "" } return t.Local().Format("02.01.2006 15:04") }