fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с таким именем не регистрировалось: при переименовании в `credentialStr` правка не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery превращал панику в HTTP 500 на каждый POST /api/auth/login. Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам регексп, в обход валидатора, а обработчика входа не касался ни один тест. Очевидная замена не помогла бы: цепочка правил поля обрывается на первом несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его валидатору отдельно — незарегистрированное правило паникует так же, как в бою, но на сборке. Барьер проверен возвратом исходного тега. Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба идёт в любом режиме операции и от актуальности пароля не зависит. Рядом лежали три расхождения того же класса, найденные при разборе. Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая установка проходила целиком, создавая учётную запись, под которой невозможно войти. Про одно имя существовало три расходящихся умолчания. Оба значения теперь проверяются при разборе окружения — той стороной, которая их порождает: отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход на готовом сервере — переустановкой. Панель была строже сервера. Форма входа ограничивала пароль 32 символами при серверном пределе в 64, а форма смены пароля назначала до 64: пароль, назначенный штатной операцией, после этого не вводился. Набор символов на пароле отвергал значение, которое сервер принял бы, — сервер его не ограничивает нигде. Контракт учётных данных объявлен один раз в service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним тестами, читающими Go-исходник. Класс символов логина был записан диапазоном по опечатке: неэкранированный дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным набором это совпадало только потому, что обе стороны несли одну опечатку. Набор записан явно и НЕ сужен — он уже действует на установленных серверах. Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх, проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина начиналась после иконки, у пароля обрывалась перед «глазом». Индикация перенесена на элемент, который оператор и видит полем; чужая тень гасится селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, — конкретностью, а не !important. Остальные формы панели проверены: собственная рамка на el-form-item есть только на форме входа. Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому — UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. Обработчик входа переехал из controller/peer.go в controller/auth.go: стек в journal указывал на управление пирами. Требование теперь называется, а не сообщается фактом нарушения. «Неверный формат логина» и «Некорректное значение» не давали оператору способа узнать, что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не показан. Фразы форм и серверная причина credential_format перечисляют границы и набор. Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления — по той же причине, что и гейт детектора гонок. Каждое из его утверждений проверено мутационной пробой на реальный отказ; две первые редакции оказались вакуумными и переписаны. Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это релизный гейт сборщика. Прогон задокументирован в docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/dto"
|
||||
"hy2xs-admin/model/vo"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Вход в панель живёт в собственном файле, а не среди обработчиков пиров.
|
||||
//
|
||||
// Это не косметика. Пока Login лежал в controller/peer.go, паника валидатора на
|
||||
// форме входа приходила в journal стеком `controller/peer.go:50`, и первая
|
||||
// гипотеза при разборе RC2 указывала на управление пирами — то есть на
|
||||
// подсистему, не имеющую к отказу никакого отношения. Самая ценная дверь
|
||||
// продукта обязана называться своим именем в стеке.
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
// Неверные учётные данные получают код, чтобы панель показала
|
||||
// оператору внятную фразу на его языке. Отказ базы остаётся системной
|
||||
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
|
||||
// значит отправить оператора искать несуществующую опечатку.
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Контракт входа в панель.
|
||||
//
|
||||
// Что здесь произошло и почему тестов было мало. В RC2 каждый
|
||||
// `POST /api/auth/login` заканчивался HTTP 500: на LoginDto.Username стоял тег
|
||||
// `validateStr`, правило с таким именем не регистрировалось, и
|
||||
// go-playground/validator ПАНИКОВАЛ до всякой проверки логина и пароля.
|
||||
// Существующие тесты этого не видели, потому что проверяли регексп напрямую —
|
||||
// в обход production-валидатора, — а ни одного запроса к обработчику входа не
|
||||
// делал никто.
|
||||
//
|
||||
// Поэтому проверок здесь три уровня, и каждый ловит свой класс:
|
||||
//
|
||||
// структурный — ни один тег ни в одном DTO не ссылается на незарегистрированное
|
||||
// правило (закрывает КЛАСС, а не найденный экземпляр);
|
||||
// правила — LoginDto проходит через тот самый validate, что и в бою;
|
||||
// HTTP — обработчик отвечает конвертом, а не 500, на любом входе.
|
||||
|
||||
// --------------------------------------------------------------- структура ---
|
||||
|
||||
var validationTagPattern = regexp.MustCompile(`validate:"([^"]*)"`)
|
||||
|
||||
// Ни один тег валидации не имеет права ссылаться на правило, которого нет.
|
||||
//
|
||||
// Это главный барьер файла. Наивная версия — «прогнать нулевое DTO через
|
||||
// validate.Struct» — дефекта RC2 НЕ ловит: цепочка правил поля обрывается на
|
||||
// первом несработавшем, поэтому на пустом Username проверка отказывает по
|
||||
// `required` и до испорченного тега не доходит. Поэтому теги извлекаются из
|
||||
// исходников и каждый предъявляется валидатору отдельно: незарегистрированное
|
||||
// правило паникует ровно так же, как паниковало в бою, но здесь — на сборке.
|
||||
func TestEveryValidationTagIsRegistered(t *testing.T) {
|
||||
root := filepath.Join("..", "model")
|
||||
|
||||
users := map[string]map[string]struct{}{}
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||
return nil
|
||||
}
|
||||
source, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for _, match := range validationTagPattern.FindAllSubmatch(source, -1) {
|
||||
for _, rule := range splitValidationRules(string(match[1])) {
|
||||
if users[rule] == nil {
|
||||
users[rule] = map[string]struct{}{}
|
||||
}
|
||||
users[rule][filepath.ToSlash(path)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать исходники модели: %v", err)
|
||||
}
|
||||
|
||||
// Пустой обход означал бы, что проверка молчит из-за неверного пути, а не
|
||||
// из-за отсутствия дефектов. Число намеренно грубое: тест закрепляет факт
|
||||
// «теги найдены», а не их количество.
|
||||
if len(users) < 5 {
|
||||
t.Fatalf("в %s найдено %d правил валидации — обход не дошёл до DTO", root, len(users))
|
||||
}
|
||||
|
||||
for rule, files := range users {
|
||||
assertRuleIsRegistered(t, rule, files)
|
||||
}
|
||||
}
|
||||
|
||||
// splitValidationRules разбирает содержимое тега на отдельные правила.
|
||||
//
|
||||
// Разделителями считаются и `,`, и `|`: go-playground/validator понимает оба —
|
||||
// первый как «и», второй как «или», — и правило, записанное через `|`, обязано
|
||||
// существовать ровно так же.
|
||||
func splitValidationRules(tag string) []string {
|
||||
var rules []string
|
||||
for _, rule := range strings.FieldsFunc(tag, func(r rune) bool { return r == ',' || r == '|' }) {
|
||||
rule = strings.TrimSpace(rule)
|
||||
if rule == "" || rule == "-" {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func assertRuleIsRegistered(t *testing.T, rule string, files map[string]struct{}) {
|
||||
t.Helper()
|
||||
|
||||
where := make([]string, 0, len(files))
|
||||
for file := range files {
|
||||
where = append(where, file)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Errorf(
|
||||
"правило %q не зарегистрировано в controller/validator.go, но используется в %s: %v\n"+
|
||||
"незарегистрированный тег означает панику на КАЖДОМ запросе к этой форме",
|
||||
rule, strings.Join(where, ", "), recovered,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
// Значение подобрано так, чтобы вопрос был именно о существовании правила:
|
||||
// результат проверки здесь не важен, важно её отсутствие паники.
|
||||
_ = validate.Var("hy2xsadmin", rule)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ правила ---
|
||||
|
||||
func loginDto(username string, password string) dto.LoginDto {
|
||||
return dto.LoginDto{Username: &username, Pass: &password}
|
||||
}
|
||||
|
||||
// normalizedAndValidated повторяет конвейер validateField: нормализация, затем
|
||||
// правила. Проверять правила в обход нормализации бессмысленно — в бою они
|
||||
// видят только приведённый вход.
|
||||
func normalizedAndValidated(login dto.LoginDto) error {
|
||||
login.Normalize()
|
||||
return validate.Struct(&login)
|
||||
}
|
||||
|
||||
// Регрессия RC2: корректный вход не имеет права ни паниковать, ни отказывать.
|
||||
func TestLoginDtoPassesProductionValidator(t *testing.T) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("проверка LoginDto паникует: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := normalizedAndValidated(loginDto("hy2xsadmin", "bootstrap-password")); err != nil {
|
||||
t.Fatalf("корректный LoginDto отклонён: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ обязан быть ОТКАЗОМ — значением с причиной, а не паникой.
|
||||
func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
code string
|
||||
field string
|
||||
}{
|
||||
{"кириллица в логине", "админ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"},
|
||||
{"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"},
|
||||
{"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"},
|
||||
{"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeMinLength, "pass"},
|
||||
{"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", service.AdminPasswordMaxLength+1), constant.ErrCodeMaxLength, "pass"},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("проверка обязана вернуть отказ, а не паниковать: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
err := normalizedAndValidated(loginDto(testCase.username, testCase.password))
|
||||
if err == nil {
|
||||
t.Fatal("некорректный вход принят")
|
||||
}
|
||||
|
||||
described := describeValidationErrors(err)
|
||||
if !hasFieldReason(described, testCase.field, testCase.code) {
|
||||
t.Fatalf("ожидалась причина %q на поле %q, получено %+v", testCase.code, testCase.field, described)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hasFieldReason(described []vo.FieldError, field string, code string) bool {
|
||||
for _, reason := range described {
|
||||
if reason.Field == field && reason.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Причина отказа по набору символов обязана НАЗЫВАТЬ набор.
|
||||
//
|
||||
// Оператору негде посмотреть, какие символы допустимы в логине: значение
|
||||
// приходит из hy2xs.env, а панель показывает то, что ответил сервер. Прежнее
|
||||
// «содержит недопустимые символы» вдобавок описывало и отказ по длине.
|
||||
func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) {
|
||||
err := normalizedAndValidated(loginDto("админ12", "bootstrap-password"))
|
||||
if err == nil {
|
||||
t.Fatal("некорректный логин принят")
|
||||
}
|
||||
|
||||
for _, reason := range describeValidationErrors(err) {
|
||||
if reason.Code != constant.ErrCodeCredentialStr {
|
||||
continue
|
||||
}
|
||||
if reason.Params["charset"] != service.AdminUsernameCharset {
|
||||
t.Errorf("причина не называет набор символов: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["min"] != strconv.Itoa(service.AdminUsernameMinLength) {
|
||||
t.Errorf("причина не называет нижнюю границу: %+v", reason.Params)
|
||||
}
|
||||
if reason.Params["max"] != strconv.Itoa(service.AdminUsernameMaxLength) {
|
||||
t.Errorf("причина не называет верхнюю границу: %+v", reason.Params)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("отказ не содержит причины %q", constant.ErrCodeCredentialStr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- контракты ---
|
||||
|
||||
// Границы пароля объявлены в трёх местах и обязаны совпадать.
|
||||
//
|
||||
// Пока они расходились, панель запирала оператора снаружи: форма смены пароля
|
||||
// принимала 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"},
|
||||
} {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
if bounds["max"] != service.AdminPasswordMaxLength {
|
||||
t.Errorf("%s: max=%d, а контракт — %d", subject.name, bounds["max"], service.AdminPasswordMaxLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Набор символов пароля сервером НЕ ограничивается — ни на входе, ни при смене.
|
||||
//
|
||||
// Это решение, а не упущение: значение всё равно сравнивается с хешем, поэтому
|
||||
// ограничение набора не защищает ничего и умеет только отвергнуть пароль,
|
||||
// который сервер принял бы. Панель обязана держаться этого же контракта, и
|
||||
// проверка существует, чтобы правило не «навели» обратно.
|
||||
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"},
|
||||
} {
|
||||
field, _ := subject.typ.FieldByName(subject.field)
|
||||
for _, rule := range splitValidationRules(field.Tag.Get("validate")) {
|
||||
if rule == "credentialStr" || rule == "peerName" {
|
||||
t.Errorf("%s: на пароль повешено правило набора символов %q", subject.name, rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина логина живёт ВНУТРИ правила, а не рядом с ним.
|
||||
//
|
||||
// Два правила длины на одном поле уже приводили к отказу, который невозможно
|
||||
// объяснить оператору: `min=1,max=32` вместе с правилом «6-32» принимали и
|
||||
// отвергали одно и то же значение, а панель показывала «invalid».
|
||||
func TestUsernameLengthLivesInsideItsRule(t *testing.T) {
|
||||
field, ok := reflect.TypeOf(dto.LoginDto{}).FieldByName("Username")
|
||||
if !ok {
|
||||
t.Fatal("LoginDto.Username не найдено")
|
||||
}
|
||||
|
||||
rules := splitValidationRules(field.Tag.Get("validate"))
|
||||
for _, rule := range rules {
|
||||
if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") {
|
||||
t.Errorf("на логине отдельная граница %q: длина принадлежит правилу credentialStr", rule)
|
||||
}
|
||||
}
|
||||
|
||||
var hasCredentialRule bool
|
||||
for _, rule := range rules {
|
||||
if rule == "credentialStr" {
|
||||
hasCredentialRule = true
|
||||
}
|
||||
}
|
||||
if !hasCredentialRule {
|
||||
t.Fatalf("логин проверяется правилами %v — среди них нет credentialStr", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- HTTP ---
|
||||
|
||||
func newAuthTestDB(t *testing.T, username string, password string) int64 {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := dao.RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||
|
||||
// Токен подписывается ключом из таблицы config. Без него успешный вход
|
||||
// отказал бы системной ошибкой, и тест перестал бы отличать работающий
|
||||
// вход от неработающего.
|
||||
if err := dao.UpsertConfigValue(constant.JwtSecret, "test-jwt-secret"); err != nil {
|
||||
t.Fatalf("не удалось записать ключ подписи: %v", err)
|
||||
}
|
||||
|
||||
hash, err := util.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось захешировать пароль: %v", err)
|
||||
}
|
||||
|
||||
name := username
|
||||
status := int64(1)
|
||||
tokenVersion := int64(1)
|
||||
force := int64(0)
|
||||
changedAt := time.Now().UnixMilli()
|
||||
id, err := dao.SaveAdminUser(entity.AdminUser{
|
||||
Username: &name,
|
||||
PasswordHash: &hash,
|
||||
Status: &status,
|
||||
TokenVersion: &tokenVersion,
|
||||
ForcePasswordChange: &force,
|
||||
PasswordChangedAt: &changedAt,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось создать администратора: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// postLoginRaw отправляет тело КАК ЕСТЬ через маршрут боевой конфигурации.
|
||||
//
|
||||
// `recovery` выбирает, стоит ли перед обработчиком gin.Recovery. Обе ветки
|
||||
// нужны: без него паника роняет тест и называет причину, с ним воспроизводится
|
||||
// ровно то, что видел оператор RC2, — HTTP 500 вместо конверта.
|
||||
func postLoginRaw(t *testing.T, body []byte, recovery bool) (int, apiResult) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
engine := gin.New()
|
||||
if recovery {
|
||||
engine.Use(gin.Recovery())
|
||||
}
|
||||
engine.POST("/api/auth/login", Login)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if recorder.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(recorder.Body.Bytes(), &result)
|
||||
}
|
||||
return recorder.Code, result
|
||||
}
|
||||
|
||||
func postLogin(t *testing.T, body any) (int, apiResult) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
return postLoginRaw(t, payload, false)
|
||||
}
|
||||
|
||||
// Регрессия RC2 целиком: вход bootstrap-учёткой обязан выдать токен.
|
||||
func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
status, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
})
|
||||
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("вход ответил HTTP %d, ожидался 200", status)
|
||||
}
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("вход отклонён: %+v", result)
|
||||
}
|
||||
|
||||
var issued struct {
|
||||
TokenType string `json:"tokenType"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Data, &issued); err != nil {
|
||||
t.Fatalf("ответ не содержит токена: %s", string(result.Data))
|
||||
}
|
||||
if strings.TrimSpace(issued.AccessToken) == "" {
|
||||
t.Fatal("выдан пустой токен")
|
||||
}
|
||||
if issued.TokenType != constant.TokenType {
|
||||
t.Fatalf("тип токена %q, ожидался %q", issued.TokenType, constant.TokenType)
|
||||
}
|
||||
}
|
||||
|
||||
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
|
||||
//
|
||||
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
|
||||
// ошибку транспорта, и панель не могла сказать о ней ничего осмысленного.
|
||||
func TestLoginEndpointNeverAnswersServerErrorBehindRecovery(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
bodies := [][]byte{
|
||||
[]byte(`{"username":"hy2xsadmin","pass":"bootstrap-password"}`),
|
||||
[]byte(`{"username":"","pass":""}`),
|
||||
[]byte(`{"username":"админ12","pass":"bootstrap-password"}`),
|
||||
[]byte(`{"username":"hy2xsadmin"}`),
|
||||
[]byte(`{}`),
|
||||
[]byte(`{"username":123,"pass":false}`),
|
||||
[]byte(`не json вовсе`),
|
||||
[]byte(``),
|
||||
}
|
||||
|
||||
for _, body := range bodies {
|
||||
status, result := postLoginRaw(t, body, true)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("тело %q дало HTTP %d: обработчик обязан отвечать конвертом, а не отказом транспорта", string(body), status)
|
||||
continue
|
||||
}
|
||||
if result.Code != constant.CodeSuccess && len(result.Errors) == 0 && strings.TrimSpace(result.Message) == "" {
|
||||
t.Errorf("тело %q дало отказ без причины: %+v", string(body), result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Неверный пароль — доменный отказ с кодом, а не системная ошибка.
|
||||
func TestLoginEndpointReportsInvalidCredentials(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
})
|
||||
|
||||
if result.Code == constant.CodeSuccess {
|
||||
t.Fatal("вход с неверным паролем выполнен")
|
||||
}
|
||||
if _, found := errorFor(t, result, ""); !found {
|
||||
t.Fatalf("отказ без причины: %+v", result)
|
||||
}
|
||||
var hasCode bool
|
||||
for _, reason := range result.Errors {
|
||||
if reason.Code == constant.ErrCodeInvalidCredentials {
|
||||
hasCode = true
|
||||
}
|
||||
}
|
||||
if !hasCode {
|
||||
t.Fatalf("отказ не помечен кодом %q: %+v", constant.ErrCodeInvalidCredentials, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Несуществующий администратор неотличим от неверного пароля.
|
||||
//
|
||||
// Иначе форма входа превращается в способ проверять существование имён.
|
||||
func TestLoginEndpointDoesNotRevealWhetherAccountExists(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, missing := postLogin(t, map[string]any{"username": "otheradmin", "pass": "bootstrap-password"})
|
||||
_, wrong := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "wrong-password"})
|
||||
|
||||
if missing.Code != wrong.Code || missing.Message != wrong.Message {
|
||||
t.Fatalf("ответы различимы: несуществующий %+v против неверного пароля %+v", missing, wrong)
|
||||
}
|
||||
}
|
||||
|
||||
// Логин из bootstrap-admin.secret регулярно приезжает с пробелом или переводом
|
||||
// строки на конце. Раньше это давало «неверный логин или пароль».
|
||||
func TestLoginEndpointTrimsSurroundingWhitespaceInUsername(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
_, result := postLogin(t, map[string]any{
|
||||
"username": " hy2xsadmin\n",
|
||||
"pass": "bootstrap-password",
|
||||
})
|
||||
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("логин с окружающими пробелами отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль НЕ триммится: его набор символов не ограничен, и пробел по краям —
|
||||
// часть значения, а не мусор ввода.
|
||||
func TestLoginEndpointDoesNotTrimPassword(t *testing.T) {
|
||||
newAuthTestDB(t, "hy2xsadmin", "bootstrap-password ")
|
||||
|
||||
_, exact := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password "})
|
||||
if exact.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль с пробелом на конце отклонён: %+v", exact)
|
||||
}
|
||||
|
||||
_, trimmed := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": "bootstrap-password"})
|
||||
if trimmed.Code == constant.CodeSuccess {
|
||||
t.Fatal("пароль обрезан по краям: вход выполнен не тем значением, которое ввёл оператор")
|
||||
}
|
||||
}
|
||||
|
||||
// Пароль длиной в 64 символа, назначенный формой смены, обязан пускать в панель.
|
||||
//
|
||||
// Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при
|
||||
// серверном пределе в 64, и оператор терял доступ после штатной смены пароля.
|
||||
func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) {
|
||||
password := strings.Repeat("a", service.AdminPasswordMaxLength)
|
||||
newAuthTestDB(t, "hy2xsadmin", password)
|
||||
|
||||
_, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
|
||||
if result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("пароль предельной длины отклонён: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Отметка о входе обязана писаться: колонка есть в схеме, а писать её было
|
||||
// некому — UpdateAdminLastLoginAt не вызывался ниоткуда.
|
||||
func TestLoginEndpointRecordsLastLoginAt(t *testing.T) {
|
||||
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
before, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать администратора: %v", err)
|
||||
}
|
||||
if before.LastLoginAt != nil && *before.LastLoginAt != 0 {
|
||||
t.Fatalf("подготовка: отметка о входе уже проставлена (%d)", *before.LastLoginAt)
|
||||
}
|
||||
|
||||
if _, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "bootstrap-password",
|
||||
}); result.Code != constant.CodeSuccess {
|
||||
t.Fatalf("вход отклонён: %+v", result)
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось перечитать администратора: %v", err)
|
||||
}
|
||||
if after.LastLoginAt == nil || *after.LastLoginAt == 0 {
|
||||
t.Fatal("время входа не записано")
|
||||
}
|
||||
}
|
||||
|
||||
// Неудачная попытка отметку о входе НЕ ставит.
|
||||
func TestFailedLoginDoesNotRecordLastLoginAt(t *testing.T) {
|
||||
id := newAuthTestDB(t, "hy2xsadmin", "bootstrap-password")
|
||||
|
||||
if _, result := postLogin(t, map[string]any{
|
||||
"username": "hy2xsadmin",
|
||||
"pass": "wrong-password",
|
||||
}); result.Code == constant.CodeSuccess {
|
||||
t.Fatal("вход с неверным паролем выполнен")
|
||||
}
|
||||
|
||||
after, err := dao.GetAdminUser("id = ?", id)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось прочитать администратора: %v", err)
|
||||
}
|
||||
if after.LastLoginAt != nil && *after.LastLoginAt != 0 {
|
||||
t.Fatalf("неудачная попытка проставила время входа: %d", *after.LastLoginAt)
|
||||
}
|
||||
}
|
||||
@@ -46,27 +46,6 @@ func resolveID(c *gin.Context) (int64, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
loginDto, err := validateField(c, dto.LoginDto{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
|
||||
if err != nil {
|
||||
// Неверные учётные данные получают код, чтобы панель показала
|
||||
// оператору внятную фразу на его языке. Отказ базы остаётся системной
|
||||
// ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite
|
||||
// значит отправить оператора искать несуществующую опечатку.
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
vo.FailDomain(constant.ErrCodeInvalidCredentials, err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Fail(err.Error(), c)
|
||||
return
|
||||
}
|
||||
vo.Success(vo.JwtVo{TokenType: constant.TokenType, AccessToken: token, ForcePasswordChange: forcePasswordChange}, c)
|
||||
}
|
||||
|
||||
func PagePeer(c *gin.Context) {
|
||||
peerPageDto, err := validateField(c, dto.PeerPageDto{})
|
||||
if err != nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -60,21 +59,22 @@ func validatePeerName(f validator.FieldLevel) bool {
|
||||
return service.IsValidPeerName(f.Field().String())
|
||||
}
|
||||
|
||||
// credentialStrPattern — набор символов логина и пароля администратора.
|
||||
// validateCredentialStr — единственное правило имени администратора.
|
||||
//
|
||||
// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая
|
||||
// последствия неэкранированного дефиса в исходной записи `_+-=`. Это сделано
|
||||
// намеренно: имя администратора приходит из HY2XS_ADMIN_USER в hy2xs.env,
|
||||
// оркестратор набор символов не ограничивает, и сужение правила означало бы,
|
||||
// что установка с логином вроде `admin.ops` перестаёт пускать оператора в
|
||||
// панель. Сужать этот набор можно только вместе с проверкой имени на стороне
|
||||
// оркестратора, и это отдельная работа, а не побочный эффект правки формы
|
||||
// пира.
|
||||
var credentialStrPattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+,\-./:;<=]{6,32}$`)
|
||||
|
||||
// Набор символов и длина берутся из service по той же причине, что и у имени
|
||||
// пира: собственная копия правила в слое контроллеров уже расходилась с
|
||||
// остальным продуктом. Здесь она вдобавок была единственным местом, знавшим
|
||||
// набор, — оркестратор писал в hy2xs.env любое имя, а панель проверяла своё
|
||||
// третье правило.
|
||||
//
|
||||
// Пустое значение НЕ впускается. Раньше здесь стояло `field == "" || …`, и это
|
||||
// была ловушка: у поля-указателя `required` считает непустым сам факт
|
||||
// ненулевого указателя, поэтому `{"username":""}` доходило сюда и объявлялось
|
||||
// корректным. Пустой логин отсекается нормализацией DTO, которая превращает
|
||||
// его в «не задано», и получает отказ `required` — то есть ту причину, которая
|
||||
// у него на самом деле.
|
||||
func validateCredentialStr(f validator.FieldLevel) bool {
|
||||
field := f.Field().String()
|
||||
return field == "" || credentialStrPattern.MatchString(field)
|
||||
return service.IsValidAdminUsername(f.Field().String())
|
||||
}
|
||||
|
||||
// validateField разбирает запрос, приводит его к каноничному виду и проверяет
|
||||
@@ -202,8 +202,20 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
|
||||
service.PeerNameMinLength, service.PeerNameMaxLength, service.PeerNameCharset,
|
||||
)
|
||||
case "credentialStr":
|
||||
// Причина называет набор и границы ровно так же, как это делает
|
||||
// `peerName`. Прежнее «содержит недопустимые символы» не отвечало на
|
||||
// вопрос оператора: набор символов логина ему негде посмотреть, а
|
||||
// отказ по длине этой фразой описывался как отказ по символам.
|
||||
described.Code = constant.ErrCodeCredentialStr
|
||||
described.Message = fmt.Sprintf("поле %q содержит недопустимые символы", field)
|
||||
described.Params = map[string]string{
|
||||
"min": fmt.Sprintf("%d", service.AdminUsernameMinLength),
|
||||
"max": fmt.Sprintf("%d", service.AdminUsernameMaxLength),
|
||||
"charset": service.AdminUsernameCharset,
|
||||
}
|
||||
described.Message = fmt.Sprintf(
|
||||
"поле %q: от %d до %d символов из набора %s",
|
||||
field, service.AdminUsernameMinLength, service.AdminUsernameMaxLength, service.AdminUsernameCharset,
|
||||
)
|
||||
default:
|
||||
described.Code = constant.ErrCodeRuleUnknown
|
||||
described.Params = map[string]string{"rule": fieldErr.Tag()}
|
||||
|
||||
@@ -7,41 +7,96 @@ import (
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Набор символов логина и пароля закреплён ФАКТИЧЕСКИМ множеством.
|
||||
// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством.
|
||||
//
|
||||
// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный
|
||||
// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`.
|
||||
// Новая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, оркестратор его
|
||||
// набор символов не ограничивает, и сужение правила означало бы, что установка
|
||||
// с логином вроде `admin.ops` перестаёт пускать оператора в панель.
|
||||
// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя
|
||||
// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила
|
||||
// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
|
||||
// оператора в панель.
|
||||
//
|
||||
// Тест существует, чтобы это решение было явным: попытка «навести порядок» в
|
||||
// классе символов уронит его, а не вход администратора на живом сервере.
|
||||
func TestCredentialCharsetIsUnchanged(t *testing.T) {
|
||||
//
|
||||
// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило
|
||||
// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает
|
||||
// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть
|
||||
// не увидела бы расхождения между копией и правилом.
|
||||
func TestAdminUsernameCharsetIsUnchanged(t *testing.T) {
|
||||
const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<="
|
||||
|
||||
for _, symbol := range strings.Split(historical, "") {
|
||||
candidate := "admin" + symbol
|
||||
if !credentialStrPattern.MatchString(candidate) {
|
||||
if !service.IsValidAdminUsername(candidate) {
|
||||
t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rejected := range []string{
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
"", // не задано
|
||||
"admi", // короче шести символов
|
||||
strings.Repeat("a", 33), // длиннее тридцати двух
|
||||
"admin пробел", // пробел
|
||||
"админ1", // кириллица
|
||||
"admin\n1", // перевод строки
|
||||
"admin'1", // апостроф вне набора
|
||||
} {
|
||||
if credentialStrPattern.MatchString(rejected) {
|
||||
if service.IsValidAdminUsername(rejected) {
|
||||
t.Errorf("значение %q принято логином, ожидался отказ", rejected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор
|
||||
// обязан описывать тот же класс, что и регексп.
|
||||
//
|
||||
// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию:
|
||||
// оператор читает набор, которого правило не принимает.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Длина пароля считается в РУНАХ — ровно так её считает 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) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Контракт учётных данных администратора на стороне панели.
|
||||
*
|
||||
* Зачем этот модуль существует. Правила логина и пароля жили прямо в двух
|
||||
* формах и разошлись и с сервером, и друг с другом:
|
||||
*
|
||||
* форма входа логин 6-32 + набор символов, пароль 6-32 + набор
|
||||
* форма смены пароля пароль 6-64 + набор
|
||||
* сервер логин 6-32 + набор символов, пароль 6-64 без набора
|
||||
*
|
||||
* Следствий было два, и оба закрывали панель. Пароль, назначенный штатной
|
||||
* формой смены, мог оказаться длиннее 32 символов — и форма входа отказывалась
|
||||
* его отправлять: оператор терял доступ после операции, которую панель ему же и
|
||||
* предложила. А набор символов на пароле отвергал значение, которое сервер
|
||||
* принял бы, — панель была строже сервера там, где она не имеет на это права.
|
||||
*
|
||||
* Правило теперь одно на обе формы, и оно сверяется с Go-контрактом
|
||||
* (apps/service/admin_credentials.go) тестом tools/test/frontend-contract.test.ts.
|
||||
*/
|
||||
|
||||
export const ADMIN_USERNAME_MIN_LENGTH = 6;
|
||||
export const ADMIN_USERNAME_MAX_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Набор символов логина в записи регекспа.
|
||||
*
|
||||
* Дефис ЭКРАНИРОВАН намеренно. В прежней записи `[a-zA-Z0-9!@#$%^&*()_+-=]` он
|
||||
* экранирован не был, поэтому `+-=` образовывал ДИАПАЗОН и молча впускал
|
||||
* `, - . / 0-9 : ; < =`. Действующий набор совпадает с этим фактическим
|
||||
* множеством — сужать его нельзя, оно уже работает на установленных
|
||||
* серверах, — но записан явно: пока он выглядел опечаткой, любая попытка
|
||||
* «навести порядок» развела бы панель и сервер обратно.
|
||||
*/
|
||||
const ADMIN_USERNAME_CHARACTER_CLASS = "a-zA-Z0-9!@#$%^&*()_+,\\-./:;<=";
|
||||
|
||||
export const ADMIN_USERNAME_PATTERN = new RegExp(
|
||||
`^[${ADMIN_USERNAME_CHARACTER_CLASS}]{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}}$`
|
||||
);
|
||||
|
||||
/** Тот же набор в том виде, в каком его показывают оператору. */
|
||||
export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=";
|
||||
|
||||
/**
|
||||
* Границы пароля. Набора символов у пароля НЕТ: сервер его не ограничивает ни
|
||||
* при установке, ни при смене, и панель не имеет права отвергать значение,
|
||||
* которое сервер принял бы.
|
||||
*/
|
||||
export const ADMIN_PASSWORD_MIN_LENGTH = 6;
|
||||
export const ADMIN_PASSWORD_MAX_LENGTH = 64;
|
||||
@@ -21,8 +21,12 @@ export default {
|
||||
password: "Password",
|
||||
login: "Login",
|
||||
capsLockOn: "Caps lock is On",
|
||||
usernameFormatIncorrect: "Username format is incorrect",
|
||||
passwordFormatIncorrect: "Password format is incorrect",
|
||||
},
|
||||
// Требования к учётным данным администратора: общие для формы входа и формы
|
||||
// смены пароля. См. комментарий в ru.ts.
|
||||
credentials: {
|
||||
usernameFormat: "Username: {min} to {max} characters from {charset}",
|
||||
passwordLength: "Password: {min} to {max} characters",
|
||||
},
|
||||
dashboard: {
|
||||
stale: "Dashboard data is stale. Retrying automatically...",
|
||||
@@ -161,7 +165,7 @@ export default {
|
||||
gt: "“{field}”: must be greater than {gt}",
|
||||
peer_name:
|
||||
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
|
||||
credential_format: "“{field}”: contains characters that are not allowed",
|
||||
credential_format: "“{field}”: {min} to {max} characters from {charset}",
|
||||
rule_violated: "“{field}”: value is not acceptable",
|
||||
validation_failed: "Validation failed",
|
||||
body_invalid:
|
||||
|
||||
@@ -19,8 +19,19 @@ export default {
|
||||
password: "Пароль",
|
||||
login: "Войти",
|
||||
capsLockOn: "Caps Lock включён",
|
||||
usernameFormatIncorrect: "Неверный формат логина",
|
||||
passwordFormatIncorrect: "Неверный формат пароля",
|
||||
},
|
||||
// Требования к учётным данным администратора. Фразы общие для формы входа и
|
||||
// формы смены пароля: требование одно, и второй его формулировки быть не
|
||||
// должно — расхождение здесь означало бы, что оператору обещают разное про
|
||||
// одно и то же поле.
|
||||
//
|
||||
// Обе фразы НАЗЫВАЮТ требование, а не сообщают о его нарушении. Прежние
|
||||
// «Неверный формат логина» и «Некорректное значение» не давали оператору ни
|
||||
// одного способа узнать, что именно от него хотят: набор символов логина
|
||||
// приходит из hy2xs.env, и посмотреть его в панели негде.
|
||||
credentials: {
|
||||
usernameFormat: "Логин: от {min} до {max} символов из набора {charset}",
|
||||
passwordLength: "Пароль: от {min} до {max} символов",
|
||||
},
|
||||
dashboard: {
|
||||
stale:
|
||||
@@ -167,7 +178,11 @@ export default {
|
||||
gt: "«{field}»: значение должно быть больше {gt}",
|
||||
peer_name:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}. Пробелы, кириллица и знаки / : ; . недопустимы",
|
||||
credential_format: "«{field}»: недопустимые символы",
|
||||
// Сервер присылает границы и набор в params — фраза называет требование
|
||||
// целиком. Прежнее «недопустимые символы» вдобавок описывало этими же
|
||||
// словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое.
|
||||
credential_format:
|
||||
"«{field}»: от {min} до {max} символов из набора {charset}",
|
||||
rule_violated: "«{field}»: значение не подходит",
|
||||
validation_failed: "Проверка данных не пройдена",
|
||||
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
|
||||
|
||||
@@ -33,6 +33,10 @@ import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { adminChangePasswordApi } from "@/api/admin";
|
||||
import { useAdminStore } from "@/store/modules/admin";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -46,7 +50,29 @@ const form = reactive({
|
||||
newPassword: "",
|
||||
});
|
||||
|
||||
const passwordPattern = /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,64}$/;
|
||||
// Проверяется ТОЛЬКО длина, и она берётся из общего контракта.
|
||||
//
|
||||
// Здесь стояло правило набора символов `[a-zA-Z0-9!@#$%^&*()_+-=]`, которого
|
||||
// сервер не предъявляет ни при смене пароля, ни при установке. То есть панель
|
||||
// отказывала оператору в пароле, который сервер принял бы, и сообщала об этом
|
||||
// фразой «Некорректное значение», не называя ни одного требования.
|
||||
//
|
||||
// Границы совпадают с формой входа не случайно: пока они расходились, длинный
|
||||
// пароль, назначенный здесь, невозможно было ввести там.
|
||||
//
|
||||
// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов
|
||||
// отбрасывает только их, и объяснение, называющее убранную конструкцию по
|
||||
// имени, иначе роняет проверку «этой конструкции здесь больше нет».
|
||||
const passwordRule = {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
message: t("credentials.passwordLength", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
}),
|
||||
trigger: ["change", "blur"] as string[],
|
||||
};
|
||||
|
||||
const rules: FormRules = {
|
||||
oldPassword: [
|
||||
{
|
||||
@@ -54,11 +80,7 @@ const rules: FormRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: passwordPattern,
|
||||
message: t("common.invalid"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{ ...passwordRule },
|
||||
],
|
||||
newPassword: [
|
||||
{
|
||||
@@ -66,11 +88,7 @@ const rules: FormRules = {
|
||||
message: t("common.required"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: passwordPattern,
|
||||
message: t("common.invalid"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{ ...passwordRule },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +83,14 @@ import { useAdminStore } from "@/store/modules/admin";
|
||||
// Зависимость API
|
||||
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
|
||||
import { AdminLoginDto } from "@/api/admin/types";
|
||||
import {
|
||||
ADMIN_PASSWORD_MAX_LENGTH,
|
||||
ADMIN_PASSWORD_MIN_LENGTH,
|
||||
ADMIN_USERNAME_CHARSET,
|
||||
ADMIN_USERNAME_MAX_LENGTH,
|
||||
ADMIN_USERNAME_MIN_LENGTH,
|
||||
ADMIN_USERNAME_PATTERN,
|
||||
} from "@/constants/credentials";
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const route = useRoute();
|
||||
@@ -114,6 +122,14 @@ const loginForm = ref<AdminLoginDto>({
|
||||
pass: "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Правила формы входа берутся из общего контракта, а не пишутся здесь.
|
||||
*
|
||||
* У пароля проверяется ТОЛЬКО длина. Прежнее правило требовало ещё и набор
|
||||
* символов, из-за чего форма входа отказывалась отправлять пароль, который
|
||||
* сервер принимает: набор пароля сервер не ограничивает нигде. Проверка,
|
||||
* которая умеет только запереть оператора и ничего не защищает, — не проверка.
|
||||
*/
|
||||
const loginRules = {
|
||||
username: [
|
||||
{
|
||||
@@ -122,8 +138,12 @@ const loginRules = {
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
||||
message: t("login.usernameFormatIncorrect"),
|
||||
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"],
|
||||
},
|
||||
],
|
||||
@@ -134,8 +154,12 @@ const loginRules = {
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
||||
message: t("login.passwordFormatIncorrect"),
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
message: t("credentials.passwordLength", {
|
||||
min: ADMIN_PASSWORD_MIN_LENGTH,
|
||||
max: ADMIN_PASSWORD_MAX_LENGTH,
|
||||
}),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
@@ -205,10 +229,61 @@ const handleLogin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Видимое поле формы входа — это `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 {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
package dto
|
||||
|
||||
// Границы пароля здесь и в LoginDto обязаны совпадать.
|
||||
//
|
||||
// Пока они расходились, панель запирала оператора снаружи после операции,
|
||||
// которую сама же и предлагала: форма смены пароля принимала 64 символа, а
|
||||
// форма входа — 32, и назначенный длинный пароль становился паролем, с которым
|
||||
// невозможно войти. Числа в тегах сверяются с service.AdminPassword*Length в
|
||||
// TestAdminPasswordBoundsAreOneContract — в теге структуры константу не
|
||||
// подставить.
|
||||
//
|
||||
// Набора символов здесь нет и не было: пароль принадлежит оператору целиком.
|
||||
|
||||
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"`
|
||||
|
||||
+47
-1
@@ -1,6 +1,52 @@
|
||||
package dto
|
||||
|
||||
// Контракт формы входа.
|
||||
//
|
||||
// Раньше на Username стояло `required,min=6,max=32,validateStr`, и правила
|
||||
// `validateStr` не существовало: оно пережило переименование в `credentialStr`
|
||||
// и осталось ссылкой на незарегистрированную функцию. go-playground/validator в
|
||||
// таком случае ПАНИКУЕТ, поэтому каждый POST /api/auth/login заканчивался
|
||||
// паникой, которую gin.Recovery превращал в HTTP 500 — панель после чистой
|
||||
// установки была недоступна целиком. Ни один тест этого не видел: проверялся
|
||||
// сам регексп, а не LoginDto через production-валидатор, и цепочка правил на
|
||||
// нулевом DTO обрывается на `required` раньше, чем доходит до испорченного
|
||||
// тега.
|
||||
//
|
||||
// Границы длины логина живут ВНУТРИ правила `credentialStr`, а не рядом с ним.
|
||||
// Два правила на одном поле уже приводили к отказу, который невозможно
|
||||
// объяснить: имя пира с `min=1,max=32,validateStr` принималось одним правилом
|
||||
// и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция
|
||||
// была бы той же ошибкой.
|
||||
//
|
||||
// У пароля набора символов НЕТ, и это осознанно: сервер нигде его не
|
||||
// ограничивает — ни при установке, ни при смене пароля. Ограничение на входе
|
||||
// не защищало бы ничего (значение всё равно сравнивается с хешем) и умело
|
||||
// только запирать оператора с паролем, который сервер принял бы.
|
||||
//
|
||||
// Числа в тегах обязаны совпадать с service.AdminPassword*Length; за этим
|
||||
// следит TestLoginPasswordBoundsMatchContract — в теге структуры константу не
|
||||
// подставить.
|
||||
type LoginDto struct {
|
||||
Username *string `json:"username" form:"username" validate:"required,min=6,max=32,validateStr"`
|
||||
Username *string `json:"username" form:"username" validate:"required,credentialStr"`
|
||||
Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"`
|
||||
}
|
||||
|
||||
// Normalize: незаполненное поле обязано выглядеть незаполненным.
|
||||
//
|
||||
// У поля-указателя `required` считает значение заданным по ненулевому
|
||||
// указателю, поэтому `{"username":"","pass":""}` проходило `required` целиком и
|
||||
// получало отказ по набору символов и по длине — то есть оператор, отправивший
|
||||
// пустую форму, читал про недопустимые символы вместо «поле обязательно».
|
||||
//
|
||||
// Логин при этом ещё и триммится: скопированное из bootstrap-admin.secret
|
||||
// значение регулярно приезжает с пробелом или переводом строки на конце, и
|
||||
// раньше это давало «неверный логин или пароль» — отказ, который невозможно
|
||||
// связать с причиной.
|
||||
//
|
||||
// Пароль НЕ триммится. Набор его символов не ограничен, пробел по краям —
|
||||
// законная часть значения, и молчаливое его удаление означало бы, что панель
|
||||
// отправляет не то, что ввёл оператор.
|
||||
func (d *LoginDto) Normalize() {
|
||||
blankToNil(&d.Username)
|
||||
emptyToNil(&d.Pass)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,24 @@ func trimValue(field *string) {
|
||||
*field = strings.TrimSpace(*field)
|
||||
}
|
||||
|
||||
// emptyToNil: пустая строка означает «не задано», и ничего больше.
|
||||
//
|
||||
// Отличается от blankToNil тем, что НЕ трогает пробелы. Применяется к полям,
|
||||
// содержимое которых принадлежит оператору целиком, — к паролю: набор его
|
||||
// символов не ограничен, пробел по краям является частью значения, и
|
||||
// молчаливое его удаление означало бы, что на сервер уезжает не то, что было
|
||||
// введено. Пустое поле при этом обязано получить отказ `required`, а не отказ
|
||||
// по длине: у поля-указателя `required` считает значение заданным по самому
|
||||
// факту ненулевого указателя.
|
||||
func emptyToNil(field **string) {
|
||||
if *field == nil {
|
||||
return
|
||||
}
|
||||
if **field == "" {
|
||||
*field = nil
|
||||
}
|
||||
}
|
||||
|
||||
// zeroToNil: ноль у необязательного числового поля означает «не задано».
|
||||
//
|
||||
// Применяется ТОЛЬКО там, где ноль не является осмысленным значением:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
@@ -58,6 +59,23 @@ func Login(username string, plainPassword string) (string, bool, error) {
|
||||
if tokenErr != nil {
|
||||
return "", false, tokenErr
|
||||
}
|
||||
// Отметка о входе ставится здесь, а не в слое контроллеров.
|
||||
//
|
||||
// Колонка `last_login_at` объявлена и в схеме, и в entity, а писать её было
|
||||
// некому: UpdateAdminLastLoginAt существовал и не вызывался ниоткуда, то
|
||||
// есть панель показывала бы «последний вход» как 1970 год на живом сервере.
|
||||
// Место выбрано так, чтобы записать её было невозможно забыть: успешная
|
||||
// проверка пароля — единственная дверь, и она здесь одна.
|
||||
//
|
||||
// Отказ записи НЕ отменяет вход. Учётные данные уже подтверждены, и
|
||||
// превращать неудачу бухгалтерии в отказ аутентификации значило бы закрыть
|
||||
// панель из-за строки статистики. Молчать при этом тоже нельзя: неписаная
|
||||
// отметка — расхождение между тем, что показывает панель, и тем, что
|
||||
// произошло.
|
||||
if err = UpdateAdminLastLoginAt(*admin.Id, time.Now().UnixMilli()); err != nil {
|
||||
logrus.Errorf("не удалось записать время входа администратора %d: %v", *admin.Id, err)
|
||||
}
|
||||
|
||||
// Признак «пароль в старом формате, заставить сменить» отсюда убран вместе
|
||||
// с поддержкой самого формата: единственный источник этого требования —
|
||||
// флаг force_password_change, который ставит установка.
|
||||
|
||||
Reference in New Issue
Block a user