65042ee335
Верхняя граница пароля была объявлена в 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>
122 lines
5.1 KiB
Go
122 lines
5.1 KiB
Go
/**
|
||
* Разбор структурированного отказа API.
|
||
*
|
||
* Панель НЕ разбирает текст сообщения. Раньше у неё не было выбора: сервер
|
||
* отвечал на любую ошибку любого поля формы одним словом `invalid`, и всё, что
|
||
* панель могла сделать, — показать это слово тостом. Оператор, оставивший поле
|
||
* секрета пустым ровно так, как предлагала подпись под полем, видел «Invalid» и
|
||
* не имел ни одного способа узнать причину.
|
||
*
|
||
* Теперь у отказа есть код, а у отказа по полю — ещё и имя поля. Панель
|
||
* выбирает по коду СВОЮ локализованную фразу; текст сервера остаётся запасным
|
||
* вариантом для кода, которого она ещё не знает, и ответом для клиента без UI.
|
||
*/
|
||
|
||
/** Числовые коды ответа; синхронизировано с model/constant/code.go. */
|
||
export const API_CODE = {
|
||
success: 20000,
|
||
systemError: 50000,
|
||
validationFailed: 50001,
|
||
unauthorized: 50401,
|
||
forbidden: 50403,
|
||
} as const;
|
||
|
||
/**
|
||
* Коды причин; синхронизировано с constant.ErrCode* в model/constant/error.go.
|
||
*
|
||
* Перечислены только те, на которые панель реагирует по-разному. Остальные
|
||
* доезжают до оператора сообщением сервера.
|
||
*/
|
||
export const ERR_CODE = {
|
||
bodyInvalid: "body_invalid",
|
||
validationFailed: "validation_failed",
|
||
required: "required",
|
||
min: "min",
|
||
max: "max",
|
||
// Границы числа и границы длины строки различаются кодом, хотя тег
|
||
// валидатора у них один: «не меньше 1 устройства» и «не короче 6 символов» —
|
||
// разные фразы для оператора.
|
||
minLength: "min_length",
|
||
maxLength: "max_length",
|
||
len: "len",
|
||
oneOf: "oneof",
|
||
greaterThan: "gt",
|
||
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",
|
||
// ЧАСТИЧНЫЙ результат отзыва доступа, а не отказ операции: запрет новых
|
||
// подключений записан, активную сессию завершить не удалось. Панель
|
||
// показывает его предупреждением и обновляет строку — состояние в базе уже
|
||
// изменилось.
|
||
peerDisconnectFailed: "peer_disconnect_failed",
|
||
invalidCredentials: "invalid_credentials",
|
||
importFileExtension: "import_file_extension",
|
||
unauthorized: "unauthorized",
|
||
sessionExpired: "session_expired",
|
||
tokenInvalid: "token_invalid",
|
||
accountDisabled: "account_disabled",
|
||
} as const;
|
||
|
||
export interface ApiFieldError {
|
||
code: string;
|
||
field?: string;
|
||
message: string;
|
||
params?: Record<string, string>;
|
||
}
|
||
|
||
export interface ApiErrorPayload {
|
||
code: number;
|
||
message?: string;
|
||
errors?: ApiFieldError[];
|
||
}
|
||
|
||
/** Отказ API как исключение, сохраняющее машиночитаемую причину. */
|
||
export class ApiError extends Error {
|
||
readonly code: number;
|
||
readonly errors: ApiFieldError[];
|
||
|
||
constructor(payload: ApiErrorPayload) {
|
||
super(payload.message || "Error");
|
||
this.name = "ApiError";
|
||
this.code = payload.code;
|
||
this.errors = payload.errors ?? [];
|
||
}
|
||
|
||
/** Причины, привязанные к полям формы. */
|
||
fieldErrors(): ApiFieldError[] {
|
||
return this.errors.filter((item) => !!item.field);
|
||
}
|
||
|
||
/** Первая причина без привязки к полю — отказ уровня операции. */
|
||
operationError(): ApiFieldError | undefined {
|
||
return this.errors.find((item) => !item.field);
|
||
}
|
||
|
||
hasCode(code: string): boolean {
|
||
return this.errors.some((item) => item.code === code);
|
||
}
|
||
|
||
get requiresSignIn(): boolean {
|
||
return this.code === API_CODE.unauthorized;
|
||
}
|
||
|
||
get sessionExpired(): boolean {
|
||
return (
|
||
this.hasCode(ERR_CODE.sessionExpired) ||
|
||
this.hasCode(ERR_CODE.accountDisabled)
|
||
);
|
||
}
|
||
}
|
||
|
||
export function isApiError(value: unknown): value is ApiError {
|
||
return value instanceof ApiError;
|
||
}
|