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:
2026-09-06 18:38:04 +05:00
parent a8407cf16b
commit 65042ee335
38 changed files with 2646 additions and 400 deletions
+137 -45
View File
@@ -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) {
+41 -5
View File
@@ -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
+47 -79
View File
@@ -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) {
+173
View File
@@ -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
}
+178
View File
@@ -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("пароль с краевым пробелом отклонён: пробел объявлен частью значения")
}
}
+104
View File
@@ -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
View File
@@ -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 с окружением и НЕ
// придумывает его сам.
//
+93 -4
View File
@@ -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[],
};
}
+4 -1
View File
@@ -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:
+8 -1
View File
@@ -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: "Запрос не разобран: проверьте формат и типы полей",
+5
View File
@@ -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: [
+14 -7
View File
@@ -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,
})
),
],
};
+14 -5
View File
@@ -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"
// Доменные коды: правило соблюдено, но операция всё равно невозможна.
+27 -7
View File
@@ -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
View File
@@ -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: незаполненное поле обязано выглядеть незаполненным.
+52
View File
@@ -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("пустые пароли формы смены остались заданными")
}
}
-90
View File
@@ -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
View File
@@ -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 {
+76 -5
View File
@@ -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))
}
}
}