fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от 64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция пакета до v0.28. Следствие: пароль из 64 кириллических букв (128 байт) проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на штатной смене пароля, а при установке падением старта админки, то есть сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта было то, что тест закреплял это значение как ожидаемое. Вместе с ним закрыты три соседних расхождения того же контракта. Пароль триммился вопреки собственному контракту. util.HashPassword вёл проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение "abcde " принимали все двери продукта и не мог захешировать никто, а первая учётная запись создавалась не с тем паролем, который оператор записал в hy2xs.env. Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы async-validator, а он сравнивает min/max с String.prototype.length: пароль из трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера, который панель не могла объяснить. hy2xs.env не был форматом. Значения писались интерполяцией, а читались split("=") с trim(); при этом файл читает не только оркестратор — он объявлен EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd срезает краевые пробелы и трактует обратный слеш как escape. Что сделано: - контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword и dao, а service импортирует util — обратный импорт был бы циклическим, и именно поэтому HashPassword завёл собственную копию правила; - AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в оркестраторе и панели; сверяется тестами, читающими Go-исходник; - одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в байтах тегом валидатора не выразить) и код причины admin_password_format, называющий обе границы; - TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет контракт сам и падает с текстом, называющим переменную и файл; - панель считает code points и UTF-8 байты общим adminPasswordFormRule на обеих формах вместо встроенных min/max; - orchestrator/src/lib/envFile.ts — порт конечного автомата parse_env_file_internal из systemd и обратный ему кодировщик; экранируются только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же кодировщиком пишется bootstrap-admin.secret; - управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а ввести такой пароль в форму входа всё равно нельзя; - отрицательная проба smoke сверяет конверт отказа (code 50000, invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль генерирует, а не берёт из литерала; - положительная проба читает bootstrap-секрет парсером формата вместо grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы. Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я», «abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами инвертирован; round-trip env-формата на значениях с кавычками, слешами и краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов приёмки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+137
-45
@@ -16,12 +16,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
@@ -174,11 +174,16 @@ func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
|
||||
{"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин длиннее максимума", strings.Repeat("a", service.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"логин длиннее максимума", strings.Repeat("a", credential.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"},
|
||||
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
|
||||
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
|
||||
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeMinLength, "pass"},
|
||||
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", service.AdminPasswordMaxLength+1), constant.ErrCodeMaxLength, "pass"},
|
||||
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeAdminPassword, "pass"},
|
||||
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", credential.AdminPasswordMaxLength+1), constant.ErrCodeAdminPassword, "pass"},
|
||||
// Укладывается в 64 символа и НЕ укладывается в 72 байта. Прежний
|
||||
// контракт принимал это значение, и отказ приходил из bcrypt уже после
|
||||
// всех проверок — системной ошибкой, а не причиной на поле формы.
|
||||
{"пароль длиннее предела bcrypt", "hy2xsadmin", strings.Repeat("я", credential.AdminPasswordMaxLength), constant.ErrCodeAdminPassword, "pass"},
|
||||
{"управляющий символ в пароле", "hy2xsadmin", "bootstrap\npassword", constant.ErrCodeAdminPassword, "pass"},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
@@ -226,13 +231,13 @@ func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
|
||||
if reason.Code != constant.ErrCodeCredentialStr {
|
||||
continue
|
||||
}
|
||||
if reason.Params["charset"] != service.AdminUsernameCharset {
|
||||
if reason.Params["charset"] != credential.AdminUsernameCharset {
|
||||
t.Errorf("причина не называет набор символов: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["min"] != strconv.Itoa(service.AdminUsernameMinLength) {
|
||||
if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) {
|
||||
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["max"] != strconv.Itoa(service.AdminUsernameMaxLength) {
|
||||
if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) {
|
||||
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
|
||||
}
|
||||
return
|
||||
@@ -242,44 +247,88 @@ func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
|
||||
|
||||
// ---------------------------------------------------------------- контракты ---
|
||||
|
||||
// Границы пароля объявлены в трёх местах и обязаны совпадать.
|
||||
// passwordFields — все поля продукта, несущие пароль администратора.
|
||||
var passwordFields = []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
field string
|
||||
}{
|
||||
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
|
||||
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
|
||||
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
|
||||
}
|
||||
|
||||
// Правило пароля ОДНО, и границы живут внутри него.
|
||||
//
|
||||
// Пока они расходились, панель запирала оператора снаружи: форма смены пароля
|
||||
// принимала 64 символа, форма входа — 32. Тег структуры не умеет ссылаться на
|
||||
// константу, поэтому равенство удерживается здесь.
|
||||
func TestPasswordBoundsAreOneContract(t *testing.T) {
|
||||
for _, subject := range []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
field string
|
||||
}{
|
||||
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
|
||||
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
|
||||
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
|
||||
} {
|
||||
// Здесь стояли `min=6,max=64` прямо в тегах, и проверка следила за тем, чтобы
|
||||
// числа совпадали с контрактом. Совпадали они честно — и всё равно закрепляли
|
||||
// неполное правило: тег считает символы Unicode, а bcrypt считает байты, и
|
||||
// границы в байтах тегом не выразить вовсе. Поэтому теперь проверяется не
|
||||
// равенство чисел, а ОТСУТСТВИЕ чисел: длина принадлежит правилу, а не соседям
|
||||
// по тегу. Тот же барьер уже стоит на логине (TestUsernameLengthLivesInsideItsRule).
|
||||
func TestPasswordLengthLivesInsideItsRule(t *testing.T) {
|
||||
for _, subject := range passwordFields {
|
||||
field, ok := subject.typ.FieldByName(subject.field)
|
||||
if !ok {
|
||||
t.Fatalf("%s: поле не найдено", subject.name)
|
||||
}
|
||||
|
||||
bounds := map[string]int{}
|
||||
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
|
||||
name, param, found := strings.Cut(rule, "=")
|
||||
if !found || (name != "min" && name != "max") {
|
||||
continue
|
||||
rules := splitValidationRules(field.Tag.Get("validate"))
|
||||
|
||||
for _, rule := range rules {
|
||||
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
|
||||
t.Errorf(
|
||||
"%s: отдельная граница %q рядом с правилом. Длина принадлежит adminPassword: "+
|
||||
"тег умеет считать только символы, а у пароля есть ещё граница в байтах",
|
||||
subject.name, rule,
|
||||
)
|
||||
}
|
||||
value, err := strconv.Atoi(param)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: граница %q не число", subject.name, rule)
|
||||
}
|
||||
bounds[name] = value
|
||||
}
|
||||
|
||||
if bounds["min"] != service.AdminPasswordMinLength {
|
||||
t.Errorf("%s: min=%d, а контракт — %d", subject.name, bounds["min"], service.AdminPasswordMinLength)
|
||||
var hasPasswordRule bool
|
||||
for _, rule := range rules {
|
||||
if rule == "adminPassword" {
|
||||
hasPasswordRule = true
|
||||
}
|
||||
}
|
||||
if bounds["max"] != service.AdminPasswordMaxLength {
|
||||
t.Errorf("%s: max=%d, а контракт — %d", subject.name, bounds["max"], service.AdminPasswordMaxLength)
|
||||
if !hasPasswordRule {
|
||||
t.Errorf("%s: проверяется правилами %v — среди них нет adminPassword", subject.name, rules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Все поля пароля проверяются ТЕМ ЖЕ правилом, что и bootstrap-установка.
|
||||
//
|
||||
// Проверка идёт через production-валидатор на граничных значениях: совпадения
|
||||
// имён тегов мало, потому что именно расхождение реализаций за одинаковыми
|
||||
// именами и было исходным дефектом.
|
||||
func TestEveryPasswordFieldFollowsTheContract(t *testing.T) {
|
||||
samples := []string{
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength-1),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("я", 37),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat("😀", 19),
|
||||
strings.Repeat("я", credential.AdminPasswordMaxLength),
|
||||
"abcde ",
|
||||
"abcde\n",
|
||||
}
|
||||
|
||||
for _, subject := range passwordFields {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
rules := strings.Join(splitValidationRules(field.Tag.Get("validate")), ",")
|
||||
rules = strings.ReplaceAll(rules, "required,", "")
|
||||
|
||||
for _, sample := range samples {
|
||||
expected := credential.IsValidAdminPassword(sample)
|
||||
if got := validate.Var(sample, rules) == nil; got != expected {
|
||||
t.Errorf(
|
||||
"%s: значение из %d символов / %d байт принято=%v, контракт=%v",
|
||||
subject.name, len([]rune(sample)), len(sample), got, expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,15 +340,7 @@ func TestPasswordBoundsAreOneContract(t *testing.T) {
|
||||
// который сервер принял бы. Панель обязана держаться этого же контракта, и
|
||||
// проверка существует, чтобы правило не «навели» обратно.
|
||||
func TestPasswordHasNoCharsetRule(t *testing.T) {
|
||||
for _, subject := range []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
field string
|
||||
}{
|
||||
{"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"},
|
||||
{"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"},
|
||||
{"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"},
|
||||
} {
|
||||
for _, subject := range passwordFields {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
|
||||
if rule == "credentialStr" || rule == "peerName" {
|
||||
@@ -556,7 +597,7 @@ func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
|
||||
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
|
||||
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
|
||||
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
|
||||
password := strings.Repeat("a", service.AdminPasswordMaxLength)
|
||||
password := strings.Repeat("a", credential.AdminPasswordMaxLength)
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
@@ -565,6 +606,57 @@ func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль, упирающийся в предел bcrypt ровно, обязан работать целиком: он
|
||||
// назначается формой смены пароля и вводится формой входа.
|
||||
//
|
||||
// 36 кириллических букв — это ровно 72 байта, то есть последнее значение,
|
||||
// которое GenerateFromPassword принимает. Проверяется весь путь: хеширование
|
||||
// при создании учётной записи и вход этим же значением.
|
||||
func TestLoginEndpointAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
|
||||
password := strings.Repeat("я", 36)
|
||||
if len(password) != credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль в 72 байта отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// А на символ длиннее — отказ ПРАВИЛА, с причиной на поле, а не системная
|
||||
// ошибка из bcrypt.
|
||||
//
|
||||
// Ровно этого не было: 37 кириллических букв (74 байта) укладывались в 64
|
||||
// символа, проходили все проверки и упирались в ErrPasswordTooLong уже внутри
|
||||
// хеширования. На форме смены пароля оператор видел «system error».
|
||||
func TestLoginEndpointRejectsPasswordOverBcryptByteLimitWithFieldReason(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
password := strings.Repeat("я", 37)
|
||||
if len(password) <= credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт, ожидалось больше %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
status, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("HTTP %d: обработчик обязан отвечать конвертом", status)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("код ответа %d, ожидался отказ проверки данных %d: %+v", result.Code, constant.CodeInvalidError, result)
|
||||
}
|
||||
var hasReason bool
|
||||
for _, reason := range result.Errors {
|
||||
if reason.Field == "pass" && reason.Code == constant.ErrCodeAdminPassword {
|
||||
hasReason = true
|
||||
}
|
||||
}
|
||||
if !hasReason {
|
||||
t.Fatalf("отказ не назван причиной %q на поле pass: %+v", constant.ErrCodeAdminPassword, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Отметка о входе обязана писаться: колонка есть в схеме, а писать её было
|
||||
// некому — UpdateAdminLastLoginAt не вызывался ниоткуда.
|
||||
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user