From c77cfaafc2052846302292fd138d824a41ea5107 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Mon, 3 Feb 2020 18:12:57 -0500 Subject: [PATCH] Add token validation for manual terraform login When a token is pasted by the user, we make a request to the TFE API /account/details endpoint to verify its validity. If successful, we display the logged-in username as confirmation. If not, we refuse to store the invalid token and display an error message. This commit also trims whitespace from around the pasted value, to reduce the likelihood of a copy & paste error. --- command/login.go | 25 ++++++++- command/login_test.go | 38 ++++++++++--- .../testdata/login-tfe-server/tfeserver.go | 54 +++++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 command/testdata/login-tfe-server/tfeserver.go diff --git a/command/login.go b/command/login.go index 2883df39f..a53071226 100644 --- a/command/login.go +++ b/command/login.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strings" + tfe "github.com/hashicorp/go-tfe" svchost "github.com/hashicorp/terraform-svchost" svcauth "github.com/hashicorp/terraform-svchost/auth" "github.com/hashicorp/terraform-svchost/disco" @@ -546,12 +547,34 @@ func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsC } } - token, err := c.Ui.AskSecret(fmt.Sprintf("Token for %s:", hostname.ForDisplay())) + token, err := c.Ui.AskSecret(fmt.Sprintf(c.Colorize().Color("Token for [bold]%s[reset]:"), hostname.ForDisplay())) if err != nil { diags := diags.Append(fmt.Errorf("Failed to retrieve token: %s", err)) return "", diags } + token = strings.TrimSpace(token) + cfg := &tfe.Config{ + Address: service.String(), + BasePath: service.Path, + Token: token, + Headers: make(http.Header), + } + client, err := tfe.NewClient(cfg) + if err != nil { + diags = diags.Append(fmt.Errorf("Failed to create API client: %s", err)) + return "", diags + } + user, err := client.Users.ReadCurrent(context.Background()) + if err == tfe.ErrUnauthorized { + diags = diags.Append(fmt.Errorf("Token is invalid: %s", err)) + return "", diags + } else if err != nil { + diags = diags.Append(fmt.Errorf("Failed to retrieve user account details: %s", err)) + return "", diags + } + c.Ui.Output(fmt.Sprintf(c.Colorize().Color("\nRetrieved token for user [bold]%s[reset]\n"), user.Username)) + return svcauth.HostCredentialsToken(token), nil } diff --git a/command/login_test.go b/command/login_test.go index 288a2e291..b03e49c82 100644 --- a/command/login_test.go +++ b/command/login_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform/command/cliconfig" oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server" + tfeserver "github.com/hashicorp/terraform/command/testdata/login-tfe-server" "github.com/hashicorp/terraform/command/webbrowser" "github.com/hashicorp/terraform/httpclient" "github.com/hashicorp/terraform/version" @@ -27,6 +28,12 @@ func TestLogin(t *testing.T) { s := httptest.NewServer(oauthserver.Handler) defer s.Close() + // tfeserver.Handler is a stub TFE API implementation which will respond + // to ping and current account requests, when requests are authenticated + // with token "good-token" + 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) { return func(t *testing.T) { t.Helper() @@ -73,9 +80,9 @@ func TestLogin(t *testing.T) { svcs.ForceHostServices(svchost.Hostname("tfe.acme.com"), map[string]interface{}{ // This represents a Terraform Enterprise instance which does not // yet support the login API, but does support the TFE tokens API. - "tfe.v2": "/api/v2", - "tfe.v2.1": "/api/v2", - "tfe.v2.2": "/api/v2", + "tfe.v2": ts.URL + "/api/v2", + "tfe.v2.1": ts.URL + "/api/v2", + "tfe.v2.2": ts.URL + "/api/v2", }) svcs.ForceHostServices(svchost.Hostname("unsupported.example.net"), map[string]interface{}{ // This host intentionally left blank. @@ -133,8 +140,9 @@ 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)) { - // Enter "yes" at the consent prompt, then paste a token. - inp("yes\npasted-token\n") + // Enter "yes" at the consent prompt, then paste a token with some + // accidental whitespace. + inp("yes\n good-token \n") status := c.Run([]string{"tfe.acme.com"}) if status != 0 { t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String()) @@ -145,11 +153,29 @@ func TestLogin(t *testing.T) { if err != nil { t.Errorf("failed to retrieve credentials: %s", err) } - if got, want := creds.Token(), "pasted-token"; got != want { + if got, want := creds.Token(), "good-token"; got != want { t.Errorf("wrong token %q; want %q", got, want) } })) + t.Run("TFE host without login support, incorrectly pasted token", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) { + // Enter "yes" at the consent prompt, then paste an invalid token. + inp("yes\ngood-tok\n") + status := c.Run([]string{"tfe.acme.com"}) + if status != 1 { + t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String()) + } + + credsSrc := c.Services.CredentialsSource() + creds, err := credsSrc.ForHost(svchost.Hostname("tfe.acme.com")) + if err != nil { + t.Errorf("failed to retrieve credentials: %s", err) + } + if creds != nil { + t.Errorf("wrong token %q; should have no token", creds.Token()) + } + })) + t.Run("host without login or TFE API support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi, inp func(string)) { status := c.Run([]string{"unsupported.example.net"}) if status == 0 { diff --git a/command/testdata/login-tfe-server/tfeserver.go b/command/testdata/login-tfe-server/tfeserver.go new file mode 100644 index 000000000..11d164abc --- /dev/null +++ b/command/testdata/login-tfe-server/tfeserver.go @@ -0,0 +1,54 @@ +// Package tfeserver is a test stub implementing a subset of the TFE API used +// only for the testing of the "terraform login" command. +package tfeserver + +import ( + "fmt" + "net/http" + "strings" +) + +const ( + goodToken = "good-token" + accountDetails = `{"data":{"id":"user-abc123","type":"users","attributes":{"username":"testuser","email":"testuser@example.com"}}}` +) + +// Handler is an implementation of net/http.Handler that provides a stub +// TFE API server implementation with the following endpoints: +// +// /ping - API existence endpoint +// /account/details - current user endpoint +var Handler http.Handler + +type handler struct{} + +func (h handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + resp.Header().Set("Content-Type", "application/vnd.api+json") + switch req.URL.Path { + case "/api/v2/ping": + h.servePing(resp, req) + case "/api/v2/account/details": + h.serveAccountDetails(resp, req) + default: + fmt.Printf("404 when fetching %s\n", req.URL.String()) + http.Error(resp, `{"errors":[{"status":"404","title":"not found"}]}`, http.StatusNotFound) + } +} + +func (h handler) servePing(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(http.StatusNoContent) +} + +func (h handler) serveAccountDetails(resp http.ResponseWriter, req *http.Request) { + if !strings.Contains(req.Header.Get("Authorization"), goodToken) { + http.Error(resp, `{"errors":[{"status":"401","title":"unauthorized"}]}`, http.StatusUnauthorized) + return + } + + resp.WriteHeader(http.StatusOK) + resp.Write([]byte(accountDetails)) +} + +func init() { + Handler = handler{} +}