mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: enable masking password inputs instead of blocking echo (#17469)
Closes #17059
This commit is contained in:
+65
-8
@@ -1,6 +1,7 @@
|
|||||||
package cliui
|
package cliui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -8,19 +9,21 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/bgentry/speakeasy"
|
|
||||||
"github.com/mattn/go-isatty"
|
"github.com/mattn/go-isatty"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/pty"
|
||||||
"github.com/coder/pretty"
|
"github.com/coder/pretty"
|
||||||
"github.com/coder/serpent"
|
"github.com/coder/serpent"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PromptOptions supply a set of options to the prompt.
|
// PromptOptions supply a set of options to the prompt.
|
||||||
type PromptOptions struct {
|
type PromptOptions struct {
|
||||||
Text string
|
Text string
|
||||||
Default string
|
Default string
|
||||||
|
// When true, the input will be masked with asterisks.
|
||||||
Secret bool
|
Secret bool
|
||||||
IsConfirm bool
|
IsConfirm bool
|
||||||
Validate func(string) error
|
Validate func(string) error
|
||||||
@@ -88,14 +91,13 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) {
|
|||||||
var line string
|
var line string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
signal.Notify(interrupt, os.Interrupt)
|
||||||
|
defer signal.Stop(interrupt)
|
||||||
|
|
||||||
inFile, isInputFile := inv.Stdin.(*os.File)
|
inFile, isInputFile := inv.Stdin.(*os.File)
|
||||||
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
|
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
|
||||||
// we don't install a signal handler here because speakeasy has its own
|
line, err = readSecretInput(inFile, inv.Stdout)
|
||||||
line, err = speakeasy.Ask("")
|
|
||||||
} else {
|
} else {
|
||||||
signal.Notify(interrupt, os.Interrupt)
|
|
||||||
defer signal.Stop(interrupt)
|
|
||||||
|
|
||||||
line, err = readUntil(inv.Stdin, '\n')
|
line, err = readUntil(inv.Stdin, '\n')
|
||||||
|
|
||||||
// Check if the first line beings with JSON object or array chars.
|
// Check if the first line beings with JSON object or array chars.
|
||||||
@@ -204,3 +206,58 @@ func readUntil(r io.Reader, delim byte) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readSecretInput reads secret input from the terminal rune-by-rune,
|
||||||
|
// masking each character with an asterisk.
|
||||||
|
func readSecretInput(f *os.File, w io.Writer) (string, error) {
|
||||||
|
// Put terminal into raw mode (no echo, no line buffering).
|
||||||
|
oldState, err := pty.MakeInputRaw(f.Fd())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = pty.RestoreTerminal(f.Fd(), oldState)
|
||||||
|
}()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(f)
|
||||||
|
var runes []rune
|
||||||
|
|
||||||
|
for {
|
||||||
|
r, _, err := reader.ReadRune()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r == '\r' || r == '\n':
|
||||||
|
// Finish on Enter
|
||||||
|
if _, err := fmt.Fprint(w, "\r\n"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(runes), nil
|
||||||
|
|
||||||
|
case r == 3:
|
||||||
|
// Ctrl+C
|
||||||
|
return "", ErrCanceled
|
||||||
|
|
||||||
|
case r == 127 || r == '\b':
|
||||||
|
// Backspace/Delete: remove last rune
|
||||||
|
if len(runes) > 0 {
|
||||||
|
// Erase the last '*' on the screen
|
||||||
|
if _, err := fmt.Fprint(w, "\b \b"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
runes = runes[:len(runes)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Only mask printable, non-control runes
|
||||||
|
if !unicode.IsControl(r) {
|
||||||
|
runes = append(runes, r)
|
||||||
|
if _, err := fmt.Fprint(w, "*"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+51
-15
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -13,7 +14,6 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/cli/cliui"
|
"github.com/coder/coder/v2/cli/cliui"
|
||||||
"github.com/coder/coder/v2/pty"
|
|
||||||
"github.com/coder/coder/v2/pty/ptytest"
|
"github.com/coder/coder/v2/pty/ptytest"
|
||||||
"github.com/coder/coder/v2/testutil"
|
"github.com/coder/coder/v2/testutil"
|
||||||
"github.com/coder/serpent"
|
"github.com/coder/serpent"
|
||||||
@@ -181,6 +181,48 @@ func TestPrompt(t *testing.T) {
|
|||||||
resp := testutil.TryReceive(ctx, t, doneChan)
|
resp := testutil.TryReceive(ctx, t, doneChan)
|
||||||
require.Equal(t, "valid", resp)
|
require.Equal(t, "valid", resp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("MaskedSecret", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
ptty := ptytest.New(t)
|
||||||
|
doneChan := make(chan string)
|
||||||
|
go func() {
|
||||||
|
resp, err := newPrompt(ctx, ptty, cliui.PromptOptions{
|
||||||
|
Text: "Password:",
|
||||||
|
Secret: true,
|
||||||
|
}, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
doneChan <- resp
|
||||||
|
}()
|
||||||
|
ptty.ExpectMatch("Password: ")
|
||||||
|
|
||||||
|
ptty.WriteLine("test")
|
||||||
|
|
||||||
|
resp := testutil.TryReceive(ctx, t, doneChan)
|
||||||
|
require.Equal(t, "test", resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UTF8Password", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := testutil.Context(t, testutil.WaitShort)
|
||||||
|
ptty := ptytest.New(t)
|
||||||
|
doneChan := make(chan string)
|
||||||
|
go func() {
|
||||||
|
resp, err := newPrompt(ctx, ptty, cliui.PromptOptions{
|
||||||
|
Text: "Password:",
|
||||||
|
Secret: true,
|
||||||
|
}, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
doneChan <- resp
|
||||||
|
}()
|
||||||
|
ptty.ExpectMatch("Password: ")
|
||||||
|
|
||||||
|
ptty.WriteLine("和製漢字")
|
||||||
|
|
||||||
|
resp := testutil.TryReceive(ctx, t, doneChan)
|
||||||
|
require.Equal(t, "和製漢字", resp)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPrompt(ctx context.Context, ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *serpent.Invocation)) (string, error) {
|
func newPrompt(ctx context.Context, ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *serpent.Invocation)) (string, error) {
|
||||||
@@ -209,13 +251,12 @@ func TestPasswordTerminalState(t *testing.T) {
|
|||||||
passwordHelper()
|
passwordHelper()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Skipping on windows. PTY doesn't read ptty.Write correctly.")
|
||||||
|
}
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ptty := ptytest.New(t)
|
ptty := ptytest.New(t)
|
||||||
ptyWithFlags, ok := ptty.PTY.(pty.WithFlags)
|
|
||||||
if !ok {
|
|
||||||
t.Skip("unable to check PTY local echo on this platform")
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(os.Args[0], "-test.run=TestPasswordTerminalState") //nolint:gosec
|
cmd := exec.Command(os.Args[0], "-test.run=TestPasswordTerminalState") //nolint:gosec
|
||||||
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
|
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
|
||||||
@@ -229,21 +270,16 @@ func TestPasswordTerminalState(t *testing.T) {
|
|||||||
defer process.Kill()
|
defer process.Kill()
|
||||||
|
|
||||||
ptty.ExpectMatch("Password: ")
|
ptty.ExpectMatch("Password: ")
|
||||||
|
ptty.Write('t')
|
||||||
require.Eventually(t, func() bool {
|
ptty.Write('e')
|
||||||
echo, err := ptyWithFlags.EchoEnabled()
|
ptty.Write('s')
|
||||||
return err == nil && !echo
|
ptty.Write('t')
|
||||||
}, testutil.WaitShort, testutil.IntervalMedium, "echo is on while reading password")
|
ptty.ExpectMatch("****")
|
||||||
|
|
||||||
err = process.Signal(os.Interrupt)
|
err = process.Signal(os.Interrupt)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = process.Wait()
|
_, err = process.Wait()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
echo, err := ptyWithFlags.EchoEnabled()
|
|
||||||
return err == nil && echo
|
|
||||||
}, testutil.WaitShort, testutil.IntervalMedium, "echo is off after reading password")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:unused
|
// nolint:unused
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ require (
|
|||||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
|
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
|
||||||
github.com/awalterschulze/gographviz v2.0.3+incompatible
|
github.com/awalterschulze/gographviz v2.0.3+incompatible
|
||||||
github.com/aws/smithy-go v1.22.3
|
github.com/aws/smithy-go v1.22.3
|
||||||
github.com/bgentry/speakeasy v0.2.0
|
|
||||||
github.com/bramvdbogaerde/go-scp v1.5.0
|
github.com/bramvdbogaerde/go-scp v1.5.0
|
||||||
github.com/briandowns/spinner v1.23.0
|
github.com/briandowns/spinner v1.23.0
|
||||||
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5
|
github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5
|
||||||
|
|||||||
@@ -815,8 +815,6 @@ github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
|
|||||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
|
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
|
||||||
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
|
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
|
||||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||||
github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E=
|
|
||||||
github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
|
||||||
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
|
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
|
||||||
|
|||||||
Reference in New Issue
Block a user