Skip to content

Commit e7b778a

Browse files
authored
Add --interactive option for ha auth reset (#558)
Add option to perform interactive reset of user password. In that case, when no username argument is provided, a list of existing users is shown first. With username selected or given, prompt for a password is shown, read with no echo to the terminal for privacy. This can be especially useful when the username contains characters that can't be entered in the OS console. While the framebuffer console still may not show all the characters, it may give enough clues to select the users for whom the password should be reset. See: home-assistant/operating-system#3879
1 parent b4329b7 commit e7b778a

2 files changed

Lines changed: 178 additions & 4 deletions

File tree

client/helper.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@ package client
33
import (
44
"bufio"
55
"encoding/json"
6+
"errors"
67
"fmt"
8+
"golang.org/x/term"
79
"io"
810
"net/url"
911
"os"
12+
"os/signal"
1013
"path"
14+
"strconv"
15+
"syscall"
1116
"time"
1217

1318
yaml "github.com/ghodss/yaml"
@@ -219,3 +224,76 @@ func AskForConfirmation(prompt string, tries int) bool {
219224
}
220225
return false
221226
}
227+
228+
func ReadInteger(prompt string, tries int, min int, max int) (int, error) {
229+
reader := bufio.NewReader(os.Stdin)
230+
if tries <= 0 {
231+
tries = 2
232+
}
233+
234+
for ; tries > 0; tries-- {
235+
fmt.Printf("%s [%d-%d]: ", prompt, min, max)
236+
237+
res, err := reader.ReadString('\n')
238+
if err != nil {
239+
log.Fatalf("error: %v", err)
240+
continue
241+
}
242+
243+
res = strings.TrimSpace(res)
244+
if len(res) == 0 {
245+
continue
246+
}
247+
248+
val, err := strconv.Atoi(res)
249+
if err != nil || val < min || val > max {
250+
fmt.Printf("Invalid value. Must be between %d and %d.\n", min, max)
251+
continue
252+
}
253+
return val, nil
254+
}
255+
256+
return 0, errors.New("maximum tries exceeded")
257+
}
258+
259+
func ReadPassword(repeat bool) (string, error) {
260+
initialState, err := term.GetState(syscall.Stdin)
261+
if err != nil {
262+
return "", err
263+
}
264+
265+
// Make sure terminal is restored on termination
266+
signalChan := make(chan os.Signal, 1)
267+
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
268+
go func() {
269+
<-signalChan
270+
err := term.Restore(syscall.Stdin, initialState)
271+
if err != nil {
272+
fmt.Println("Failed to restore terminal state!")
273+
panic(err)
274+
}
275+
os.Exit(1)
276+
}()
277+
278+
fmt.Print("Password: ")
279+
password, err := term.ReadPassword(syscall.Stdin)
280+
fmt.Println()
281+
if err != nil {
282+
return "", err
283+
}
284+
285+
if repeat {
286+
fmt.Print("Password (again): ")
287+
password2, err := term.ReadPassword(syscall.Stdin)
288+
fmt.Println()
289+
if err != nil {
290+
return "", err
291+
}
292+
293+
if string(password) != string(password2) {
294+
return "", errors.New("passwords do not match")
295+
}
296+
}
297+
298+
return string(password), nil
299+
}

cmd/auth_reset.go

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,60 @@ import (
88
"github.com/spf13/cobra"
99
)
1010

11+
type User struct {
12+
Username string
13+
Name string
14+
Owner bool
15+
Active bool
16+
LocalOnly bool
17+
}
18+
19+
var MsgLimitedAccess = "For security reasons, this command works only from the operating system terminal."
20+
21+
func getUsers() ([]User, error) {
22+
resp, err := helper.GenericJSONGet("auth", "list")
23+
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
var data *helper.Response
29+
var result []User
30+
31+
if resp.IsSuccess() {
32+
data = resp.Result().(*helper.Response)
33+
34+
if data.Result != "ok" {
35+
err := fmt.Errorf("error returned from Supervisor: %s", data.Message)
36+
return nil, err
37+
}
38+
39+
for _, user := range data.Data["users"].([]interface{}) {
40+
user := user.(map[string]interface{})
41+
result = append(result, User{
42+
Username: user["username"].(string),
43+
Name: user["name"].(string),
44+
Owner: user["is_owner"].(bool),
45+
Active: user["is_active"].(bool),
46+
LocalOnly: user["local_only"].(bool),
47+
})
48+
}
49+
} else {
50+
data = resp.Error().(*helper.Response)
51+
err := fmt.Errorf("error returned from Supervisor: %s", data.Message)
52+
return nil, err
53+
}
54+
55+
return result, nil
56+
}
57+
58+
func listUsers(users []User) {
59+
for i, user := range users {
60+
fmt.Printf("- %d: %s (%s)\n", i+1, user.Username, user.Name)
61+
fmt.Printf(" owner: %t, active: %t, local only: %t\n", user.Owner, user.Active, user.LocalOnly)
62+
}
63+
}
64+
1165
var authResetCmd = &cobra.Command{
1266
Use: "reset",
1367
Aliases: []string{"rst", "change"},
@@ -19,6 +73,7 @@ only work on some locations. For example, the Operating System CLI.
1973
`,
2074
Example: `
2175
ha authentication reset --username "JohnDoe" --password "123SuperSecret!"
76+
ha authentication reset --interactive
2277
`,
2378
ValidArgsFunction: cobra.NoFileCompletions,
2479
Args: cobra.NoArgs,
@@ -40,9 +95,49 @@ only work on some locations. For example, the Operating System CLI.
4095
}
4196
}
4297

98+
interactive, _ := cmd.Flags().GetBool("interactive")
99+
if interactive {
100+
// prompt for user if not given
101+
if options["username"] != nil && options["username"] != "" {
102+
fmt.Printf("Changing password for user '%s'\n", options["username"])
103+
} else {
104+
users, err := getUsers()
105+
if err != nil {
106+
cmd.PrintErrln(MsgLimitedAccess)
107+
fmt.Println(err)
108+
ExitWithError = true
109+
return
110+
}
111+
112+
fmt.Println("List of users:")
113+
listUsers(users)
114+
115+
idx, err := helper.ReadInteger("Select a user to reset the password for", 3, 1, len(users))
116+
if err != nil {
117+
fmt.Println("Aborted: ", err)
118+
ExitWithError = true
119+
return
120+
}
121+
122+
user := users[idx-1]
123+
fmt.Printf("Changing password for user %d: %s (%s)\n", idx, user.Username, user.Name)
124+
options["username"] = user.Username
125+
}
126+
127+
// prompt for password
128+
password, err := helper.ReadPassword(true)
129+
if err != nil {
130+
fmt.Printf("Failed to set password: %v\n", err)
131+
ExitWithError = true
132+
return
133+
}
134+
options["password"] = password
135+
}
136+
137+
// change the password
43138
resp, err := helper.GenericJSONPost(section, command, options)
44139
if err != nil {
45-
cmd.PrintErrln("this command is limited due to security reasons, and will only work on some locations. For example, the Operating System terminal.")
140+
cmd.PrintErrln(MsgLimitedAccess)
46141
fmt.Println(err)
47142
ExitWithError = true
48143
} else {
@@ -53,9 +148,10 @@ only work on some locations. For example, the Operating System CLI.
53148

54149
func init() {
55150
authResetCmd.Flags().String("username", "", "Username to reset the password for")
56-
authResetCmd.Flags().String("password", "", "The new password to assign")
57-
authResetCmd.MarkFlagRequired("username")
58-
authResetCmd.MarkFlagRequired("password")
151+
authResetCmd.Flags().String("password", "", "New password to assign. Ignored in interactive mode")
152+
authResetCmd.Flags().Bool("interactive", false, "Use interactive prompt for entering username and/or password")
153+
authResetCmd.MarkFlagsOneRequired("username", "interactive")
154+
authResetCmd.MarkFlagsOneRequired("password", "interactive")
59155
authResetCmd.RegisterFlagCompletionFunc("username", cobra.NoFileCompletions)
60156
authResetCmd.RegisterFlagCompletionFunc("password", cobra.NoFileCompletions)
61157
authCmd.AddCommand(authResetCmd)

0 commit comments

Comments
 (0)