// admin-reset is a one-off CLI for resetting an admin_users row's password. // // It reads DB connection settings from environment variables (the same names // the API uses), looks up the admin user by email, prompts for a new password // twice (no echo), bcrypts it, and updates the row. Safe to keep in the repo // — running it requires DB credentials. // // Usage: // // # load env (host, user, db, sslmode) and password from secrets file // set -a && source deploy/prod.env && set +a // go run ./cmd/admin-reset // // # or with a non-default secrets path / different admin // go run ./cmd/admin-reset --password-file path/to/postgres_password.txt // go run ./cmd/admin-reset --email someone@example.com package main import ( "bufio" "errors" "flag" "fmt" "os" "strconv" "strings" "time" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" "golang.org/x/term" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/treytartt/honeydue-api/internal/models" ) const minPasswordLen = 12 func main() { email := flag.String("email", "admin@myhoneydue.com", "Admin email to reset") passwordFile := flag.String("password-file", "deploy/secrets/postgres_password.txt", "Path to file containing POSTGRES_PASSWORD (used if env var is empty)") list := flag.Bool("list", false, "List all rows in admin_users and exit (no changes)") verify := flag.Bool("verify", false, "Prompt for a password and check it against the stored hash; no changes") newEmail := flag.String("new-email", "", "If set: rename the matched admin's email to this value and exit (no password change)") flag.Parse() log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) dsn, host, err := buildDSN(*passwordFile) if err != nil { log.Fatal().Err(err).Msg("failed to build database DSN") } db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { log.Fatal().Err(err).Msg("failed to connect to database") } if *list { var admins []models.AdminUser if err := db.Order("id").Find(&admins).Error; err != nil { log.Fatal().Err(err).Msg("failed to list admin users") } fmt.Fprintf(os.Stderr, "DB host: %s\n%d admin user(s):\n\n", host, len(admins)) fmt.Fprintf(os.Stderr, "%-4s %-40s %-12s %-6s %s\n", "ID", "EMAIL", "ROLE", "ACTIVE", "LAST_LOGIN") for _, a := range admins { last := "-" if a.LastLogin != nil { last = a.LastLogin.Format(time.RFC3339) } fmt.Fprintf(os.Stderr, "%-4d %-40s %-12s %-6t %s\n", a.ID, a.Email, a.Role, a.IsActive, last) } return } // Mirror the live API's case-insensitive lookup so --verify reflects what // /api/admin/auth/login actually does. The reset path uses the same query // for consistency. var admin models.AdminUser if err := db.Where("LOWER(email) = LOWER(?)", *email).First(&admin).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { log.Fatal().Str("email", *email).Msg("admin user not found (try --list to see existing rows)") } log.Fatal().Err(err).Msg("failed to look up admin user") } if *newEmail != "" { target := strings.TrimSpace(*newEmail) if target == "" || !strings.Contains(target, "@") { log.Fatal().Str("new_email", *newEmail).Msg("--new-email must be a valid email address") } if strings.EqualFold(target, admin.Email) { fmt.Fprintf(os.Stderr, "No change — current email already matches %q\n", target) return } // Catch the unique-index conflict early with a clear message instead of a Postgres error. var collisionCount int64 if err := db.Model(&models.AdminUser{}). Where("LOWER(email) = LOWER(?) AND id <> ?", target, admin.ID). Count(&collisionCount).Error; err != nil { log.Fatal().Err(err).Msg("failed to check for email collision") } if collisionCount > 0 { log.Fatal().Str("new_email", target).Msg("another admin row already uses this email — aborting") } fmt.Fprintf(os.Stderr, "Renaming admin email: %s → %s (id=%d)\n", admin.Email, target, admin.ID) fmt.Fprintf(os.Stderr, "DB host: %s\n\n", host) res := db.Model(&models.AdminUser{}). Where("id = ?", admin.ID). Updates(map[string]any{ "email": target, "updated_at": time.Now().UTC(), }) if res.Error != nil { log.Fatal().Err(res.Error).Msg("failed to rename admin email") } if res.RowsAffected != 1 { log.Fatal().Int64("rows", res.RowsAffected).Msg("expected exactly 1 row updated") } fmt.Fprintf(os.Stderr, "OK — email is now %s\n", target) return } if *verify { fmt.Fprintf(os.Stderr, "Verifying password for: %s (id=%d, role=%s, active=%t)\n", admin.Email, admin.ID, admin.Role, admin.IsActive) fmt.Fprintf(os.Stderr, "DB host: %s\n\n", host) pw, err := readPassword("Password: ") if err != nil { log.Fatal().Err(err).Msg("failed to read password") } if admin.CheckPassword(pw) { fmt.Fprintln(os.Stderr, "PASS — bcrypt hash matches the supplied password") if !admin.IsActive { fmt.Fprintln(os.Stderr, "WARNING: is_active = false — login will still be rejected with \"Account is disabled\"") } } else { fmt.Fprintln(os.Stderr, "FAIL — bcrypt hash does NOT match the supplied password") os.Exit(1) } return } fmt.Fprintf(os.Stderr, "Resetting password for: %s (id=%d, role=%s, active=%t)\n", admin.Email, admin.ID, admin.Role, admin.IsActive) fmt.Fprintf(os.Stderr, "DB host: %s\n\n", host) pw1, err := readPassword("New password: ") if err != nil { log.Fatal().Err(err).Msg("failed to read password") } if len(pw1) < minPasswordLen { log.Fatal().Int("min", minPasswordLen).Msg("password too short") } pw2, err := readPassword("Confirm password: ") if err != nil { log.Fatal().Err(err).Msg("failed to read password") } if pw1 != pw2 { log.Fatal().Msg("passwords do not match") } hash, err := bcrypt.GenerateFromPassword([]byte(pw1), bcrypt.DefaultCost) if err != nil { log.Fatal().Err(err).Msg("failed to hash password") } res := db.Model(&models.AdminUser{}). Where("id = ?", admin.ID). Updates(map[string]any{ "password": string(hash), "updated_at": time.Now().UTC(), }) if res.Error != nil { log.Fatal().Err(res.Error).Msg("failed to update admin user") } if res.RowsAffected != 1 { log.Fatal().Int64("rows", res.RowsAffected).Msg("expected exactly 1 row updated") } fmt.Fprintf(os.Stderr, "\nOK — password reset for %s\n", admin.Email) } func buildDSN(passwordFile string) (dsn, host string, err error) { host = os.Getenv("DB_HOST") user := os.Getenv("POSTGRES_USER") dbname := os.Getenv("POSTGRES_DB") sslmode := os.Getenv("DB_SSLMODE") if sslmode == "" { sslmode = "require" } port := 5432 if s := os.Getenv("DB_PORT"); s != "" { p, perr := strconv.Atoi(s) if perr != nil { return "", "", fmt.Errorf("invalid DB_PORT %q: %w", s, perr) } port = p } password := os.Getenv("POSTGRES_PASSWORD") if password == "" && passwordFile != "" { b, rerr := os.ReadFile(passwordFile) if rerr != nil { return "", "", fmt.Errorf("POSTGRES_PASSWORD not set and could not read %s: %w", passwordFile, rerr) } password = strings.TrimRight(string(b), "\r\n") } missing := []string{} if host == "" { missing = append(missing, "DB_HOST") } if user == "" { missing = append(missing, "POSTGRES_USER") } if dbname == "" { missing = append(missing, "POSTGRES_DB") } if password == "" { missing = append(missing, "POSTGRES_PASSWORD") } if len(missing) > 0 { return "", "", fmt.Errorf("missing required env vars: %s", strings.Join(missing, ", ")) } dsn = fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", host, port, user, password, dbname, sslmode) return dsn, host, nil } func readPassword(prompt string) (string, error) { fmt.Fprint(os.Stderr, prompt) if term.IsTerminal(int(os.Stdin.Fd())) { b, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { return "", err } return strings.TrimRight(string(b), "\r\n"), nil } s, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { return "", err } return strings.TrimRight(s, "\r\n"), nil }