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:
parent
e763fd55f3
commit
c77cfaafc2
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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{}
|
||||
}
|
Loading…
Reference in New Issue