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.
This commit is contained in:
Alisdair McDiarmid 2020-02-03 18:12:57 -05:00
parent e763fd55f3
commit c77cfaafc2
3 changed files with 110 additions and 7 deletions

View File

@ -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
}

View File

@ -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 {

View File

@ -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{}
}