Merge pull request #23995 from hashicorp/alisdair/terraform-login

Enable login subcommand, add manual token support
This commit is contained in:
Alisdair McDiarmid 2020-02-04 11:28:27 -05:00 committed by GitHub
commit f34cba407f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 159 additions and 47 deletions

View File

@ -10,10 +10,11 @@ import (
"math/rand" "math/rand"
"net" "net"
"net/http" "net/http"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/hashicorp/terraform-svchost" svchost "github.com/hashicorp/terraform-svchost"
svcauth "github.com/hashicorp/terraform-svchost/auth" svcauth "github.com/hashicorp/terraform-svchost/auth"
"github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/command/cliconfig" "github.com/hashicorp/terraform/command/cliconfig"
@ -125,25 +126,49 @@ func (c *LoginCommand) Run(args []string) int {
case nil: case nil:
// Great! No problem, then. // Great! No problem, then.
case *disco.ErrServiceNotProvided: case *disco.ErrServiceNotProvided:
diags = diags.Append(tfdiags.Sourceless( // This is also fine! We'll try the manual token creation process.
tfdiags.Error,
"Host does not support Terraform login",
fmt.Sprintf("The given hostname %q does not allow creating Terraform authorization tokens.", dispHostname),
))
case *disco.ErrVersionNotSupported: case *disco.ErrVersionNotSupported:
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Warning,
"Host does not support Terraform login", "Host does not support Terraform login",
fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname), fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname),
)) ))
default: default:
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Warning,
"Host does not support Terraform login", "Host does not support Terraform login",
fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err), fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err),
)) ))
} }
// If login service is unavailable, check for a TFE v2 API as fallback
var service *url.URL
if clientConfig == nil {
service, err = host.ServiceURL("tfe.v2")
switch err.(type) {
case nil:
// Success!
case *disco.ErrServiceNotProvided:
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Host does not support Terraform tokens API",
fmt.Sprintf("The given hostname %q does not support creating Terraform authorization tokens.", dispHostname),
))
case *disco.ErrVersionNotSupported:
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Host does not support Terraform tokens API",
fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname),
))
default:
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Host does not support Terraform tokens API",
fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err),
))
}
}
if credsCtx.Location == cliconfig.CredentialsInOtherFile { if credsCtx.Location == cliconfig.CredentialsInOtherFile {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Error,
@ -157,37 +182,41 @@ func (c *LoginCommand) Run(args []string) int {
return 1 return 1
} }
var token *oauth2.Token var token svcauth.HostCredentialsToken
switch { var tokenDiags tfdiags.Diagnostics
case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
// We prefer an OAuth code grant if the server supports it. // Prefer Terraform login if available
var tokenDiags tfdiags.Diagnostics if clientConfig != nil {
token, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig) var oauthToken *oauth2.Token
diags = diags.Append(tokenDiags)
if tokenDiags.HasErrors() { switch {
c.showDiagnostics(diags) case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
return 1 // We prefer an OAuth code grant if the server supports it.
oauthToken, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig)
case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"):
// The password grant type is allowed only for Terraform Cloud SaaS.
oauthToken, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
default:
tokenDiags = tokenDiags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Host does not support Terraform login",
fmt.Sprintf("The given hostname %q does not allow any OAuth grant types that are supported by this version of Terraform.", dispHostname),
))
} }
case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"): if oauthToken != nil {
// The password grant type is allowed only for Terraform Cloud SaaS. token = svcauth.HostCredentialsToken(oauthToken.AccessToken)
var tokenDiags tfdiags.Diagnostics
token, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
diags = diags.Append(tokenDiags)
if tokenDiags.HasErrors() {
c.showDiagnostics(diags)
return 1
} }
default: } else if service != nil {
diags = diags.Append(tfdiags.Sourceless( token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, service)
tfdiags.Error, }
"Host does not support Terraform login",
fmt.Sprintf("The given hostname %q does not allow any OAuth grant types that are supported by this version of Terraform.", dispHostname), diags = diags.Append(tokenDiags)
)) if diags.HasErrors() {
c.showDiagnostics(diags) c.showDiagnostics(diags)
return 1 return 1
} }
err = creds.StoreForHost(hostname, svcauth.HostCredentialsToken(token.AccessToken)) err = creds.StoreForHost(hostname, token)
if err != nil { if err != nil {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Error,
@ -468,10 +497,72 @@ func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname,
return token, diags return token, diags
} }
func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) { func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsCtx *loginCredentialsContext, service *url.URL) (svcauth.HostCredentialsToken, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics var diags tfdiags.Diagnostics
c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using OAuth.\n", hostname.ForDisplay())) confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthGrantType(""), credsCtx)
diags = diags.Append(confirmDiags)
if !confirm {
diags = diags.Append(errors.New("Login cancelled"))
return "", diags
}
tokensURL := url.URL{
Scheme: "https",
Host: service.Hostname(),
Path: "/app/settings/tokens",
RawQuery: "source=terraform-login",
}
launchBrowserManually := false
if c.BrowserLauncher != nil {
err := c.BrowserLauncher.OpenURL(tokensURL.String())
if err == nil {
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 {
// Assume we're on a platform where opening a browser isn't possible.
launchBrowserManually = true
}
} else {
launchBrowserManually = true
}
if launchBrowserManually {
c.Ui.Output(fmt.Sprintf("Open the following URL to access the tokens page for %s:\n %s\n", hostname.ForDisplay(), tokensURL.String()))
}
c.Ui.Output("\n---------------------------------------------------------------------------------\n")
c.Ui.Output("Generate a token using your browser, and copy-paste it into this prompt.\n")
// credsCtx might not be set if we're using a mock credentials source
// in a test, but it should always be set in normal use.
if credsCtx != nil {
switch credsCtx.Location {
case cliconfig.CredentialsViaHelper:
c.Ui.Output(fmt.Sprintf("Terraform will store the token in the configured %q credentials helper\nfor use by subsequent commands.\n", credsCtx.HelperType))
case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
c.Ui.Output(fmt.Sprintf("Terraform will store the token in plain text in the following file\nfor use by subsequent commands:\n %s\n", credsCtx.LocalFilename))
}
}
token, err := c.Ui.AskSecret(fmt.Sprintf("Token for %s:", hostname.ForDisplay()))
if err != nil {
diags := diags.Append(fmt.Errorf("Failed to retrieve token: %s", err))
return "", diags
}
return svcauth.HostCredentialsToken(token), nil
}
func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
mechanism := "OAuth"
if grantType == "" {
mechanism = "your browser"
}
c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using %s.\n", hostname.ForDisplay(), mechanism))
if grantType.UsesAuthorizationEndpoint() { if grantType.UsesAuthorizationEndpoint() {
c.Ui.Output( c.Ui.Output(

View File

@ -12,7 +12,7 @@ import (
"github.com/mitchellh/cli" "github.com/mitchellh/cli"
"github.com/hashicorp/terraform-svchost" svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform-svchost/disco" "github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/command/cliconfig" "github.com/hashicorp/terraform/command/cliconfig"
oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server" oauthserver "github.com/hashicorp/terraform/command/testdata/login-oauth-server"
@ -70,6 +70,13 @@ func TestLogin(t *testing.T) {
"token": s.URL + "/token", "token": s.URL + "/token",
}, },
}) })
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",
})
svcs.ForceHostServices(svchost.Hostname("unsupported.example.net"), map[string]interface{}{ svcs.ForceHostServices(svchost.Hostname("unsupported.example.net"), map[string]interface{}{
// This host intentionally left blank. // This host intentionally left blank.
}) })
@ -125,13 +132,31 @@ func TestLogin(t *testing.T) {
} }
})) }))
t.Run("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, inp func(string)) {
// Enter "yes" at the consent prompt, then paste a token.
inp("yes\npasted-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())
}
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 got, want := creds.Token(), "pasted-token"; got != want {
t.Errorf("wrong token %q; want %q", got, want)
}
}))
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"}) status := c.Run([]string{"unsupported.example.net"})
if status == 0 { if status == 0 {
t.Fatalf("successful exit; want error") t.Fatalf("successful exit; want error")
} }
if got, want := ui.ErrorWriter.String(), "Error: Host does not support Terraform login"; !strings.Contains(got, want) { if got, want := ui.ErrorWriter.String(), "Error: Host does not support Terraform tokens API"; !strings.Contains(got, want) {
t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got) t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
} }
})) }))

View File

@ -184,15 +184,11 @@ func initCommands(config *cliconfig.Config, services *disco.Disco, providerSrc g
}, nil }, nil
}, },
// "terraform login" is disabled until Terraform Cloud is ready to "login": func() (cli.Command, error) {
// support it. return &command.LoginCommand{
/* Meta: meta,
"login": func() (cli.Command, error) { }, nil
return &command.LoginCommand{ },
Meta: meta,
}, nil
},
*/
"output": func() (cli.Command, error) { "output": func() (cli.Command, error) {
return &command.OutputCommand{ return &command.OutputCommand{