a8407cf16b
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.
157 lines
7.2 KiB
Go
157 lines
7.2 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/sirupsen/logrus"
|
|
"hy2xs-admin/dao"
|
|
"hy2xs-admin/model/bo"
|
|
"hy2xs-admin/model/constant"
|
|
"hy2xs-admin/model/entity"
|
|
"hy2xs-admin/model/vo"
|
|
"hy2xs-admin/util"
|
|
)
|
|
|
|
func adminClaimsFromContext(c *gin.Context) (bo.AccountBo, bool) {
|
|
if c == nil {
|
|
return bo.AccountBo{}, false
|
|
}
|
|
v, ok := c.Get("adminClaims")
|
|
if !ok {
|
|
return bo.AccountBo{}, false
|
|
}
|
|
claims, castOK := v.(bo.AccountBo)
|
|
return claims, castOK
|
|
}
|
|
|
|
// ErrInvalidCredentials — логин или пароль не подошли.
|
|
//
|
|
// ОДНО значение на оба случая, и это не упрощение. «Такого администратора
|
|
// нет» и «пароль не тот» обязаны быть неразличимы снаружи: иначе форма входа
|
|
// превращается в способ проверять существование имён администраторов, а
|
|
// панель слушает только localhost именно потому, что вход — самая ценная
|
|
// дверь продукта.
|
|
//
|
|
// Отказ хранилища при этом сюда НЕ сворачивается: слой данных уже умеет
|
|
// отличать «записи нет» от «база не ответила» (dao.IsNotFound), и недоступная
|
|
// SQLite обязана выглядеть как системная ошибка, а не как неверный пароль.
|
|
var ErrInvalidCredentials = errors.New(constant.WrongPassword)
|
|
|
|
func Login(username string, plainPassword string) (string, bool, error) {
|
|
admin, err := dao.GetAdminUser("username = ? and status = 1", username)
|
|
if err != nil {
|
|
if dao.IsNotFound(err) {
|
|
return "", false, ErrInvalidCredentials
|
|
}
|
|
return "", false, err
|
|
}
|
|
if !util.VerifyPassword(plainPassword, *admin.PasswordHash) {
|
|
return "", false, ErrInvalidCredentials
|
|
}
|
|
tokenVersion := int64(1)
|
|
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
|
tokenVersion = *admin.TokenVersion
|
|
}
|
|
accountBo := bo.AccountBo{Id: *admin.Id, Username: *admin.Username, Roles: []string{"admin"}, Deleted: 0, TokenVersion: tokenVersion}
|
|
token, tokenErr := GenToken(accountBo)
|
|
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, который ставит установка.
|
|
requirePasswordChange := false
|
|
if admin.ForcePasswordChange != nil {
|
|
requirePasswordChange = *admin.ForcePasswordChange != 0
|
|
}
|
|
return token, requirePasswordChange, nil
|
|
}
|
|
|
|
// GetAdminInfo читает учётные данные ТОЛЬКО из контекста запроса.
|
|
//
|
|
// Резервной ветки «если в контексте пусто — разобрать токен самостоятельно»
|
|
// здесь больше нет. Она была недостижима: все маршруты, ведущие сюда, стоят за
|
|
// middleware.JWTHandler, который разбирает токен, сверяет token_version и
|
|
// кладёт claims в контекст. При этом ветка несла собственный разбор заголовка
|
|
// Authorization через service.GetToken, где strings.SplitN(...)[1] паниковал
|
|
// на заголовке без пробела.
|
|
//
|
|
// Хуже недостижимости было бы её оживление: этот путь не проверял ни статус
|
|
// учётной записи, ни версию токена, то есть отозванный токен прошёл бы здесь,
|
|
// но не прошёл бы через middleware. Второй способ аутентификации, слабее
|
|
// первого, — не запасной вариант, а обход.
|
|
func GetAdminInfo(c *gin.Context) (vo.AdminInfoVo, error) {
|
|
claims, ok := adminClaimsFromContext(c)
|
|
if !ok {
|
|
return vo.AdminInfoVo{}, errors.New(constant.UnauthorizedError)
|
|
}
|
|
admin, err := dao.GetAdminUser("id = ?", claims.Id)
|
|
if err != nil {
|
|
return vo.AdminInfoVo{}, err
|
|
}
|
|
if admin.Status != nil && *admin.Status == 0 {
|
|
return vo.AdminInfoVo{}, errors.New("this account has been disabled")
|
|
}
|
|
force := admin.ForcePasswordChange != nil && *admin.ForcePasswordChange != 0
|
|
return vo.AdminInfoVo{Id: claims.Id, Username: claims.Username, Roles: claims.Roles, ForcePasswordChange: force}, nil
|
|
}
|
|
|
|
func UpdateAdminLastLoginAt(id int64, loginAt int64) error {
|
|
return dao.UpdateAdminUser([]int64{id}, map[string]interface{}{"last_login_at": loginAt})
|
|
}
|
|
|
|
func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string) error {
|
|
info, err := GetAdminInfo(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
admin, err := dao.GetAdminUser("id = ?", info.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if admin.PasswordHash == nil {
|
|
return errors.New(constant.SysError)
|
|
}
|
|
if !util.VerifyPassword(oldPassword, *admin.PasswordHash) {
|
|
return errors.New(constant.WrongPassword)
|
|
}
|
|
hash, hashErr := util.HashPassword(newPassword)
|
|
if hashErr != nil {
|
|
return hashErr
|
|
}
|
|
nowMs := time.Now().UnixMilli()
|
|
currentTokenVersion := int64(1)
|
|
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
|
|
currentTokenVersion = *admin.TokenVersion
|
|
}
|
|
return dao.UpdateAdminUser([]int64{info.Id}, map[string]interface{}{
|
|
"password_hash": hash,
|
|
"force_password_change": 0,
|
|
"password_changed_at": nowMs,
|
|
"token_version": currentTokenVersion + 1,
|
|
})
|
|
}
|
|
|
|
func GetAdminForTokenValidation(id int64) (entity.AdminUser, error) {
|
|
return dao.GetAdminUser("id = ?", id)
|
|
}
|