Files
HY2XS_flamy/apps/credential/admin.go
T

237 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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))
}
// Noncharacters и суррогаты — то, что НЕ ПРИМЕТ systemd.
//
// `EnvironmentFile=` разбирается systemd, и перед тем как принять пару, он
// прогоняет ключ и значение через `utf8_is_valid` (src/basic/env-file.c,
// `check_utf8ness_and_warn`). Отказ там — это `-EINVAL`, то есть НЕ загруженный
// environment file, то есть юнит, который не стартует; предупреждением это не
// является.
//
// `utf8_is_valid` отвергает встроенный NUL и всё, что не является Unicode
// scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх того отвергает:
//
// U+D800..U+DFFF суррогаты
// U+FDD0..U+FDEF noncharacters
// (cp & 0xFFFE) == 0xFFFE — U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
//
// Пока контракт этого не знал, пароль вроде `abcde﷐` — шесть символов,
// восемь байт, ни одного управляющего — проходил ВСЕ проверки продукта,
// записывался в /etc/hy2xs/hy2xs.env, и админка после этого не стартовала. Тот
// же класс дефекта, ради уничтожения которого контракт и существует, только на
// один слой ниже.
//
// Публичная документация systemd также запрещает U+FEFF. Реализация v257.13
// случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует
// документированному контракту, а не этому расхождению реализации.
func isUnicodeNoncharacter(r rune) bool {
if r >= 0xFDD0 && r <= 0xFDEF {
return true
}
return r&0xFFFE == 0xFFFE
}
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
//
// Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения
// (границы длины, запрет управляющих символов) живут отдельно — иначе
// невозможно объяснить, какое из двух правил нарушено и чем это грозит.
func IsEnvTransportableText(value string) bool {
// Невалидный UTF-8 сюда попадает не только из файла: Go-строка — это
// произвольные байты, и `[]byte` из внешнего источника может оказаться
// чем угодно. Проверка идёт ПЕРВОЙ, потому что `range` по невалидной строке
// молча отдаёт U+FFFD и скрыл бы причину.
if !utf8.ValidString(value) {
return false
}
for _, r := range value {
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
// отдельной веткой (`embedded NUL`).
if r == 0 || r == 0xFEFF {
return false
}
if isUnicodeNoncharacter(r) {
return false
}
}
return true
}
// IsValidAdminPassword — единственное правило пароля администратора.
//
// Правило состоит из четырёх частей и НИ ОДНА из них не является набором
// символов:
//
// домен systemd — значение обязано быть загружаемым из EnvironmentFile
// (IsEnvTransportableText);
// 6-64 code points — граница, которую видит оператор;
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
// Cc — продуктовая политика, см. hasForbiddenRune.
//
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
// видит оператор: «пароль из 64 символов» обязано означать одно и то же для
// латиницы и для кириллицы.
//
// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
//
// Набор символов НЕ ограничивается ничем сверх перечисленного. Пароль назначает
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
// пароль, который сервер принял бы.
//
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
// нормализации DTO, ни при чтении bootstrap-окружения.
func IsValidAdminPassword(password string) bool {
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до
// админки вовсе — systemd откажется загружать файл окружения, и юнит не
// стартует. Это отказ более грубого рода, чем нарушение границ длины.
if !IsEnvTransportableText(password) {
return false
}
runes := utf8.RuneCountInString(password)
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
return false
}
if len(password) > AdminPasswordMaxBytes {
return false
}
return !hasForbiddenRune(password)
}
// hasForbiddenRune — продуктовая политика HY2XS поверх домена systemd.
//
// Запрещены УПРАВЛЯЮЩИЕ символы Unicode целиком, то есть категория Cc:
//
// U+0000..U+001F C0
// U+007F DEL
// U+0080..U+009F C1
//
// Раньше здесь стояли только C0 и DEL, а комментарий обещал «без управляющих
// символов» — то есть документация была шире кода ровно на C1. Проверяется
// теперь то, что обещано.
//
// U+FEFF здесь уже не проверяется: он принадлежит документированному домену
// EnvironmentFile и отвергается IsEnvTransportableText.
func hasForbiddenRune(value string) bool {
for _, r := range value {
if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
return true
}
}
return false
}