Files
founder c0a43ae915 fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
2026-09-01 07:27:15 +05:00

130 lines
6.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
)
// Токены подписываются и проверяются библиотекой golang-jwt/jwt/v5.
//
// Переход с v3 не косметический. У `github.com/golang-jwt/jwt` v3.2.2 есть
// GO-2025-3553 (чрезмерное выделение памяти при разборе заголовка), и у неё
// НЕТ исправленной версии в ветке v3: `Fixed in: N/A`. Уязвимый код
// достигается из ParseToken, то есть с неаутентифицированного запроса — любой,
// кто может обратиться к панели, попадает в него до всякой проверки прав.
// Обновить здесь было нечего, поэтому лечится только сменой мажорной ветки.
//
// Заодно закрыт второй, тихий недостаток прежней реализации: keyfunc возвращал
// ключ, НЕ проверяя алгоритм подписи. Разбор без ограничения набора алгоритмов
// — это тот класс ошибок, из которого растёт подмена алгоритма; ниже стоит
// jwt.WithValidMethods, и допустимый алгоритм ровно один.
const TokenExpireDuration = time.Hour * 24
// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами,
// не должен разбираться как наш даже при совпадении ключа.
const tokenIssuer = "hy2xs-admin"
type MyClaims struct {
Admin bo.AccountBo `json:"admin"`
jwt.RegisteredClaims
}
func jwtSecret() ([]byte, error) {
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
if err != nil {
return nil, errors.New(constant.SysError)
}
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
// Подпись пустым ключом дала бы токены, которые подделывает кто угодно.
// Раньше значение разыменовывалось без проверки: пустой JWT_SECRET
// молча превращался в ключ нулевой длины.
return nil, errors.New(constant.SysError)
}
return []byte(*config.Value), nil
}
func GenToken(accountBo bo.AccountBo) (string, error) {
secret, err := jwtSecret()
if err != nil {
return "", err
}
now := time.Now()
claims := MyClaims{
Admin: accountBo,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(TokenExpireDuration)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Issuer: tokenIssuer,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
// Отказ разбора токена — это ЗНАЧЕНИЕ, а не свежая ошибка с текстом внутри.
//
// Различие существенно для вызывающего: middleware обязан отличить истёкшую
// сессию от недействительного токена, потому что оператору это показывается
// по-разному — «сессия истекла, войдите заново» против «войдите». Раньше
// единственным способом задать этот вопрос было сравнение err.Error() с
// константой, то есть разбор человеческого текста; такое сравнение молча
// перестаёт работать при первой же правке формулировки.
//
// Тексты сохранены прежними: они уезжают в ответ панели.
var (
// ErrTokenExpired — токен разобран, но его срок истёк.
ErrTokenExpired = errors.New(constant.TokenExpiredError)
// ErrTokenInvalid — токен не разобран, подписан не тем ключом или не
// содержит ожидаемых утверждений.
ErrTokenInvalid = errors.New(constant.IllegalTokenError)
)
func ParseToken(tokenString string) (*MyClaims, error) {
secret, err := jwtSecret()
if err != nil {
return nil, err
}
token, err := jwt.ParseWithClaims(
tokenString,
&MyClaims{},
func(*jwt.Token) (interface{}, error) { return secret, nil },
// Допустимый алгоритм ровно один. Без этого ограничения набор
// алгоритмов задаёт сам токен, то есть недоверенный вход.
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(tokenIssuer),
// Токен без срока жизни не должен считаться валидным бессрочным.
jwt.WithExpirationRequired(),
)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, ErrTokenInvalid
}
claims, ok := token.Claims.(*MyClaims)
if !ok || !token.Valid {
return nil, ErrTokenInvalid
}
return claims, nil
}
// Функции GetToken здесь больше нет.
//
// Она доставала токен из заголовка Authorization вторым, независимым от
// middleware.JWTHandler способом — и делала это через
// strings.SplitN(tokenStr, " ", 2)[1], то есть паниковала на заголовке без
// пробела: `Authorization: abc` давал index out of range. Единственный
// потребитель — резервная ветка GetAdminInfo, которая была недостижима и
// проверяла меньше, чем middleware. Разбор токена у продукта ровно один.