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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
@@ -33,6 +34,7 @@ func init() {
|
||||
|
||||
mustRegister("peerName", validatePeerName)
|
||||
mustRegister("credentialStr", validateCredentialStr)
|
||||
mustRegister("adminPassword", validateAdminPassword)
|
||||
}
|
||||
|
||||
func mustRegister(tag string, fn validator.Func) {
|
||||
@@ -74,7 +76,23 @@ func validatePeerName(f validator.FieldLevel) bool {
|
||||
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||
// у него на самом деле.
|
||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||
return service.IsValidAdminUsername(f.Field().String())
|
||||
return credential.IsValidAdminUsername(f.Field().String())
|
||||
}
|
||||
|
||||
// validateAdminPassword — единственное правило пароля администратора.
|
||||
//
|
||||
// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от
|
||||
// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри
|
||||
// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы
|
||||
// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв —
|
||||
// 128 байт — проходил проверку целиком и падал только в хешировании, уже как
|
||||
// системная ошибка на штатной смене пароля.
|
||||
//
|
||||
// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах.
|
||||
// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы
|
||||
// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна.
|
||||
func validateAdminPassword(f validator.FieldLevel) bool {
|
||||
return credential.IsValidAdminPassword(f.Field().String())
|
||||
}
|
||||
|
||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||
@@ -208,13 +226,31 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
// отказ по длине этой фразой описывался как отказ по символам.
|
||||
described.Code = constant.ErrCodeCredentialStr
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", service.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", service.AdminUsernameMaxLength),
|
||||
"charset": service.AdminUsernameCharset,
|
||||
"min": fmt.Sprintf("%d", credential.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength),
|
||||
"charset": credential.AdminUsernameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов из набора %s",
|
||||
field, service.AdminUsernameMinLength, service.AdminUsernameMaxLength, service.AdminUsernameCharset,
|
||||
field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset,
|
||||
)
|
||||
case "adminPassword":
|
||||
// Причина называет ОБЕ границы, потому что их две и они в разных
|
||||
// единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был
|
||||
// бы неправдой: символов там 40, а байтов 160, и оператор, послушав
|
||||
// такую причину, сокращал бы пароль, который отвергнут не за это.
|
||||
described.Code = constant.ErrCodeAdminPassword
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", credential.AdminPasswordMinLength),
|
||||
"max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength),
|
||||
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов",
|
||||
field,
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
default:
|
||||
described.Code = constant.ErrCodeRuleUnknown
|
||||
|
||||
@@ -4,99 +4,67 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||||
// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||||
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||
// оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
//
|
||||
// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило
|
||||
// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает
|
||||
// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть
|
||||
// не увидела бы расхождения между копией и правилом.
|
||||
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !service.IsValidAdminUsername(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"", // не задано
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и
|
||||
// потому не увидели бы расхождения между копией и правилом — а именно так этот
|
||||
// класс дефектов и появлялся: набор символов логина существовал в четырёх
|
||||
// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential;
|
||||
// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают
|
||||
// одинаково на одних и тех же значениях.
|
||||
func TestCredentialStrTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"hy2xsadmin",
|
||||
"admin.ops",
|
||||
"admin+1",
|
||||
"admi",
|
||||
"админ12",
|
||||
"admin пробел",
|
||||
strings.Repeat("a", credential.AdminUsernameMaxLength+1),
|
||||
} {
|
||||
if service.IsValidAdminUsername(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
expected := credential.IsValidAdminUsername(candidate)
|
||||
if got := validate.Var(candidate, "credentialStr") == nil; got != expected {
|
||||
t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
||||
// обязан описывать тот же класс, что и регексп.
|
||||
// Тот же вопрос про пароль, и он важнее.
|
||||
//
|
||||
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
||||
// оператор читает набор, которого правило не принимает.
|
||||
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
|
||||
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength-1)) {
|
||||
t.Errorf("логин длиной %d принят при минимуме %d", service.AdminUsernameMinLength-1, service.AdminUsernameMinLength)
|
||||
}
|
||||
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMinLength)
|
||||
}
|
||||
if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMaxLength)
|
||||
}
|
||||
if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength+1)) {
|
||||
t.Errorf("логин длиной %d принят при максимуме %d", service.AdminUsernameMaxLength+1, service.AdminUsernameMaxLength)
|
||||
}
|
||||
|
||||
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
|
||||
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
|
||||
punctuation := strings.Fields(service.AdminUsernameCharset)
|
||||
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
|
||||
if !service.IsValidAdminUsername("admin" + symbol) {
|
||||
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
|
||||
// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить
|
||||
// предел bcrypt в байтах — правила длины в байтах у go-playground/validator
|
||||
// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и
|
||||
// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка
|
||||
// закрепляет, что правило теперь одно и что оно то же самое, которым
|
||||
// пользуются bootstrap-установка и `reset-admin`.
|
||||
func TestAdminPasswordTagDelegatesToContract(t *testing.T) {
|
||||
for _, candidate := range []string{
|
||||
"bootstrap-password",
|
||||
"abcde ",
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength-1),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength+1),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("я", 37),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat("😀", 19),
|
||||
strings.Repeat("я", credential.AdminPasswordMaxLength),
|
||||
"abcde\n",
|
||||
} {
|
||||
expected := credential.IsValidAdminPassword(candidate)
|
||||
if got := validate.Var(candidate, "adminPassword") == nil; got != expected {
|
||||
t.Errorf(
|
||||
"тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v",
|
||||
len([]rune(candidate)), len(candidate), got, expected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина пароля считается в РУНАХ — ровно так её считает go-playground/validator
|
||||
// в правилах `min`/`max` на строке.
|
||||
//
|
||||
// Расхождение означало бы, что пароль с кириллицей проходит проверку на одной
|
||||
// двери и отказывается на другой: 32 кириллические буквы — это 64 БАЙТА.
|
||||
func TestAdminPasswordLengthIsCountedInRunes(t *testing.T) {
|
||||
if !service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength)) {
|
||||
t.Errorf("пароль из %d кириллических букв отклонён: длина считается в байтах", service.AdminPasswordMaxLength)
|
||||
}
|
||||
if service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength+1)) {
|
||||
t.Error("пароль длиннее максимума принят")
|
||||
}
|
||||
if service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength-1)) {
|
||||
t.Error("пароль короче минимума принят")
|
||||
}
|
||||
if !service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength)) {
|
||||
t.Error("пароль длиной ровно в минимум отклонён")
|
||||
}
|
||||
}
|
||||
|
||||
// Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в
|
||||
// одну таблицу и не имеют права требовать разного.
|
||||
func TestPeerNameRuleIsSharedWithImport(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Package credential объявляет контракт учётных данных администратора — ОДИН
|
||||
// на весь продукт.
|
||||
//
|
||||
// Почему это отдельный пакет, а не файл в service. Контракт нужен четырём
|
||||
// слоям сразу, и два из них лежат НИЖЕ service по графу импортов:
|
||||
//
|
||||
// controller -> service -> util (хеширование пароля)
|
||||
// dao -> util (создание первой учётной записи)
|
||||
// cmd -> dao, util (reset-admin)
|
||||
//
|
||||
// Пока контракт жил в service, `util.HashPassword` не мог его позвать — импорт
|
||||
// был бы циклическим, — и завёл собственную проверку `len(TrimSpace(p)) < 6`.
|
||||
// Эта третья копия правила разошлась с остальными ровно тем способом, ради
|
||||
// предотвращения которого контракт и объявлен: LoginDto считал `"abcde "`
|
||||
// корректным паролем, а HashPassword отказывался его хешировать. Пакет-лист
|
||||
// ниже всех потребителей — единственная запись, при которой у правила
|
||||
// физически не может появиться второго экземпляра.
|
||||
package credential
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Контракт учётных данных администратора.
|
||||
//
|
||||
// Про логин и пароль администратора одновременно знали четыре места: регексп в
|
||||
// слое контроллеров, границы длины в тегах LoginDto, правило формы входа в
|
||||
// панели и правило формы смены пароля в ней же. Ни одно из них не ссылалось на
|
||||
// остальные, и все четыре успели разойтись:
|
||||
//
|
||||
// - тег `validateStr` на Username пережил переименование правила и остался
|
||||
// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе,
|
||||
// а gin.Recovery превращал панику в HTTP 500;
|
||||
// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64,
|
||||
// сервер — тоже 64. То есть пароль, назначенный штатной формой смены,
|
||||
// форма входа отказывалась отправлять: панель запирала оператора снаружи
|
||||
// после операции, которую сама же и предложила;
|
||||
// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в
|
||||
// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка
|
||||
// завершалась успешно, а войти в панель было нельзя.
|
||||
//
|
||||
// Общее у всех трёх — контракт существовал только как совпадение литералов,
|
||||
// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт
|
||||
// IsValidAdminUsername и IsValidAdminPassword, а расхождение с панелью и
|
||||
// оркестратором ловится тестами, читающими этот же файл.
|
||||
const (
|
||||
AdminUsernameMinLength = 6
|
||||
AdminUsernameMaxLength = 32
|
||||
|
||||
// Границы пароля в СИМВОЛАХ Unicode (code points).
|
||||
AdminPasswordMinLength = 6
|
||||
AdminPasswordMaxLength = 64
|
||||
|
||||
// AdminPasswordMaxBytes — предел, установленный bcrypt.
|
||||
//
|
||||
// Он объявлен ОТДЕЛЬНО от AdminPasswordMaxLength, потому что это ДРУГАЯ
|
||||
// единица измерения, и подменять одну другой нельзя.
|
||||
//
|
||||
// Здесь стоял ровно этот дефект. Верхняя граница в 64 символа была выбрана
|
||||
// «заведомо ниже 72 байт», и обоснование выглядело убедительно, пока пароль
|
||||
// оставался ASCII. Но `golang.org/x/crypto/bcrypt` считает БАЙТЫ:
|
||||
//
|
||||
// 64 x "a" = 64 байта -> GenerateFromPassword принимает
|
||||
// 64 x "я" = 128 байт -> ErrPasswordTooLong
|
||||
// 19 x "😀" = 76 байт -> ErrPasswordTooLong
|
||||
//
|
||||
// причём отказ приходил НЕ на форме, а из хеширования: смена пароля на 64
|
||||
// кириллические буквы проходила все проверки продукта и заканчивалась
|
||||
// системной ошибкой на штатной операции. Комментарий в прежней редакции
|
||||
// вдобавок утверждал, что bcrypt «молча отбрасывает остаток» — так вело
|
||||
// себя издание пакета до v0.28; действующее (v0.55.0, bcrypt.go:96)
|
||||
// отвечает ошибкой:
|
||||
//
|
||||
// if len(password) > 72 { return nil, ErrPasswordTooLong }
|
||||
AdminPasswordMaxBytes = 72
|
||||
)
|
||||
|
||||
// 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 — единственное правило пароля администратора.
|
||||
//
|
||||
// Правило состоит из трёх частей и НИ ОДНА из них не является набором
|
||||
// символов:
|
||||
//
|
||||
// 6-64 code points — граница, которую видит оператор;
|
||||
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
|
||||
// без управляющих — граница, которую ставит транспорт.
|
||||
//
|
||||
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
|
||||
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
|
||||
// видит оператор: «пароль из 64 символов» обязано означать одно и то же для
|
||||
// латиницы и для кириллицы.
|
||||
//
|
||||
// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение
|
||||
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
|
||||
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
|
||||
//
|
||||
// Управляющие символы запрещены, и это единственное ограничение НАБОРА в
|
||||
// контракте. Оно введено не ради строгости: первый пароль администратора
|
||||
// уезжает в /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile, и
|
||||
// у перевода строки там нет представления, переживающего запись и чтение. При
|
||||
// этом такой пароль всё равно невозможно ввести в однострочное поле формы
|
||||
// входа, то есть он умеет ровно одно — запереть оператора снаружи. Запрет
|
||||
// делает транспорт ПОЛНЫМ: всякий пароль, принятый этим правилом, доезжает до
|
||||
// bcrypt побайтово тем же, каким его задал оператор.
|
||||
//
|
||||
// Набор символов НЕ ограничивается ничем сверх этого. Пароль назначает
|
||||
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
|
||||
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
|
||||
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
|
||||
// пароль, который сервер принял бы.
|
||||
//
|
||||
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
|
||||
// нормализации DTO, ни при чтении bootstrap-окружения.
|
||||
func IsValidAdminPassword(password string) bool {
|
||||
runes := utf8.RuneCountInString(password)
|
||||
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
|
||||
return false
|
||||
}
|
||||
if len(password) > AdminPasswordMaxBytes {
|
||||
return false
|
||||
}
|
||||
return !hasControlRune(password)
|
||||
}
|
||||
|
||||
// hasControlRune сообщает, есть ли в строке управляющий символ.
|
||||
//
|
||||
// Проверяются C0 (U+0000-U+001F) и DEL (U+007F). Управляющие символы Unicode
|
||||
// вне ASCII сюда не попадают намеренно: у systemd границей строки является
|
||||
// байт `\n` или `\r`, и U+2028 для него — три обычных байта, а не перевод
|
||||
// строки. Правило описывает ровно то, что ломает транспорт, и ни символом
|
||||
// больше.
|
||||
func hasControlRune(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 0x20 || r == 0x7F {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||||
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||
// оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !IsValidAdminUsername(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"", // не задано
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
} {
|
||||
if IsValidAdminUsername(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
||||
// обязан описывать тот же класс, что и регексп.
|
||||
//
|
||||
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
||||
// оператор читает набор, которого правило не принимает.
|
||||
func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) {
|
||||
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) {
|
||||
t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength)
|
||||
}
|
||||
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength)
|
||||
}
|
||||
if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) {
|
||||
t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength)
|
||||
}
|
||||
if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) {
|
||||
t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength)
|
||||
}
|
||||
|
||||
// Человекочитаемый набор перечисляет ровно те же знаки препинания, что и
|
||||
// класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ.
|
||||
punctuation := strings.Fields(AdminUsernameCharset)
|
||||
for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") {
|
||||
if !IsValidAdminUsername("admin" + symbol) {
|
||||
t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные
|
||||
// границы.
|
||||
//
|
||||
// Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
|
||||
// байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок
|
||||
// требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым
|
||||
// фиксировал как ожидаемое ровно то значение, на котором продукт ломался:
|
||||
// оркестратор его пропускал, DTO пропускало, а bcrypt отвечал
|
||||
// ErrPasswordTooLong уже при создании учётной записи.
|
||||
func TestAdminPasswordBoundaries(t *testing.T) {
|
||||
// Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» —
|
||||
// четырёхбайтовым, случаи ниже проверяли бы не то, что написано.
|
||||
if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
|
||||
}
|
||||
if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
password string
|
||||
accepted bool
|
||||
}{
|
||||
{"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true},
|
||||
{"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false},
|
||||
{"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true},
|
||||
{"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false},
|
||||
|
||||
// Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов.
|
||||
{"36 x «я» = 72 байта", strings.Repeat("я", 36), true},
|
||||
{"37 x «я» = 74 байта", strings.Repeat("я", 37), false},
|
||||
{"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true},
|
||||
{"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false},
|
||||
|
||||
// Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА.
|
||||
{"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false},
|
||||
|
||||
// Пробелы по краям — часть пароля, а не мусор ввода.
|
||||
{"пробел на конце", "abcde ", true},
|
||||
{"пробел в начале", " abcde", true},
|
||||
{"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true},
|
||||
|
||||
// Набор символов не ограничен ничем, кроме управляющих.
|
||||
{"кириллица", "пароль-администратора", true},
|
||||
{"знаки препинания", `p@$$w0rd"\'#;`, true},
|
||||
{"перевод строки", "abcde\n", false},
|
||||
{"возврат каретки", "abcde\r", false},
|
||||
{"табуляция", "abcde\t", false},
|
||||
{"нулевой байт", "abcde\x00", false},
|
||||
{"DEL", "abcde\x7f", false},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := IsValidAdminPassword(testCase.password); got != testCase.accepted {
|
||||
t.Errorf(
|
||||
"IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)",
|
||||
got, testCase.accepted,
|
||||
utf8.RuneCountInString(testCase.password), len(testCase.password),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Предел в байтах не выводится из предела в символах, и обратное неверно тоже.
|
||||
//
|
||||
// Проверка защищает от «упрощения», при котором одну из двух констант объявят
|
||||
// производной от другой: у 64 символов длина от 64 до 256 байт.
|
||||
func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
|
||||
if AdminPasswordMaxBytes != 72 {
|
||||
t.Fatalf(
|
||||
"предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72",
|
||||
AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// Пароль, укладывающийся в символы и не укладывающийся в байты, обязан
|
||||
// существовать — иначе байтовая граница недостижима и ничего не проверяет.
|
||||
long := strings.Repeat("я", AdminPasswordMaxLength)
|
||||
if utf8.RuneCountInString(long) > AdminPasswordMaxLength {
|
||||
t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница")
|
||||
}
|
||||
if IsValidAdminPassword(long) {
|
||||
t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится нигде, включая сам контракт.
|
||||
//
|
||||
// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace,
|
||||
// значение `"abcde "` считалось корректным всеми дверями продукта и не могло
|
||||
// быть захешировано.
|
||||
func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) {
|
||||
// Шесть символов, из которых последний — пробел. По длине без пробела это
|
||||
// пять символов, то есть прежняя проверка отказала бы.
|
||||
const password = "abcde "
|
||||
if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength {
|
||||
t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то")
|
||||
}
|
||||
if !IsValidAdminPassword(password) {
|
||||
t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/util"
|
||||
)
|
||||
|
||||
// Bootstrap-путь администратора обязан подчиняться ОБЩЕМУ контракту пароля.
|
||||
//
|
||||
// Здесь было два расхождения, и оба ломали установку молча.
|
||||
//
|
||||
// Первое: `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Продукт
|
||||
// объявляет пробел по краям частью пароля и не снимает его ни на форме входа,
|
||||
// ни при смене, ни в оркестраторе — а здесь снимал. Учётная запись создавалась
|
||||
// с паролем, отличным от того, который оператор записал в hy2xs.env и которым
|
||||
// он потом пытался войти.
|
||||
//
|
||||
// Второе: контракт не проверялся вовсе, поэтому непригодный для bcrypt пароль
|
||||
// (64 кириллические буквы = 128 байт) доходил до GenerateFromPassword, и старт
|
||||
// службы падал с «bcrypt: password length exceeds 72 bytes» — сообщением, по
|
||||
// которому нельзя понять ни что чинить, ни где.
|
||||
|
||||
func startAdminBootstrap(t *testing.T, password string) error {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("HY2XS_ADMIN_USER", "hy2xsadmin")
|
||||
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", password)
|
||||
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||
|
||||
err := InitSqlAt(filepath.Join(t.TempDir(), "hy2xs-admin-test.db"))
|
||||
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||
return err
|
||||
}
|
||||
|
||||
// Пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом.
|
||||
func TestBootstrapAdminKeepsSurroundingWhitespaceInPassword(t *testing.T) {
|
||||
const password = "bootstrap-admin-password "
|
||||
|
||||
if err := startAdminBootstrap(t, password); err != nil {
|
||||
t.Fatalf("запуск сервиса не удался: %v", err)
|
||||
}
|
||||
|
||||
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись администратора не создана: %v", err)
|
||||
}
|
||||
if admin.PasswordHash == nil {
|
||||
t.Fatal("у созданной учётной записи нет хеша пароля")
|
||||
}
|
||||
|
||||
if !util.VerifyPassword(password, *admin.PasswordHash) {
|
||||
t.Error("вход исходным значением невозможен: пароль обрезан при создании учётной записи")
|
||||
}
|
||||
if util.VerifyPassword(strings.TrimSpace(password), *admin.PasswordHash) {
|
||||
t.Error("вход обрезанным значением возможен: пробел по краям снят вопреки контракту")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль, не проходящий контракт, роняет старт с ПРИЧИНОЙ, а не с ошибкой
|
||||
// bcrypt, и учётной записи после этого не остаётся.
|
||||
func TestBootstrapAdminRefusesPasswordOutsideTheContract(t *testing.T) {
|
||||
// 64 символа — в границе; 128 байт — за пределом bcrypt.
|
||||
password := strings.Repeat("я", credential.AdminPasswordMaxLength)
|
||||
if credential.IsValidAdminPassword(password) {
|
||||
t.Fatal("подготовка: контракт принимает значение, проверяется не то")
|
||||
}
|
||||
|
||||
err := startAdminBootstrap(t, password)
|
||||
if err == nil {
|
||||
t.Fatal("старт с непригодным паролем завершился успешно")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HY2XS_ADMIN_INITIAL_PASSWORD") {
|
||||
t.Errorf("отказ не называет переменную, которую надо чинить: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hy2xs.env") {
|
||||
t.Errorf("отказ не называет файл, в котором чинится причина: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль ровно в предел bcrypt (72 байта) — законное значение, и установка с
|
||||
// ним обязана проходить целиком.
|
||||
func TestBootstrapAdminAcceptsPasswordAtBcryptByteLimit(t *testing.T) {
|
||||
password := strings.Repeat("я", 36)
|
||||
if len(password) != credential.AdminPasswordMaxBytes {
|
||||
t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes)
|
||||
}
|
||||
|
||||
if err := startAdminBootstrap(t, password); err != nil {
|
||||
t.Fatalf("установка с паролем в 72 байта не удалась: %v", err)
|
||||
}
|
||||
|
||||
admin, err := GetAdminUser("username = ?", "hy2xsadmin")
|
||||
if err != nil {
|
||||
t.Fatalf("учётная запись администратора не создана: %v", err)
|
||||
}
|
||||
if !util.VerifyPassword(password, *admin.PasswordHash) {
|
||||
t.Error("пароль в 72 байта не проходит проверку собственным хешем")
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -3,11 +3,13 @@ package dao
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
"hy2xs-admin/credential"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/util"
|
||||
@@ -132,7 +134,14 @@ func ensureSecureBootstrapAdmin() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))
|
||||
// Пароль читается КАК ЕСТЬ.
|
||||
//
|
||||
// Здесь стоял strings.TrimSpace, и он противоречил контракту продукта:
|
||||
// пробел по краям объявлен частью пароля и не снимается ни на форме входа,
|
||||
// ни при смене пароля, ни в оркестраторе. То есть bootstrap-путь
|
||||
// гарантированно создавал учётную запись НЕ с тем паролем, который оператор
|
||||
// записал в hy2xs.env, а войти в неё он пытался бы исходным значением.
|
||||
adminPassword := os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")
|
||||
if adminPassword == "" {
|
||||
return errors.New(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" +
|
||||
@@ -144,6 +153,28 @@ func ensureSecureBootstrapAdmin() error {
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
)
|
||||
}
|
||||
|
||||
// Контракт проверяется ЗДЕСЬ, а не только при хешировании.
|
||||
//
|
||||
// Отказ bcrypt на непригодном пароле выглядел бы как «bcrypt: password
|
||||
// length exceeds 72 bytes» в journal — сообщение, по которому оператор не
|
||||
// поймёт ни что чинить, ни где. Причина же чинится одной строкой в
|
||||
// hy2xs.env, и назвать её обязан тот, кто знает про этот файл.
|
||||
if !credential.IsValidAdminPassword(adminPassword) {
|
||||
return fmt.Errorf(
|
||||
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
|
||||
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов.\n"+
|
||||
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
|
||||
"Учётная запись администратора с таким значением создана НЕ будет: "+
|
||||
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
|
||||
"Исправьте значение в /etc/hy2xs/hy2xs.env и запустите\n"+
|
||||
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
|
||||
status := int64(1)
|
||||
tokenVersion := int64(1)
|
||||
@@ -709,6 +740,7 @@ func tableExists(tableName string) bool {
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
|
||||
// придумывает его сам.
|
||||
//
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* принял бы, — панель была строже сервера там, где она не имеет на это права.
|
||||
*
|
||||
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
|
||||
* (apps/service/admin_credentials.go) тестом tools/test/frontend-contract.test.ts.
|
||||
* (apps/credential/admin.go) тестом tools/test/frontend-contract.test.ts.
|
||||
*/
|
||||
|
||||
export const ADMIN_USERNAME_MIN_LENGTH = 6;
|
||||
@@ -41,9 +41,98 @@ export const ADMIN_USERNAME_PATTERN = new RegExp(
|
||||
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
|
||||
|
||||
/**
|
||||
* Границы пароля. Набора символов у пароля НЕТ: сервер его не ограничивает ни
|
||||
* при установке, ни при смене, и панель не имеет права отвергать значение,
|
||||
* которое сервер принял бы.
|
||||
* Границы пароля в СИМВОЛАХ Unicode. Набора символов у пароля НЕТ: сервер его
|
||||
* не ограничивает ни при установке, ни при смене, и панель не имеет права
|
||||
* отвергать значение, которое сервер принял бы.
|
||||
*/
|
||||
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
|
||||
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Предел bcrypt — в БАЙТАХ, и он существует одновременно с границей в символах.
|
||||
*
|
||||
* `golang.org/x/crypto/bcrypt` отвечает `ErrPasswordTooLong` на пароль длиннее
|
||||
* 72 байт. Через границу в символах это не выражается: у 64 символов длина от
|
||||
* 64 до 256 байт в зависимости от алфавита.
|
||||
*/
|
||||
export const ADMIN_PASSWORD_MAX_BYTES = 72;
|
||||
|
||||
/**
|
||||
* Единственное правило пароля на стороне панели.
|
||||
*
|
||||
* Почему не встроенные `min`/`max` Element Plus. Правила формы Element Plus
|
||||
* делегирует библиотеке `async-validator`, а она сравнивает `min`/`max` строки
|
||||
* с `String.prototype.length`, то есть с числом единиц UTF-16. Go и оркестратор
|
||||
* считают code points. Расхождение не теоретическое:
|
||||
*
|
||||
* "😀😀😀" -> Go: 3 руны -> сервер отказывает (минимум 6)
|
||||
* JS: value.length = 6 -> форма считает минимум достигнутым
|
||||
*
|
||||
* то есть панель отправляла бы заведомо отвергаемый пароль и не могла бы
|
||||
* объяснить отказ. Обратная сторона того же — пароль из 40 эмодзи: 40 code
|
||||
* points укладываются в границу символов и не укладываются в 72 байта.
|
||||
*
|
||||
* Поэтому правило одно и считает ровно то же, что сервер: code points через
|
||||
* итератор строки (он идёт по code points, а не по единицам UTF-16) и байты
|
||||
* через `TextEncoder`, который кодирует именно в UTF-8.
|
||||
*
|
||||
* Управляющие символы (C0 и DEL) — единственное ограничение НАБОРА в
|
||||
* контракте, и оно принадлежит не форме, а транспорту: первый пароль
|
||||
* администратора уезжает в EnvironmentFile systemd, где у перевода строки нет
|
||||
* представления, переживающего запись и чтение. Проверяются они перебором кодов,
|
||||
* а не классом регекспа: литеральные управляющие символы в исходнике невидимы,
|
||||
* и такое правило нельзя проверить глазами при ревью.
|
||||
*/
|
||||
export function isValidAdminPassword(value: string): boolean {
|
||||
let characters = 0;
|
||||
for (const character of value) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
return false;
|
||||
}
|
||||
characters += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
characters < ADMIN_PASSWORD_MIN_LENGTH ||
|
||||
characters > ADMIN_PASSWORD_MAX_LENGTH
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(value).length <= ADMIN_PASSWORD_MAX_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Правило пароля в том виде, в каком его принимает `el-form`.
|
||||
*
|
||||
* Собрано здесь, а не в каждой форме, по той же причине, по которой здесь живёт
|
||||
* сам контракт: у формы входа и формы смены пароля правило обязано быть одним и
|
||||
* тем же объектом кода, а не двумя совпадающими записями. Пока они были
|
||||
* двумя, они разошлись — 32 против 64 — и панель заперла оператора снаружи
|
||||
* после штатной смены пароля.
|
||||
*
|
||||
* Пустое значение пропускается: о нём отчитывается правило `required`, и без
|
||||
* этого оператор, отправивший пустую форму, читал бы про границы длины вместо
|
||||
* «поле обязательно». Сервер ведёт себя ровно так же — DTO приводит пустой
|
||||
* пароль к «не задано» до проверки правил.
|
||||
*
|
||||
* `message` передаётся аргументом: фраза принадлежит словарю локализации, а
|
||||
* `t()` доступен только внутри компонента.
|
||||
*/
|
||||
export function adminPasswordFormRule(message: string) {
|
||||
return {
|
||||
validator: (
|
||||
_rule: unknown,
|
||||
value: string,
|
||||
callback: (error?: Error) => void
|
||||
) => {
|
||||
if ((value ?? "") === "" || isValidAdminPassword(value)) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
callback(new Error(message));
|
||||
},
|
||||
trigger: ["change", "blur"] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ export default {
|
||||
// смены пароля. См. комментарий в ru.ts.
|
||||
credentials: {
|
||||
usernameFormat: "Username: {min} to {max} characters from {charset}",
|
||||
passwordLength: "Password: {min} to {max} characters",
|
||||
passwordFormat:
|
||||
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters",
|
||||
},
|
||||
dashboard: {
|
||||
stale: "Dashboard data is stale. Retrying automatically...",
|
||||
@@ -166,6 +167,8 @@ export default {
|
||||
peer_name:
|
||||
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
||||
credential_format: "“{field}”: {min} to {max} characters from {charset}",
|
||||
admin_password_format:
|
||||
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters",
|
||||
rule_violated: "“{field}”: value is not acceptable",
|
||||
validation_failed: "Validation failed",
|
||||
body_invalid:
|
||||
|
||||
@@ -31,7 +31,12 @@ export default {
|
||||
// приходит из hy2xs.env, и посмотреть его в панели негде.
|
||||
credentials: {
|
||||
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
|
||||
passwordLength: "Пароль: от {min} до {max} символов",
|
||||
// Фраза называет ОБЕ границы, потому что их две и они в разных единицах.
|
||||
// Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72
|
||||
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
|
||||
// сокращать пароль, отвергнутый не за это.
|
||||
passwordFormat:
|
||||
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов",
|
||||
},
|
||||
dashboard: {
|
||||
stale:
|
||||
@@ -183,6 +188,8 @@ export default {
|
||||
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
|
||||
credential_format:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}",
|
||||
admin_password_format:
|
||||
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов",
|
||||
rule_violated: "«{field}»: значение не подходит",
|
||||
validation_failed: "Проверка данных не пройдена",
|
||||
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
||||
|
||||
@@ -44,6 +44,11 @@ export const ERR_CODE = {
|
||||
ruleViolated: "rule_violated",
|
||||
peerName: "peer_name",
|
||||
credentialFormat: "credential_format",
|
||||
// Правило пароля администратора целиком: длина в символах, длина в байтах
|
||||
// (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому,
|
||||
// что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у
|
||||
// пароля, укладывающегося в границу символов.
|
||||
adminPasswordFormat: "admin_password_format",
|
||||
peerNameTaken: "peer_name_taken",
|
||||
peerNameReserved: "peer_name_reserved",
|
||||
peerBootstrapLocked: "peer_bootstrap_identity_locked",
|
||||
|
||||
@@ -34,8 +34,10 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { adminChangePasswordApi } from "@/api/admin";
|
||||
import { useAdminStore } from "@/store/modules/admin";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_BYTES,
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
adminPasswordFormRule,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -60,18 +62,22 @@ const form = reactive({
|
||||
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
|
||||
// пароль, назначенный здесь, невозможно было ввести там.
|
||||
//
|
||||
// Правило — собственное, а не встроенные `min`/`max` Element Plus: те
|
||||
// делегируются `async-validator`, который считает длину строки в единицах
|
||||
// UTF-16, тогда как сервер считает code points и вдобавок ограничивает длину в
|
||||
// БАЙТАХ пределом bcrypt. Пароль из 64 кириллических букв проходил здесь и
|
||||
// возвращался с сервера системной ошибкой хеширования.
|
||||
//
|
||||
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
|
||||
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
|
||||
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
|
||||
const passwordRule = {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
message: t("credentials.passwordLength", {
|
||||
const passwordRule = adminPasswordFormRule(
|
||||
t("credentials.passwordFormat", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
}),
|
||||
trigger: ["change", "blur"] as string[],
|
||||
};
|
||||
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
|
||||
})
|
||||
);
|
||||
|
||||
const rules: FormRules = {
|
||||
oldPassword: [
|
||||
|
||||
@@ -84,12 +84,14 @@ import { useAdminStore } from "@/store/modules/admin";
|
||||
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
|
||||
import { AdminLoginDto } from "@/api/admin/types";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_BYTES,
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
ADMIN_USERNAME_CHARSET,
|
||||
ADMIN_USERNAME_MAX_LENGTH,
|
||||
ADMIN_USERNAME_MIN_LENGTH,
|
||||
ADMIN_USERNAME_PATTERN,
|
||||
adminPasswordFormRule,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
@@ -129,6 +131,13 @@ const loginForm = ref<AdminLoginDto>({
|
||||
* символов, из-за чего форма входа отказывалась отправлять пароль, который
|
||||
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
|
||||
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
|
||||
*
|
||||
* Правило пароля — собственное, а не встроенные `min`/`max` Element Plus.
|
||||
* Element Plus делегирует правила формы библиотеке `async-validator`, а она
|
||||
* сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает
|
||||
* единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи
|
||||
* имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и
|
||||
* отказывал. См. isValidAdminPassword.
|
||||
*/
|
||||
const loginRules = {
|
||||
username: [
|
||||
@@ -153,15 +162,13 @@ const loginRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
message: t("credentials.passwordLength", {
|
||||
adminPasswordFormRule(
|
||||
t("credentials.passwordFormat", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
}),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
|
||||
})
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -43,15 +43,24 @@ const (
|
||||
// валидатора у них один. Оператору это разные фразы: «не меньше 1
|
||||
// устройства» и «не короче 6 символов», — и панель обязана уметь их
|
||||
// различить, не заводя у себя таблицу «какое поле какого рода».
|
||||
ErrCodeMin string = "min"
|
||||
ErrCodeMax string = "max"
|
||||
ErrCodeMinLength string = "min_length"
|
||||
ErrCodeMaxLength string = "max_length"
|
||||
ErrCodeLen string = "len"
|
||||
ErrCodeMin string = "min"
|
||||
ErrCodeMax string = "max"
|
||||
ErrCodeMinLength string = "min_length"
|
||||
ErrCodeMaxLength string = "max_length"
|
||||
ErrCodeLen string = "len"
|
||||
ErrCodeOneOf string = "oneof"
|
||||
ErrCodeGreaterThan string = "gt"
|
||||
ErrCodePeerName string = "peer_name"
|
||||
ErrCodeCredentialStr string = "credential_format"
|
||||
// ErrCodeAdminPassword — пароль администратора не удовлетворяет контракту.
|
||||
//
|
||||
// Отдельный код, а не пара min_length/max_length, потому что правило одно и
|
||||
// проверяет три вещи сразу: длину в символах, длину в байтах (предел
|
||||
// bcrypt) и отсутствие управляющих символов. Пока границы стояли двумя
|
||||
// тегами, отказ по байтам было нечем назвать — его просто не существовало,
|
||||
// и пароль из 64 кириллических букв доходил до хеширования, где становился
|
||||
// системной ошибкой.
|
||||
ErrCodeAdminPassword string = "admin_password_format"
|
||||
ErrCodeRuleUnknown string = "rule_violated"
|
||||
|
||||
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
package dto
|
||||
|
||||
// Границы пароля здесь и в LoginDto обязаны совпадать.
|
||||
// Правило пароля здесь и в LoginDto обязано быть ОДНИМ И ТЕМ ЖЕ.
|
||||
//
|
||||
// Пока они расходились, панель запирала оператора снаружи после операции,
|
||||
// Пока границы расходились, панель запирала оператора снаружи после операции,
|
||||
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
|
||||
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
|
||||
// невозможно войти. Числа в тегах сверяются с service.AdminPassword*Length в
|
||||
// TestAdminPasswordBoundsAreOneContract — в теге структуры константу не
|
||||
// подставить.
|
||||
// невозможно войти.
|
||||
//
|
||||
// Числа из тегов ушли внутрь правила `adminPassword`. Записанные тегом
|
||||
// `min=6,max=64` они, кроме дублирования, были ещё и неполны: тег считает
|
||||
// символы Unicode, а bcrypt — байты, поэтому новый пароль из 64 кириллических
|
||||
// букв проходил проверку формы, доходил до util.HashPassword и возвращался
|
||||
// оператору системной ошибкой. Отказ по байтам тегом валидатора выразить
|
||||
// нечем — правила длины в байтах у него нет.
|
||||
//
|
||||
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
|
||||
|
||||
type AdminChangePasswordDto struct {
|
||||
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
|
||||
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
|
||||
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,adminPassword"`
|
||||
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,adminPassword"`
|
||||
}
|
||||
|
||||
// Normalize: пароль НЕ триммится ни в одном из полей.
|
||||
//
|
||||
// Пустое поле обязано выглядеть незаполненным и получить отказ `required`: у
|
||||
// поля-указателя `required` считает значение заданным по самому факту
|
||||
// ненулевого указателя, поэтому `{"oldPassword":""}` иначе получило бы отказ по
|
||||
// длине вместо «поле обязательно».
|
||||
//
|
||||
// Пробел по краям при этом остаётся частью значения. Форма входа ведёт себя так
|
||||
// же (LoginDto.Normalize), и иначе быть не может: пароль, назначенный здесь,
|
||||
// вводится там.
|
||||
func (d *AdminChangePasswordDto) Normalize() {
|
||||
emptyToNil(&d.OldPassword)
|
||||
emptyToNil(&d.NewPassword)
|
||||
}
|
||||
|
||||
+11
-7
@@ -18,17 +18,21 @@ package dto
|
||||
// и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция
|
||||
// была бы той же ошибкой.
|
||||
//
|
||||
// У пароля набора символов НЕТ, и это осознанно: сервер нигде его не
|
||||
// У пароля границы длины живут внутри правила `adminPassword` по той же
|
||||
// причине. Здесь стояло `min=6,max=64`, и эта запись была не только вторым
|
||||
// правилом на одном поле, но и НЕПОЛНОЙ: `max` считает символы Unicode, а
|
||||
// bcrypt — байты, поэтому 64 кириллические буквы (128 байт) проходили проверку
|
||||
// и падали в хешировании. Границу в байтах тегом валидатора не выразить.
|
||||
//
|
||||
// Набора символов у пароля НЕТ, и это осознанно: сервер нигде его не
|
||||
// ограничивает — ни при установке, ни при смене пароля. Ограничение на входе
|
||||
// не защищало бы ничего (значение всё равно сравнивается с хешем) и умело
|
||||
// только запирать оператора с паролем, который сервер принял бы.
|
||||
//
|
||||
// Числа в тегах обязаны совпадать с service.AdminPassword*Length; за этим
|
||||
// следит TestLoginPasswordBoundsMatchContract — в теге структуры константу не
|
||||
// подставить.
|
||||
// только запирать оператора с паролем, который сервер принял бы. Единственное
|
||||
// исключение — управляющие символы, и оно принадлежит контракту, а не форме:
|
||||
// см. credential.IsValidAdminPassword.
|
||||
type LoginDto struct {
|
||||
Username *string `json:"username" form:"username" validate:"required,credentialStr"`
|
||||
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
|
||||
Pass *string `json:"pass" form:"pass" validate:"required,adminPassword"`
|
||||
}
|
||||
|
||||
// Normalize: незаполненное поле обязано выглядеть незаполненным.
|
||||
|
||||
@@ -119,4 +119,56 @@ func TestNormalizableIsImplemented(t *testing.T) {
|
||||
var _ Normalizable = (*PeerPageDto)(nil)
|
||||
var _ Normalizable = (*BaseDto)(nil)
|
||||
var _ Normalizable = (*LogDto)(nil)
|
||||
var _ Normalizable = (*LoginDto)(nil)
|
||||
var _ Normalizable = (*AdminChangePasswordDto)(nil)
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится ни на одной из двух форм.
|
||||
//
|
||||
// Набор его символов не ограничен, пробел по краям — часть значения, и
|
||||
// молчаливое его удаление означало бы, что на сервер уезжает не то, что ввёл
|
||||
// оператор. Логин при этом триммится: скопированное из bootstrap-admin.secret
|
||||
// значение регулярно приезжает с пробелом или переводом строки на конце.
|
||||
func TestPasswordIsNeverTrimmedByNormalization(t *testing.T) {
|
||||
login := LoginDto{Username: strPtr(" hy2xsadmin\n"), Pass: strPtr(" пароль ")}
|
||||
login.Normalize()
|
||||
|
||||
if login.Username == nil || *login.Username != "hy2xsadmin" {
|
||||
t.Fatalf("логин не приведён к каноничному виду: %v", login.Username)
|
||||
}
|
||||
if login.Pass == nil || *login.Pass != " пароль " {
|
||||
t.Fatalf("пароль изменён нормализацией: %q", *login.Pass)
|
||||
}
|
||||
|
||||
change := AdminChangePasswordDto{
|
||||
OldPassword: strPtr("abcde "),
|
||||
NewPassword: strPtr(" abcdef"),
|
||||
}
|
||||
change.Normalize()
|
||||
|
||||
if change.OldPassword == nil || *change.OldPassword != "abcde " {
|
||||
t.Fatalf("старый пароль изменён нормализацией: %v", change.OldPassword)
|
||||
}
|
||||
if change.NewPassword == nil || *change.NewPassword != " abcdef" {
|
||||
t.Fatalf("новый пароль изменён нормализацией: %v", change.NewPassword)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой пароль обязан выглядеть НЕЗАДАННЫМ.
|
||||
//
|
||||
// У поля-указателя `required` считает значение заданным по самому факту
|
||||
// ненулевого указателя, поэтому без этого шага оператор, отправивший пустую
|
||||
// форму, получал отказ по длине вместо «поле обязательно».
|
||||
func TestEmptyPasswordBecomesAbsent(t *testing.T) {
|
||||
login := LoginDto{Username: strPtr("hy2xsadmin"), Pass: strPtr("")}
|
||||
login.Normalize()
|
||||
if login.Pass != nil {
|
||||
t.Fatalf("пустой пароль остался заданным: %q", *login.Pass)
|
||||
}
|
||||
|
||||
change := AdminChangePasswordDto{OldPassword: strPtr(""), NewPassword: strPtr("")}
|
||||
change.Normalize()
|
||||
if change.OldPassword != nil || change.NewPassword != nil {
|
||||
t.Fatal("пустые пароли формы смены остались заданными")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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
|
||||
}
|
||||
+31
-2
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"hy2xs-admin/credential"
|
||||
)
|
||||
|
||||
// Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней.
|
||||
@@ -32,9 +33,37 @@ import (
|
||||
// осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике
|
||||
// логина, доступном без аутентификации.
|
||||
|
||||
// HashPassword проверяет пароль ОБЩИМ контрактом и хеширует его.
|
||||
//
|
||||
// Здесь стояла собственная проверка:
|
||||
//
|
||||
// if len(strings.TrimSpace(password)) < 6 { … }
|
||||
//
|
||||
// и она была третьей копией правила о пароле — вдобавок расходящейся с двумя
|
||||
// остальными сразу по двум признакам.
|
||||
//
|
||||
// Первое: `TrimSpace`. Контракт продукта прямо объявляет пробел по краям частью
|
||||
// пароля и не снимает его нигде — ни в нормализации DTO, ни при чтении
|
||||
// bootstrap-окружения. Пароль `"abcde "` считался корректным формой входа,
|
||||
// формой смены и оркестратором, а здесь превращался в `"abcde"` и получал
|
||||
// отказ. То есть значение, принятое всеми дверями продукта, невозможно было
|
||||
// захешировать.
|
||||
//
|
||||
// Второе: у проверки не было верхней границы вовсе, поэтому предел bcrypt в 72
|
||||
// БАЙТА всплывал ошибкой хеширования уже после того, как все проверки пройдены.
|
||||
//
|
||||
// Теперь правило одно на весь продукт. Возможность позвать его отсюда — прямая
|
||||
// причина, по которой контракт живёт в пакете `credential`, а не в `service`:
|
||||
// `service` импортирует `util`, и обратный импорт был бы циклическим.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(strings.TrimSpace(password)) < 6 {
|
||||
return "", errors.New("password too short")
|
||||
if !credential.IsValidAdminPassword(password) {
|
||||
return "", fmt.Errorf(
|
||||
"пароль администратора не удовлетворяет контракту: от %d до %d символов, "+
|
||||
"не более %d байт в UTF-8, без управляющих символов",
|
||||
credential.AdminPasswordMinLength,
|
||||
credential.AdminPasswordMaxLength,
|
||||
credential.AdminPasswordMaxBytes,
|
||||
)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hy2xs-admin/credential"
|
||||
)
|
||||
|
||||
// Здесь был TestSHA224String, который печатал хеш в stdout и ничего не
|
||||
@@ -41,12 +43,81 @@ func TestHashPasswordIsSalted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordRejectsShortPassword(t *testing.T) {
|
||||
if _, err := HashPassword("12345"); err == nil {
|
||||
t.Fatal("слишком короткий пароль принят")
|
||||
// HashPassword проверяет ОБЩИЙ контракт, а не собственную копию правила.
|
||||
//
|
||||
// Здесь стояло `len(strings.TrimSpace(password)) < 6`, и это была третья копия
|
||||
// правила о пароле, расходящаяся с двумя остальными. Тест закреплял её обе
|
||||
// ошибки: он требовал ОТКАЗА на шести пробелах — то есть на значении, которое
|
||||
// контракт продукта принимает, — и не проверял верхнюю границу вовсе.
|
||||
//
|
||||
// Шесть пробелов принимаются намеренно. Пробел объявлен частью пароля, набор
|
||||
// символов не ограничен, и «слабый пароль» здесь не тема: значение назначает
|
||||
// оператор, а хеширование не является местом, где оценивают стойкость.
|
||||
func TestHashPasswordFollowsTheCredentialContract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
password string
|
||||
}{
|
||||
{"ровно минимум", strings.Repeat("a", credential.AdminPasswordMinLength)},
|
||||
{"на символ короче минимума", strings.Repeat("a", credential.AdminPasswordMinLength-1)},
|
||||
{"шесть пробелов", strings.Repeat(" ", credential.AdminPasswordMinLength)},
|
||||
{"краевой пробел", "abcde "},
|
||||
{"ровно максимум в ASCII", strings.Repeat("a", credential.AdminPasswordMaxLength)},
|
||||
{"на символ длиннее максимума", strings.Repeat("a", credential.AdminPasswordMaxLength+1)},
|
||||
{"ровно предел bcrypt", strings.Repeat("я", 36)},
|
||||
{"на букву длиннее предела bcrypt", strings.Repeat("я", 37)},
|
||||
{"эмодзи ровно в предел", strings.Repeat("😀", 18)},
|
||||
{"эмодзи за пределом", strings.Repeat("😀", 19)},
|
||||
{"64 кириллические буквы", strings.Repeat("я", credential.AdminPasswordMaxLength)},
|
||||
{"перевод строки", "abcde\n"},
|
||||
{"пусто", ""},
|
||||
}
|
||||
if _, err := HashPassword(" "); err == nil {
|
||||
t.Fatal("пароль из пробелов принят")
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, err := HashPassword(testCase.password)
|
||||
accepted := err == nil
|
||||
expected := credential.IsValidAdminPassword(testCase.password)
|
||||
if accepted != expected {
|
||||
t.Fatalf(
|
||||
"HashPassword принял=%v, контракт=%v (%d символов, %d байт): %v",
|
||||
accepted, expected,
|
||||
len([]rune(testCase.password)), len(testCase.password), err,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Контракт обязан быть НЕ ШИРЕ того, что умеет bcrypt.
|
||||
//
|
||||
// Это и было исходным дефектом: контракт принимал 64 символа любого алфавита, а
|
||||
// bcrypt.GenerateFromPassword отвечает ErrPasswordTooLong строго при длине
|
||||
// больше 72 БАЙТ. Проверяются граничные значения, на которых расхождение
|
||||
// проявляется: всё, что контракт принял, обязано хешироваться.
|
||||
func TestEveryPasswordAcceptedByContractCanBeHashed(t *testing.T) {
|
||||
candidates := []string{
|
||||
strings.Repeat("a", credential.AdminPasswordMinLength),
|
||||
strings.Repeat("a", credential.AdminPasswordMaxLength),
|
||||
strings.Repeat("я", 36),
|
||||
strings.Repeat("😀", 18),
|
||||
strings.Repeat(" ", credential.AdminPasswordMinLength),
|
||||
"пароль-администратора",
|
||||
`p@$$w0rd"\'#;`,
|
||||
"abcde ",
|
||||
}
|
||||
|
||||
for _, password := range candidates {
|
||||
if !credential.IsValidAdminPassword(password) {
|
||||
t.Fatalf("подготовка: контракт отверг значение из %d байт", len(password))
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("контракт принял пароль из %d байт, а хеширование отказало: %v", len(password), err)
|
||||
}
|
||||
if !VerifyPassword(password, hash) {
|
||||
t.Fatalf("пароль из %d байт не проходит проверку собственным хешем", len(password))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user