Files
HY2XS_flamy/apps/frontend/src/views/login/index.vue
T
Crimson 65042ee335 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>
2026-09-06 18:38:04 +05:00

331 lines
11 KiB
Vue
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.
<template>
<div class="login-container">
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
>
<div class="flex text-white items-center py-4">
<span class="text-2xl flex-1 text-center">{{ $t("login.title") }}</span>
<lang-select style="color: #fff" />
</div>
<el-form-item prop="username">
<div class="p-2 text-white">
<svg-icon icon-class="user" />
</div>
<el-input
class="flex-1"
ref="username"
size="large"
v-model="loginForm.username"
:placeholder="$t('login.username')"
name="username"
/>
</el-form-item>
<el-tooltip
:disabled="isCapslock === false"
:content="$t('login.capsLockOn')"
placement="right"
>
<el-form-item prop="pass">
<span class="p-2 text-white">
<svg-icon icon-class="password" />
</span>
<el-input
class="flex-1"
v-model="loginForm.pass"
:placeholder="$t('login.password')"
:type="passVisible === false ? 'password' : 'input'"
size="large"
name="pass"
@keyup="checkCapslock"
@keyup.enter="handleLogin"
/>
<span class="mr-2" @click="passVisible = !passVisible">
<svg-icon
:icon-class="passVisible === false ? 'eye' : 'eye-open'"
class="text-white cursor-pointer"
/>
</span>
</el-form-item>
</el-tooltip>
<el-button
size="default"
:loading="loading"
type="primary"
class="w-full"
@click.prevent="handleLogin"
>{{ $t("login.login") }}
</el-button>
</el-form>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import router from "@/router";
import LangSelect from "@/components/LangSelect/index.vue";
import SvgIcon from "@/components/SvgIcon/index.vue";
import { useI18n } from "vue-i18n";
// Зависимость store
import { useAdminStore } from "@/store/modules/admin";
// Зависимость API
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();
const route = useRoute();
const { t } = useI18n();
/**
* Состояние загрузки кнопки
*/
const loading = ref(false);
/**
* Включён ли Caps Lock
*/
const isCapslock = ref(false);
/**
* Видимость пароля
*/
const passVisible = ref(false);
/**
* Ссылка на форму входа
*/
const loginFormRef = ref(ElForm);
/**
* Форма входа
*/
const loginForm = ref<AdminLoginDto>({
username: "",
pass: "",
});
/**
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
*
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
* символов, из-за чего форма входа отказывалась отправлять пароль, который
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
*
* Правило пароля — собственное, а не встроенные `min`/`max` Element Plus.
* Element Plus делегирует правила формы библиотеке `async-validator`, а она
* сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает
* единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи
* имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и
* отказывал. См. isValidAdminPassword.
*/
const loginRules = {
username: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
{
pattern: ADMIN_USERNAME_PATTERN,
message: t("credentials.usernameFormat", {
min: ADMIN_USERNAME_MIN_LENGTH,
max: ADMIN_USERNAME_MAX_LENGTH,
charset: ADMIN_USERNAME_CHARSET,
}),
trigger: ["change", "blur"],
},
],
pass: [
{
required: true,
message: t("common.required"),
trigger: ["change", "blur"],
},
adminPasswordFormRule(
t("credentials.passwordFormat", {
min: ADMIN_PASSWORD_MIN_LENGTH,
max: ADMIN_PASSWORD_MAX_LENGTH,
maxBytes: ADMIN_PASSWORD_MAX_BYTES,
})
),
],
};
/**
* Проверка состояния регистра ввода
*/
const checkCapslock = (e: any) => {
const { key } = e;
isCapslock.value = key && key.length === 1 && key >= "A" && key <= "Z";
};
/**
* Вход
*/
const handleLogin = () => {
loginFormRef.value.validate((valid: boolean) => {
if (valid) {
loading.value = true;
const params = { ...loginForm.value };
adminStore
.login(params)
.then(() => {
if (adminStore.forcePasswordChange) {
router.push({ path: "/admin/change-password" });
return;
}
const query: LocationQuery = route.query;
const redirect = (query.redirect as LocationQueryValue) ?? "/";
const otherQueryParams = Object.keys(query).reduce(
(acc: any, cur: string) => {
if (cur !== "redirect") {
acc[cur] = query[cur];
}
return acc;
},
{}
);
router.push({ path: redirect, query: otherQueryParams });
})
.catch(() => {})
.finally(() => {
loading.value = false;
});
}
});
};
</script>
<style lang="scss" scoped>
.login-container {
width: 100%;
min-height: 100%;
overflow: hidden;
background-color: var(--menuBg);
.login-form {
width: 520px;
max-width: 100%;
padding: 160px 35px 0;
margin: 0 auto;
overflow: hidden;
}
}
// Видимое поле формы входа — это `el-form-item`, а не `el-input`.
//
// Рамка и фон нарисованы здесь, потому что внутрь одного поля кладутся три
// вещи: иконка, ввод и переключатель видимости пароля. `el-input` занимает лишь
// среднюю из них.
//
// Отсюда и дефект индикации ошибки, который был виден на форме. Element Plus
// рисует состояние отказа на `el-input__wrapper` правилом
//
// .el-form-item.is-error .el-form-item__content .el-input__wrapper
//
// то есть селектором из ЧЕТЫРЁХ классов, а здешнее гашение тени записывалось
// селектором из трёх — и проигрывало по специфичности. В результате красная
// рамка ложилась вокруг одного лишь поля ввода: у логина она начиналась после
// иконки пользователя, у пароля обрывалась перед «глазом», и ни одна её сторона
// не совпадала с видимой границей поля.
//
// Чинится это не увеличением специфичности ради победы, а переносом индикации
// на тот элемент, который оператор и видит полем.
.el-form-item {
background: var(--subMenuBg);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 5px;
// Просвет под полем принадлежит сообщению об отказе: `el-form-item__error`
// позиционируется абсолютно от `top: 100%`, то есть живёт ВНЕ рамки. При
// стандартных 18px оно вплотную прижималось к границе снизу и к следующему
// полю сверху.
margin-bottom: 26px;
&.is-error {
border-color: var(--el-color-danger);
// Штатная индикация Element Plus гасится ЗДЕСЬ, а не в блоке `.el-input`:
// селектор повторяет её собственный и добавляет атрибут scoped-стиля,
// поэтому выигрывает по специфичности. Прежнее гашение стояло на два
// класса ниже и проигрывало — из-за чего красный прямоугольник вокруг
// одного лишь поля ввода и появлялся. `!important` здесь не нужен: правило
// не сильнее чужого, а конкретнее.
:deep(.el-form-item__content .el-input__wrapper) {
&,
&:hover,
&:focus,
&.is-focus {
box-shadow: none;
}
}
}
// Сообщение выравнивается по тексту поля, а не по краю рамки: иначе оно
// висит на сдвиг левее всего, что находится над ним.
:deep(.el-form-item__error) {
padding-top: 6px;
padding-left: 12px;
}
}
.el-input {
background: transparent;
// scoped не действует на дочерний компонент, используется :deep
:deep(.el-input__wrapper) {
padding: 0;
background: transparent;
box-shadow: none;
.el-input__inner {
color: #fff;
caret-color: #fff;
background: transparent;
border: 0;
border-radius: 0;
&:-webkit-autofill {
box-shadow: 0 0 0 1000px transparent inset !important;
-webkit-text-fill-color: #fff !important;
}
// Настройка задержки автозаполнения поля ввода
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus,
&:-webkit-autofill:active {
transition:
color 99999s ease-out,
background-color 99999s ease-out;
transition-delay: 99999s;
}
}
}
}
</style>