Merge pull request #25379 from hashicorp/alisdair/yes

command/login: Require "yes" to confirm
This commit is contained in:
Alisdair McDiarmid 2020-06-25 13:00:02 -04:00 committed by GitHub
commit b4cc77b6cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 99 additions and 42 deletions

View File

@ -20,6 +20,7 @@ import (
"github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/command/cliconfig"
"github.com/hashicorp/terraform/httpclient"
"github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags"
uuid "github.com/hashicorp/go-uuid"
@ -453,12 +454,19 @@ func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname,
c.Ui.Output("\n---------------------------------------------------------------------------------\n")
c.Ui.Output("Terraform must temporarily use your password to request an API token.\nThis password will NOT be saved locally.\n")
username, err := c.Ui.Ask(fmt.Sprintf("Username for %s:", hostname.ForDisplay()))
username, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
Id: "username",
Query: fmt.Sprintf("Username for %s:", hostname.ForDisplay()),
})
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to request username: %s", err))
return nil, diags
}
password, err := c.Ui.AskSecret(fmt.Sprintf("Password for %s:", username))
password, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
Id: "password",
Query: fmt.Sprintf("Password for %s:", hostname.ForDisplay()),
Secret: true,
})
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to request password: %s", err))
return nil, diags
@ -494,6 +502,8 @@ func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsC
return "", diags
}
c.Ui.Output("\n---------------------------------------------------------------------------------\n")
tokensURL := url.URL{
Scheme: "https",
Host: service.Hostname(),
@ -508,6 +518,7 @@ func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsC
c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the tokens page for %s.\n", hostname.ForDisplay()))
c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n %s\n", tokensURL.String()))
} else {
log.Printf("[DEBUG] error opening web browser: %s", err)
// Assume we're on a platform where opening a browser isn't possible.
launchBrowserManually = true
}
@ -533,7 +544,11 @@ func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsC
}
}
token, err := c.Ui.AskSecret(fmt.Sprintf(c.Colorize().Color("Token for [bold]%s[reset]:"), hostname.ForDisplay()))
token, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
Id: "token",
Query: fmt.Sprintf("Token for %s:", hostname.ForDisplay()),
Secret: true,
})
if err != nil {
diags := diags.Append(fmt.Errorf("Failed to retrieve token: %s", err))
return "", diags
@ -590,7 +605,11 @@ func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, gran
}
}
v, err := c.Ui.Ask("Do you want to proceed? (y/n)")
v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
Id: "approve",
Query: "Do you want to proceed?",
Description: `Only 'yes' will be accepted to confirm.`,
})
if err != nil {
// Should not happen because this command checks that input is enabled
// before we get to this point.
@ -598,12 +617,7 @@ func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, gran
return false, diags
}
switch strings.ToLower(v) {
case "y", "yes":
return true, diags
default:
return false, diags
}
return strings.ToLower(v) == "yes", diags
}
func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) {

View File

@ -1,7 +1,6 @@
package command
import (
"bytes"
"context"
"io/ioutil"
"net/http/httptest"
@ -34,7 +33,7 @@ func TestLogin(t *testing.T) {
ts := httptest.NewServer(tfeserver.Handler)
defer ts.Close()
loginTestCase := func(test func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string))) func(t *testing.T) {
loginTestCase := func(test func(t *testing.T, c *LoginCommand, ui *cli.MockUi)) func(t *testing.T) {
return func(t *testing.T) {
t.Helper()
workDir, err := ioutil.TempDir("", "terraform-test-command-login")
@ -48,15 +47,15 @@ func TestLogin(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ui := cli.NewMockUi()
// Do not use the NewMockUi initializer here, as we want to delay
// the call to init until after setting up the input mocks
ui := new(cli.MockUi)
browserLauncher := webbrowser.NewMockLauncher(ctx)
creds := cliconfig.EmptyCredentialsSourceForTests(filepath.Join(workDir, "credentials.tfrc.json"))
svcs := disco.NewWithCredentialsSource(creds)
svcs.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
inputBuf := &bytes.Buffer{}
ui.InputReader = inputBuf
svcs.ForceHostServices(svchost.Hostname("app.terraform.io"), map[string]interface{}{
"login.v1": map[string]interface{}{
// On app.terraform.io we use password-based authorization.
@ -96,16 +95,16 @@ func TestLogin(t *testing.T) {
},
}
test(t, c, ui, func(data string) {
t.Helper()
inputBuf.WriteString(data)
})
test(t, c, ui)
}
}
t.Run("defaulting to app.terraform.io with password flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
// Enter "yes" at the consent prompt, then a username and then a password.
inp("yes\nfoo\nbar\n")
t.Run("defaulting to app.terraform.io with password flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
defer testInputMap(t, map[string]string{
"approve": "yes",
"username": "foo",
"password": "bar",
})()
status := c.Run(nil)
if status != 0 {
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
@ -121,9 +120,11 @@ func TestLogin(t *testing.T) {
}
}))
t.Run("example.com with authorization code flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
t.Run("example.com with authorization code flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
// Enter "yes" at the consent prompt.
inp("yes\n")
defer testInputMap(t, map[string]string{
"approve": "yes",
})()
status := c.Run([]string{"example.com"})
if status != 0 {
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
@ -139,10 +140,13 @@ func TestLogin(t *testing.T) {
}
}))
t.Run("TFE host without login support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
t.Run("TFE host without login support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
// Enter "yes" at the consent prompt, then paste a token with some
// accidental whitespace.
inp("yes\n good-token \n")
defer testInputMap(t, map[string]string{
"approve": "yes",
"token": " good-token ",
})()
status := c.Run([]string{"tfe.acme.com"})
if status != 0 {
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
@ -158,9 +162,12 @@ func TestLogin(t *testing.T) {
}
}))
t.Run("TFE host without login support, incorrectly pasted token", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
t.Run("TFE host without login support, incorrectly pasted token", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
// Enter "yes" at the consent prompt, then paste an invalid token.
inp("yes\ngood-tok\n")
defer testInputMap(t, map[string]string{
"approve": "yes",
"token": "good-tok",
})()
status := c.Run([]string{"tfe.acme.com"})
if status != 1 {
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
@ -176,7 +183,7 @@ func TestLogin(t *testing.T) {
}
}))
t.Run("host without login or TFE API support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) {
t.Run("host without login or TFE API support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
status := c.Run([]string{"unsupported.example.net"})
if status == 0 {
t.Fatalf("successful exit; want error")
@ -186,4 +193,34 @@ func TestLogin(t *testing.T) {
t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
}
}))
t.Run("answering no cancels", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
// Enter "no" at the consent prompt
defer testInputMap(t, map[string]string{
"approve": "no",
})()
status := c.Run(nil)
if status != 1 {
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
}
if got, want := ui.ErrorWriter.String(), "Login cancelled"; !strings.Contains(got, want) {
t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
}
}))
t.Run("answering y cancels", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
// Enter "y" at the consent prompt
defer testInputMap(t, map[string]string{
"approve": "y",
})()
status := c.Run(nil)
if status != 1 {
t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
}
if got, want := ui.ErrorWriter.String(), "Login cancelled"; !strings.Contains(got, want) {
t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
}
}))
}

View File

@ -15,7 +15,9 @@ import (
"sync/atomic"
"unicode"
"github.com/bgentry/speakeasy"
"github.com/hashicorp/terraform/terraform"
"github.com/mattn/go-isatty"
"github.com/mitchellh/colorstring"
)
@ -128,8 +130,14 @@ func (i *UIInput) Input(ctx context.Context, opts *terraform.InputOpts) (string,
}
defer atomic.CompareAndSwapInt32(&i.listening, 1, 0)
buf := bufio.NewReader(r)
line, err := buf.ReadString('\n')
var line string
var err error
if opts.Secret && isatty.IsTerminal(os.Stdin.Fd()) {
line, err = speakeasy.Ask("")
} else {
buf := bufio.NewReader(r)
line, err = buf.ReadString('\n')
}
if err != nil {
log.Printf("[ERR] UIInput scan err: %s", err)
}

2
go.mod
View File

@ -21,6 +21,7 @@ require (
github.com/armon/go-radix v1.0.0 // indirect
github.com/aws/aws-sdk-go v1.31.9
github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f // indirect
github.com/bgentry/speakeasy v0.1.0
github.com/blang/semver v3.5.1+incompatible
github.com/bmatcuk/doublestar v1.1.5
github.com/boltdb/bolt v1.3.1 // indirect
@ -90,6 +91,7 @@ require (
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
github.com/masterzen/winrm v0.0.0-20200615185753-c42b5136ff88
github.com/mattn/go-colorable v0.1.1
github.com/mattn/go-isatty v0.0.5
github.com/mattn/go-shellwords v1.0.4
github.com/miekg/dns v1.0.8 // indirect
github.com/mitchellh/cli v1.0.0

10
go.sum
View File

@ -363,8 +363,6 @@ github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9 h1:SmVbOZFWAly
github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc=
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 h1:2ZKn+w/BJeL43sCxI2jhPLRv73oVVOjEKZjKkflyqxg=
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc=
github.com/masterzen/winrm v0.0.0-20190223112901-5e5c9a7fe54b h1:/1RFh2SLCJ+tEnT73+Fh5R2AO89sQqs8ba7o+hx1G0Y=
github.com/masterzen/winrm v0.0.0-20190223112901-5e5c9a7fe54b/go.mod h1:wr1VqkwW0AB5JS0QLy5GpVMS9E3VtRoSYXUYyVk46KY=
github.com/masterzen/winrm v0.0.0-20200615185753-c42b5136ff88 h1:cxuVcCvCLD9yYDbRCWw0jSgh1oT6P6mv3aJDKK5o7X4=
github.com/masterzen/winrm v0.0.0-20200615185753-c42b5136ff88/go.mod h1:a2HXwefeat3evJHxFXSayvRHpYEPJYtErl4uIzfaUqY=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@ -436,8 +434,6 @@ github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v0.0.0-20190113212917-5533ce8a0da3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58 h1:m3CEgv3ah1Rhy82L+c0QG/U3VyY1UsvsIdkh0/rU97Y=
github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58/go.mod h1:f6Izs6JvFTdnRbziASagjZ2vmf55NSIkC/weStxCHqk=
github.com/packer-community/winrmcp v0.0.0-20180921211025-c76d91c1e7db h1:9uViuKtx1jrlXLBW/pMnhOfzn3iSEdLase/But/IZRU=
github.com/packer-community/winrmcp v0.0.0-20180921211025-c76d91c1e7db/go.mod h1:f6Izs6JvFTdnRbziASagjZ2vmf55NSIkC/weStxCHqk=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs=
@ -549,8 +545,6 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -587,8 +581,6 @@ golang.org/x/net v0.0.0-20191009170851-d66e71096ffb/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200602114024-627f9648deb9 h1:pNX+40auqi2JqRfOP1akLGtYcn15TUbkhwuCO3foqqM=
golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -623,8 +615,6 @@ golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9 h1:ZBzSG/7F4eNKz2L3GE9o300RX0Az1Bw5HF7PDraD+qU=
golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -25,4 +25,8 @@ type InputOpts struct {
// Default will be the value returned if no data is entered.
Default string
// Secret should be true if we are asking for sensitive input.
// If attached to a TTY, Terraform will disable echo.
Secret bool
}

2
vendor/modules.txt vendored
View File

@ -168,6 +168,7 @@ github.com/aws/aws-sdk-go/service/sts/stsiface
# github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d
github.com/bgentry/go-netrc/netrc
# github.com/bgentry/speakeasy v0.1.0
## explicit
github.com/bgentry/speakeasy
# github.com/blang/semver v3.5.1+incompatible
## explicit
@ -476,6 +477,7 @@ github.com/masterzen/winrm/soap
## explicit
github.com/mattn/go-colorable
# github.com/mattn/go-isatty v0.0.5
## explicit
github.com/mattn/go-isatty
# github.com/mattn/go-shellwords v1.0.4
## explicit