command/login: Show login consent prompt before proceeding
Because we're going to pass the credentials we obtain on to some credentials store (either a credentials helper or a local file on disk) we ought to disclose that first and give the user a chance to cancel out and set up a different credentials storage mechanism first if desired. This also includes the very beginnings of support for the owner password grant type when running against app.terraform.io. This will be used only temporarily at initial release to allow a faster initial release without blocking on implementation of a full OAuth flow in Terraform Cloud.
This commit is contained in:
parent
6bba3ceb42
commit
0b346e589a
139
command/login.go
139
command/login.go
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
@ -12,9 +13,11 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/command/cliconfig"
|
||||||
"github.com/hashicorp/terraform/httpclient"
|
"github.com/hashicorp/terraform/httpclient"
|
||||||
"github.com/hashicorp/terraform/svchost"
|
"github.com/hashicorp/terraform/svchost"
|
||||||
"github.com/hashicorp/terraform/svchost/disco"
|
"github.com/hashicorp/terraform/svchost/disco"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
"github.com/hashicorp/terraform/tfdiags"
|
"github.com/hashicorp/terraform/tfdiags"
|
||||||
|
|
||||||
uuid "github.com/hashicorp/go-uuid"
|
uuid "github.com/hashicorp/go-uuid"
|
||||||
|
@ -36,7 +39,7 @@ func (c *LoginCommand) Run(args []string) int {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdFlags := c.Meta.defaultFlagSet("login")
|
cmdFlags := c.Meta.extendedFlagSet("login")
|
||||||
var intoFile string
|
var intoFile string
|
||||||
cmdFlags.StringVar(&intoFile, "into-file", "", "set the file that the credentials will be appended to")
|
cmdFlags.StringVar(&intoFile, "into-file", "", "set the file that the credentials will be appended to")
|
||||||
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
|
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
|
||||||
|
@ -54,6 +57,16 @@ func (c *LoginCommand) Run(args []string) int {
|
||||||
|
|
||||||
var diags tfdiags.Diagnostics
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
|
if !c.input {
|
||||||
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
|
tfdiags.Error,
|
||||||
|
"Login is an interactive command",
|
||||||
|
"The \"terraform login\" command uses interactive prompts to obtain and record credentials, so it can't be run with input disabled.\n\nTo configure credentials in a non-interactive context, write existing credentials directly to a CLI configuration file.",
|
||||||
|
))
|
||||||
|
c.showDiagnostics(diags)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
givenHostname := "app.terraform.io"
|
givenHostname := "app.terraform.io"
|
||||||
if len(args) != 0 {
|
if len(args) != 0 {
|
||||||
givenHostname = args[0]
|
givenHostname = args[0]
|
||||||
|
@ -66,6 +79,8 @@ func (c *LoginCommand) Run(args []string) int {
|
||||||
"Invalid hostname",
|
"Invalid hostname",
|
||||||
fmt.Sprintf("The given hostname %q is not valid: %s.", givenHostname, err.Error()),
|
fmt.Sprintf("The given hostname %q is not valid: %s.", givenHostname, err.Error()),
|
||||||
))
|
))
|
||||||
|
c.showDiagnostics(diags)
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// From now on, since we've validated the given hostname, we should use
|
// From now on, since we've validated the given hostname, we should use
|
||||||
|
@ -87,6 +102,25 @@ func (c *LoginCommand) Run(args []string) int {
|
||||||
// with our usual error reporting standards.
|
// with our usual error reporting standards.
|
||||||
err.Error()+".",
|
err.Error()+".",
|
||||||
))
|
))
|
||||||
|
c.showDiagnostics(diags)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := c.Services.CredentialsSource()
|
||||||
|
|
||||||
|
// In normal use (i.e. without test mocks/fakes) creds will be an instance
|
||||||
|
// of the command/cliconfig.CredentialsSource type, which has some extra
|
||||||
|
// methods we can use to give the user better feedback about what we're
|
||||||
|
// going to do. credsCtx will be nil if it's any other implementation,
|
||||||
|
// though.
|
||||||
|
var credsCtx *loginCredentialsContext
|
||||||
|
if c, ok := creds.(*cliconfig.CredentialsSource); ok {
|
||||||
|
filename, _ := c.CredentialsFilePath()
|
||||||
|
credsCtx = &loginCredentialsContext{
|
||||||
|
Location: c.HostCredentialsLocation(hostname),
|
||||||
|
LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user
|
||||||
|
HelperType: c.CredentialsHelperType(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clientConfig, err := host.ServiceOAuthClient("login.v1")
|
clientConfig, err := host.ServiceOAuthClient("login.v1")
|
||||||
|
@ -113,17 +147,48 @@ func (c *LoginCommand) Run(args []string) int {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if credsCtx.Location == cliconfig.CredentialsInOtherFile {
|
||||||
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
|
tfdiags.Error,
|
||||||
|
fmt.Sprintf("Credentials for %s are manually configured", dispHostname),
|
||||||
|
"The \"terraform login\" command cannot log in because credentials for this host are already configured in a CLI configuration file.\n\nTo log in, first revoke the existing credentials and remove that block from the CLI configuration.",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
c.showDiagnostics(diags)
|
c.showDiagnostics(diags)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
token, tokenDiags := c.interactiveGetToken(hostname, clientConfig)
|
var token *oauth2.Token
|
||||||
|
switch {
|
||||||
|
case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant):
|
||||||
|
// We prefer an OAuth code grant if the server supports it.
|
||||||
|
var tokenDiags tfdiags.Diagnostics
|
||||||
|
token, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig)
|
||||||
diags = diags.Append(tokenDiags)
|
diags = diags.Append(tokenDiags)
|
||||||
if tokenDiags.HasErrors() {
|
if tokenDiags.HasErrors() {
|
||||||
c.showDiagnostics(diags)
|
c.showDiagnostics(diags)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"):
|
||||||
|
// The password grant type is allowed only for Terraform Cloud SaaS.
|
||||||
|
var tokenDiags tfdiags.Diagnostics
|
||||||
|
token, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig)
|
||||||
|
diags = diags.Append(tokenDiags)
|
||||||
|
if tokenDiags.HasErrors() {
|
||||||
|
c.showDiagnostics(diags)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
diags = diags.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),
|
||||||
|
))
|
||||||
|
c.showDiagnostics(diags)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Save the token in the CLI config.
|
// TODO: Save the token in the CLI config.
|
||||||
// Also, if the token has an expiration time associated with it, prompt
|
// Also, if the token has an expiration time associated with it, prompt
|
||||||
|
@ -183,9 +248,16 @@ func (c *LoginCommand) defaultOutputFile() string {
|
||||||
return filepath.Join(c.CLIConfigDir, "credentials.tfrc")
|
return filepath.Join(c.CLIConfigDir, "credentials.tfrc")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LoginCommand) interactiveGetToken(hostname svchost.Hostname, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
|
func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
|
||||||
var diags tfdiags.Diagnostics
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
|
confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthAuthzCodeGrant, credsCtx)
|
||||||
|
diags = diags.Append(confirmDiags)
|
||||||
|
if !confirm {
|
||||||
|
diags = diags.Append(errors.New("Login cancelled"))
|
||||||
|
return nil, diags
|
||||||
|
}
|
||||||
|
|
||||||
// We'll use an entirely pseudo-random UUID for our temporary request
|
// We'll use an entirely pseudo-random UUID for our temporary request
|
||||||
// state. The OAuth server must echo this back to us in the callback
|
// state. The OAuth server must echo this back to us in the callback
|
||||||
// request to make it difficult for some other running process to
|
// request to make it difficult for some other running process to
|
||||||
|
@ -327,6 +399,61 @@ func (c *LoginCommand) interactiveGetToken(hostname svchost.Hostname, clientConf
|
||||||
return token, diags
|
return token, diags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) {
|
||||||
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
|
confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthOwnerPasswordGrant, credsCtx)
|
||||||
|
diags = diags.Append(confirmDiags)
|
||||||
|
if !confirm {
|
||||||
|
diags = diags.Append(errors.New("Login cancelled"))
|
||||||
|
return nil, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) {
|
||||||
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
|
c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using OAuth.\n", hostname.ForDisplay()))
|
||||||
|
|
||||||
|
if grantType.UsesAuthorizationEndpoint() {
|
||||||
|
c.Ui.Output(
|
||||||
|
"This will work only if you are able to use a web browser on this computer to\ncomplete a login process. If not, you must obtain an API token by another\nmeans and configure it in the CLI configuration manually.\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("If login is successful, Terraform will store the token in the configured\n%q credentials helper for use by subsequent commands.\n", credsCtx.HelperType))
|
||||||
|
case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable:
|
||||||
|
c.Ui.Output(fmt.Sprintf("If login is successful, Terraform will store the token in plain text in\nthe following file for use by subsequent commands:\n %s\n", credsCtx.LocalFilename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{
|
||||||
|
Id: "confirm",
|
||||||
|
Query: "Do you want to proceed with login and store the new credentials?",
|
||||||
|
Description: "Enter 'y' or 'yes' to confirm.",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Should not happen because this command checks that input is enabled
|
||||||
|
// before we get to this point.
|
||||||
|
diags = diags.Append(err)
|
||||||
|
return false, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "y", "yes":
|
||||||
|
return true, diags
|
||||||
|
default:
|
||||||
|
return false, diags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) {
|
func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) {
|
||||||
if minPort < 1024 || maxPort < 1024 {
|
if minPort < 1024 || maxPort < 1024 {
|
||||||
// This should never happen because it should've been checked by
|
// This should never happen because it should've been checked by
|
||||||
|
@ -387,6 +514,12 @@ func (c *LoginCommand) proofKey() (key, challenge string, err error) {
|
||||||
return key, challenge, nil
|
return key, challenge, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type loginCredentialsContext struct {
|
||||||
|
Location cliconfig.CredentialsLocation
|
||||||
|
LocalFilename string
|
||||||
|
HelperType string
|
||||||
|
}
|
||||||
|
|
||||||
const callbackSuccessMessage = `
|
const callbackSuccessMessage = `
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
|
Loading…
Reference in New Issue