fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с таким именем не регистрировалось: при переименовании в `credentialStr` правка не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery превращал панику в HTTP 500 на каждый POST /api/auth/login. Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам регексп, в обход валидатора, а обработчика входа не касался ни один тест. Очевидная замена не помогла бы: цепочка правил поля обрывается на первом несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его валидатору отдельно — незарегистрированное правило паникует так же, как в бою, но на сборке. Барьер проверен возвратом исходного тега. Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба идёт в любом режиме операции и от актуальности пароля не зависит. Рядом лежали три расхождения того же класса, найденные при разборе. Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая установка проходила целиком, создавая учётную запись, под которой невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба значения теперь проверяются при разборе окружения — той стороной, которая их порождает: отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход на готовом сервере — переустановкой. Панель была строже сервера. Форма входа ограничивала пароль 32 символами при серверном пределе в 64, а форма смены пароля назначала до 64: пароль, назначенный штатной операцией, после этого не вводился. Набор символов на пароле отвергал значение, которое сервер принял бы, — сервер его не ограничивает нигде. Контракт учётных данных объявлен один раз в service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним тестами, читающими Go-исходник. Класс символов логина был записан диапазоном по опечатке: неэкранированный дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным набором это совпадало только потому, что обе стороны несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на установленных серверах. Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх, проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед «глазом». Индикация перенесена на элемент, который оператор и видит полем; чужая тень гасится селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, — конкретностью, а не !important. Остальные формы панели проверены: собственная рамка на el-form-item есть только на форме входа. Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому — UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. Обработчик входа переехал из controller/peer.go в controller/auth.go: стек в journal указывал на управление пирами. Требование теперь называется, а не сообщается фактом нарушения. «Неверный формат логина» и «Некорректное значение» не давали оператору способа узнать, что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не показан. Фразы форм и серверная причина credential_format перечисляют границы и набор. Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления — по той же причине, что и гейт детектора гонок. Каждое из его утверждений проверено мутационной пробой на реальный отказ; две первые редакции оказались вакуумными и переписаны. Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это релизный гейт сборщика. Прогон задокументирован в docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Контракт учётных данных администратора — ОДИН на весь продукт.
|
||||
//
|
||||
// Зачем он вынесен сюда. Про логин и пароль администратора одновременно знали
|
||||
// четыре места: регексп в слое контроллеров, границы длины в тегах LoginDto,
|
||||
// правило формы входа в панели и правило формы смены пароля в ней же. Ни одно
|
||||
// из них не ссылалось на остальные, и все четыре успели разойтись:
|
||||
//
|
||||
// - тег `validateStr` на Username пережил переименование правила и остался
|
||||
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
|
||||
// а gin.Recovery превращал панику в HTTP 500;
|
||||
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
|
||||
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
|
||||
// форма входа отказывалась отправлять: панель запирала оператора снаружи
|
||||
// после операции, которую сама же и предложила;
|
||||
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
|
||||
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
|
||||
// завершалась успешно, а войти в панель было нельзя.
|
||||
//
|
||||
// Общее у всех трёх — контракт существовал только как совпадение литералов,
|
||||
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
|
||||
// IsValidAdminUsername, а расхождение с панелью и оркестратором ловится
|
||||
// тестами, читающими эти же константы.
|
||||
const (
|
||||
AdminUsernameMinLength = 6
|
||||
AdminUsernameMaxLength = 32
|
||||
|
||||
// Границы пароля. Верхняя выбрана не круглым числом: bcrypt читает первые
|
||||
// 72 БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо
|
||||
// ниже — иначе два разных пароля, совпадающие в начале, окажутся одним и
|
||||
// тем же паролем.
|
||||
AdminPasswordMinLength = 6
|
||||
AdminPasswordMaxLength = 64
|
||||
)
|
||||
|
||||
// adminUsernameCharacterClass — набор символов логина в записи регекспа.
|
||||
//
|
||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||||
// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=`
|
||||
// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь
|
||||
// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и
|
||||
// установка с логином вроде `admin.ops` перестала бы пускать оператора в
|
||||
// панель. Набор и проверка на стороне оркестратора меняются только вместе.
|
||||
const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=`
|
||||
|
||||
// AdminUsernameCharset — тот же набор в том виде, в каком его показывают
|
||||
// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило
|
||||
// собственную копию того же множества.
|
||||
const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`
|
||||
|
||||
// adminUsernamePattern собирается из констант, а не пишется литералом: границы
|
||||
// длины обязаны быть одними и теми же для правила и для сообщения о нём.
|
||||
var adminUsernamePattern = regexp.MustCompile(
|
||||
"^[" + adminUsernameCharacterClass + "]{" +
|
||||
strconv.Itoa(AdminUsernameMinLength) + "," +
|
||||
strconv.Itoa(AdminUsernameMaxLength) + "}$",
|
||||
)
|
||||
|
||||
// IsValidAdminUsername сообщает, пригодно ли имя администратора.
|
||||
//
|
||||
// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты
|
||||
// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда
|
||||
// значение приходит из файла окружения, а не из DTO.
|
||||
func IsValidAdminUsername(username string) bool {
|
||||
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
// IsValidAdminPassword проверяет ТОЛЬКО границы длины.
|
||||
//
|
||||
// Набор символов пароля не ограничен, и это осознанно. Пароль назначает
|
||||
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
|
||||
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
|
||||
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
|
||||
// пароль, который сервер принял бы, то есть умеет только запереть оператора.
|
||||
//
|
||||
// Длина считается в РУНАХ: ровно так её считает go-playground/validator в
|
||||
// правилах `min`/`max` на строке, и расхождение означало бы, что пароль с
|
||||
// кириллицей проходит одну проверку и отказывается на другой.
|
||||
func IsValidAdminPassword(password string) bool {
|
||||
length := utf8.RuneCountInString(password)
|
||||
return length >= AdminPasswordMinLength && length <= AdminPasswordMaxLength
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
@@ -58,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
||||
if tokenErr != nil {
|
||||
return "", false, tokenErr
|
||||
}
|
||||
// Отметка о входе ставится здесь, а не в слое контроллеров.
|
||||
//
|
||||
// Колонка `last_login_at` объявлена и в схеме, и в entity, а писать её было
|
||||
// некому: UpdateAdminLastLoginAt существовал и не вызывался ниоткуда, то
|
||||
// есть панель показывала бы «последний вход» как 1970 год на живом сервере.
|
||||
// Место выбрано так, чтобы записать её было невозможно забыть: успешная
|
||||
// проверка пароля — единственная дверь, и она здесь одна.
|
||||
//
|
||||
// Отказ записи НЕ отменяет вход. Учётные данные уже подтверждены, и
|
||||
// превращать неудачу бухгалтерии в отказ аутентификации значило бы закрыть
|
||||
// панель из-за строки статистики. Молчать при этом тоже нельзя: неписаная
|
||||
// отметка — расхождение между тем, что показывает панель, и тем, что
|
||||
// произошло.
|
||||
if err = UpdateAdminLastLoginAt(*admin.Id, time.Now().UnixMilli()); err != nil {
|
||||
logrus.Errorf("не удалось записать время входа администратора %d: %v", *admin.Id, err)
|
||||
}
|
||||
|
||||
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
|
||||
// с поддержкой самого формата: единственный источник этого требования —
|
||||
// флаг force_password_change, который ставит установка.
|
||||
|
||||
Reference in New Issue
Block a user