// 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 }