Merge pull request #24030 from hashicorp/alisdair/terraform-login-token-validation
Add token validation for manual terraform login
This commit is contained in:
commit
e57685d8fc
|
@ -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