package cmd import ( "errors" "fmt" "github.com/spf13/cobra" "hy2xs-admin/dao" "hy2xs-admin/model/entity" "hy2xs-admin/util" "os" "time" ) var resetCmd = &cobra.Command{ Use: "reset-admin", Short: "Reset admin username and password", Long: "Reset admin username and password.", Run: runReset, } func init() { rootCmd.AddCommand(resetCmd) } // Длины восстановительных учётных данных. // // Было по 6 символов на оба поля. Шесть символов алфавита из 62 — это около 36 // бит, то есть ровно нижняя граница, которую пропускает HashPassword, и // единственной защитой оставался force_password_change. Пароль восстановления // живёт до первого входа администратора, но до этого входа он полноценно // пускает в панель, поэтому его стойкость должна соответствовать остальным // секретам продукта, а не минимально допустимой. const ( resetUsernameLength = 12 resetPasswordLength = 24 ) // adminLookup — способ узнать о существующей учётной записи администратора. // // Параметризовано ради теста на отказ хранилища. Отличить «ветку создания» от // «ветки обновления» при недоступной базе иначе нельзя: при по-настоящему // сломанной базе обе ветки заканчиваются ошибкой записи, и наблюдаемый // результат совпадает. Опасен же ровно транзиентный отказ, когда чтение упало, // а запись прошла. type adminLookup func() (entity.AdminUser, error) func runReset(cmd *cobra.Command, args []string) { username, err := util.RandomString(resetUsernameLength) if err != nil { fmt.Println(err.Error()) os.Exit(1) } password, err := util.RandomString(resetPasswordLength) if err != nil { fmt.Println(err.Error()) os.Exit(1) } if err = dao.InitSql(); err != nil { fmt.Println(err.Error()) os.Exit(1) } lookup := func() (entity.AdminUser, error) { return dao.GetAdminUser("1 = 1") } if err = resetAdminCredentials(lookup, username, password, time.Now().UnixMilli()); err != nil { fmt.Println(err.Error()) os.Exit(1) } if err = dao.CloseSqliteDB(); err != nil { fmt.Println(err.Error()) os.Exit(1) } fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username)) fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password)) } func resetAdminCredentials(lookup adminLookup, username, password string, nowMs int64) error { // Хеш считается ОДИН раз и ДО записи. // // В ветке обновления стояло `hash, _ := util.HashPassword(password)` внутри // литерала map. Ошибка bcrypt проглатывалась, в password_hash уезжала пустая // строка, а на экран печатался пароль, которым войти уже невозможно: // VerifyPassword отклоняет всё, что не является bcrypt-хешем. То есть // команда восстановления доступа умела молча его отобрать. hash, err := util.HashPassword(password) if err != nil { return fmt.Errorf("не удалось захешировать пароль восстановления: %w", err) } admin, err := lookup() // Отказ хранилища — это не «администратора нет». // // Здесь стояло обычное `if err != nil { создать } else { обновить }`, хотя // слой данных специально различает ErrAdminUserNotFound и ErrStorage. // Склейка опасна не только нарушением смысла sentinel'ов: при транзиентном // отказе SQLite («database is locked») ветка создания отрабатывала успешно, // и в таблице оказывались ДВЕ учётные записи администратора. // `GetAdminUser("1 = 1").First()` дальше отдаёт произвольную из них, то есть // на сервере остаётся вторая рабочая учётка с паролем, который уже был // напечатан на экран. switch { case err == nil: tokenVersion := int64(1) if admin.TokenVersion != nil && *admin.TokenVersion > 0 { tokenVersion = *admin.TokenVersion + 1 } if updateErr := dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{ "username": username, "password_hash": hash, "force_password_change": 1, "password_changed_at": nowMs, "token_version": tokenVersion, "status": 1, }); updateErr != nil { return fmt.Errorf("не удалось обновить учётную запись администратора: %w", updateErr) } return nil case errors.Is(err, dao.ErrAdminUserNotFound): tokenVersion := int64(1) status := int64(1) forcePasswordChange := int64(1) passwordChangedAt := nowMs adminUser := entity.AdminUser{ Username: &username, PasswordHash: &hash, Status: &status, ForcePasswordChange: &forcePasswordChange, PasswordChangedAt: &passwordChangedAt, TokenVersion: &tokenVersion, } if _, saveErr := dao.SaveAdminUser(adminUser); saveErr != nil { return fmt.Errorf("не удалось создать учётную запись администратора: %w", saveErr) } return nil default: return fmt.Errorf( "не удалось прочитать учётную запись администратора: база данных не ответила. "+ "Сброс не выполнен: создавать вторую учётную запись при недоступной базе нельзя: %w", err, ) } }