| package command |
| |
| import ( |
| "context" |
| "crypto/sha256" |
| "encoding/base64" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io/ioutil" |
| "log" |
| "math/rand" |
| "net" |
| "net/http" |
| "net/url" |
| "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" |
| "github.com/hashicorp/terraform/internal/command/cliconfig" |
| "github.com/hashicorp/terraform/internal/httpclient" |
| "github.com/hashicorp/terraform/internal/logging" |
| "github.com/hashicorp/terraform/internal/terraform" |
| "github.com/hashicorp/terraform/internal/tfdiags" |
| |
| uuid "github.com/hashicorp/go-uuid" |
| "golang.org/x/oauth2" |
| ) |
| |
| // LoginCommand is a Command implementation that runs an interactive login |
| // flow for a remote service host. It then stashes credentials in a tfrc |
| // file in the user's home directory. |
| type LoginCommand struct { |
| Meta |
| } |
| |
| // Run implements cli.Command. |
| func (c *LoginCommand) Run(args []string) int { |
| args = c.Meta.process(args) |
| cmdFlags := c.Meta.extendedFlagSet("login") |
| cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } |
| if err := cmdFlags.Parse(args); err != nil { |
| return 1 |
| } |
| |
| args = cmdFlags.Args() |
| if len(args) > 1 { |
| c.Ui.Error( |
| "The login command expects at most one argument: the host to log in to.") |
| cmdFlags.Usage() |
| return 1 |
| } |
| |
| 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" |
| if len(args) != 0 { |
| givenHostname = args[0] |
| } |
| |
| hostname, err := svchost.ForComparison(givenHostname) |
| if err != nil { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Invalid hostname", |
| 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 |
| // dispHostname in the UI to ensure we're presenting it in the canonical |
| // form, in case that helpers users with debugging when things aren't |
| // working as expected. (Perhaps the normalization is part of the cause.) |
| dispHostname := hostname.ForDisplay() |
| |
| host, err := c.Services.Discover(hostname) |
| if err != nil { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Service discovery failed for "+dispHostname, |
| |
| // Contrary to usual Go idiom, the Discover function returns |
| // full sentences with initial capitalization in its error messages, |
| // and they are written with the end-user as the audience. We |
| // only need to add the trailing period to make them consistent |
| // with our usual error reporting standards. |
| err.Error()+".", |
| )) |
| c.showDiagnostics(diags) |
| return 1 |
| } |
| |
| creds := c.Services.CredentialsSource().(*cliconfig.CredentialsSource) |
| filename, _ := creds.CredentialsFilePath() |
| credsCtx := &loginCredentialsContext{ |
| Location: creds.HostCredentialsLocation(hostname), |
| LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user |
| HelperType: creds.CredentialsHelperType(), |
| } |
| |
| clientConfig, err := host.ServiceOAuthClient("login.v1") |
| switch err.(type) { |
| case nil: |
| // Great! No problem, then. |
| case *disco.ErrServiceNotProvided: |
| // This is also fine! We'll try the manual token creation process. |
| case *disco.ErrVersionNotSupported: |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Warning, |
| "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), |
| )) |
| default: |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Warning, |
| "Host does not support Terraform login", |
| 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 tfeservice *url.URL |
| if clientConfig == nil { |
| tfeservice, 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 { |
| 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() { |
| c.showDiagnostics(diags) |
| return 1 |
| } |
| |
| var token svcauth.HostCredentialsToken |
| var tokenDiags tfdiags.Diagnostics |
| |
| // Prefer Terraform login if available |
| if clientConfig != nil { |
| var oauthToken *oauth2.Token |
| |
| switch { |
| case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant): |
| // 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. |
| // Note this case is purely theoretical at this point, as TFC currently uses |
| // its own bespoke login protocol (tfe) |
| 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), |
| )) |
| } |
| if oauthToken != nil { |
| token = svcauth.HostCredentialsToken(oauthToken.AccessToken) |
| } |
| } else if tfeservice != nil { |
| token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, tfeservice) |
| } |
| |
| diags = diags.Append(tokenDiags) |
| if diags.HasErrors() { |
| c.showDiagnostics(diags) |
| return 1 |
| } |
| |
| err = creds.StoreForHost(hostname, token) |
| if err != nil { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Failed to save API token", |
| fmt.Sprintf("The given host returned an API token, but Terraform failed to save it: %s.", err), |
| )) |
| } |
| |
| c.showDiagnostics(diags) |
| if diags.HasErrors() { |
| return 1 |
| } |
| |
| c.Ui.Output("\n---------------------------------------------------------------------------------\n") |
| if hostname == "app.terraform.io" { // Terraform Cloud |
| var motd struct { |
| Message string `json:"msg"` |
| Errors []interface{} `json:"errors"` |
| } |
| |
| // Throughout the entire process of fetching a MOTD from TFC, use a default |
| // message if the platform-provided message is unavailable for any reason - |
| // be it the service isn't provided, the request failed, or any sort of |
| // platform error returned. |
| |
| motdServiceURL, err := host.ServiceURL("motd.v1") |
| if err != nil { |
| c.logMOTDError(err) |
| c.outputDefaultTFCLoginSuccess() |
| return 0 |
| } |
| |
| req, err := http.NewRequest("GET", motdServiceURL.String(), nil) |
| if err != nil { |
| c.logMOTDError(err) |
| c.outputDefaultTFCLoginSuccess() |
| return 0 |
| } |
| |
| req.Header.Set("Authorization", "Bearer "+token.Token()) |
| |
| resp, err := httpclient.New().Do(req) |
| if err != nil { |
| c.logMOTDError(err) |
| c.outputDefaultTFCLoginSuccess() |
| return 0 |
| } |
| |
| body, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| c.logMOTDError(err) |
| c.outputDefaultTFCLoginSuccess() |
| return 0 |
| } |
| |
| defer resp.Body.Close() |
| json.Unmarshal(body, &motd) |
| |
| if motd.Errors == nil && motd.Message != "" { |
| c.Ui.Output( |
| c.Colorize().Color(motd.Message), |
| ) |
| return 0 |
| } else { |
| c.logMOTDError(fmt.Errorf("platform responded with errors or an empty message")) |
| c.outputDefaultTFCLoginSuccess() |
| return 0 |
| } |
| } |
| |
| if tfeservice != nil { // Terraform Enterprise |
| c.outputDefaultTFELoginSuccess(dispHostname) |
| } else { |
| c.Ui.Output( |
| fmt.Sprintf( |
| c.Colorize().Color(strings.TrimSpace(` |
| [green][bold]Success![reset] [bold]Terraform has obtained and saved an API token.[reset] |
| |
| The new API token will be used for any future Terraform command that must make |
| authenticated requests to %s. |
| `)), |
| dispHostname, |
| ) + "\n", |
| ) |
| } |
| |
| return 0 |
| } |
| |
| func (c *LoginCommand) outputDefaultTFELoginSuccess(dispHostname string) { |
| c.Ui.Output( |
| fmt.Sprintf( |
| c.Colorize().Color(strings.TrimSpace(` |
| [green][bold]Success![reset] [bold]Logged in to Terraform Enterprise (%s)[reset] |
| `)), |
| dispHostname, |
| ) + "\n", |
| ) |
| } |
| |
| func (c *LoginCommand) outputDefaultTFCLoginSuccess() { |
| c.Ui.Output(c.Colorize().Color(strings.TrimSpace(` |
| [green][bold]Success![reset] [bold]Logged in to Terraform Cloud[reset] |
| ` + "\n"))) |
| } |
| |
| func (c *LoginCommand) logMOTDError(err error) { |
| log.Printf("[TRACE] login: An error occurred attempting to fetch a message of the day for Terraform Cloud: %s", err) |
| } |
| |
| // Help implements cli.Command. |
| func (c *LoginCommand) Help() string { |
| defaultFile := c.defaultOutputFile() |
| if defaultFile == "" { |
| // Because this is just for the help message and it's very unlikely |
| // that a user wouldn't have a functioning home directory anyway, |
| // we'll just use a placeholder here. The real command has some |
| // more complex behavior for this case. This result is not correct |
| // on all platforms, but given how unlikely we are to hit this case |
| // that seems okay. |
| defaultFile = "~/.terraform/credentials.tfrc.json" |
| } |
| |
| helpText := fmt.Sprintf(` |
| Usage: terraform [global options] login [hostname] |
| |
| Retrieves an authentication token for the given hostname, if it supports |
| automatic login, and saves it in a credentials file in your home directory. |
| |
| If no hostname is provided, the default hostname is app.terraform.io, to |
| log in to Terraform Cloud. |
| |
| If not overridden by credentials helper settings in the CLI configuration, |
| the credentials will be written to the following local file: |
| %s |
| `, defaultFile) |
| return strings.TrimSpace(helpText) |
| } |
| |
| // Synopsis implements cli.Command. |
| func (c *LoginCommand) Synopsis() string { |
| return "Obtain and save credentials for a remote host" |
| } |
| |
| func (c *LoginCommand) defaultOutputFile() string { |
| if c.CLIConfigDir == "" { |
| return "" // no default available |
| } |
| return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json") |
| } |
| |
| func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, 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 |
| // state. The OAuth server must echo this back to us in the callback |
| // request to make it difficult for some other running process to |
| // interfere by sending its own request to our temporary server. |
| reqState, err := uuid.GenerateUUID() |
| if err != nil { |
| // This should be very unlikely, but could potentially occur if e.g. |
| // there's not enough pseudo-random entropy available. |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Can't generate login request state", |
| fmt.Sprintf("Cannot generate random request identifier for login request: %s.", err), |
| )) |
| return nil, diags |
| } |
| |
| proofKey, proofKeyChallenge, err := c.proofKey() |
| if err != nil { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Can't generate login request state", |
| fmt.Sprintf("Cannot generate random prrof key for login request: %s.", err), |
| )) |
| return nil, diags |
| } |
| |
| listener, callbackURL, err := c.listenerForCallback(clientConfig.MinPort, clientConfig.MaxPort) |
| if err != nil { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Can't start temporary login server", |
| fmt.Sprintf( |
| "The login process uses OAuth, which requires starting a temporary HTTP server on localhost. However, no TCP port numbers between %d and %d are available to create such a server.", |
| clientConfig.MinPort, clientConfig.MaxPort, |
| ), |
| )) |
| return nil, diags |
| } |
| |
| // codeCh will allow our temporary HTTP server to transmit the OAuth code |
| // to the main execution path that follows. |
| codeCh := make(chan string) |
| server := &http.Server{ |
| Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { |
| log.Printf("[TRACE] login: request to callback server") |
| err := req.ParseForm() |
| if err != nil { |
| log.Printf("[ERROR] login: cannot ParseForm on callback request: %s", err) |
| resp.WriteHeader(400) |
| return |
| } |
| gotState := req.Form.Get("state") |
| if gotState != reqState { |
| log.Printf("[ERROR] login: incorrect \"state\" value in callback request") |
| resp.WriteHeader(400) |
| return |
| } |
| gotCode := req.Form.Get("code") |
| if gotCode == "" { |
| log.Printf("[ERROR] login: no \"code\" argument in callback request") |
| resp.WriteHeader(400) |
| return |
| } |
| |
| log.Printf("[TRACE] login: request contains an authorization code") |
| |
| // Send the code to our blocking wait below, so that the token |
| // fetching process can continue. |
| codeCh <- gotCode |
| close(codeCh) |
| |
| log.Printf("[TRACE] login: returning response from callback server") |
| |
| resp.Header().Add("Content-Type", "text/html") |
| resp.WriteHeader(200) |
| resp.Write([]byte(callbackSuccessMessage)) |
| }), |
| } |
| go func() { |
| defer logging.PanicHandler() |
| err := server.Serve(listener) |
| if err != nil && err != http.ErrServerClosed { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Can't start temporary login server", |
| fmt.Sprintf( |
| "The login process uses OAuth, which requires starting a temporary HTTP server on localhost. However, no TCP port numbers between %d and %d are available to create such a server.", |
| clientConfig.MinPort, clientConfig.MaxPort, |
| ), |
| )) |
| close(codeCh) |
| } |
| }() |
| |
| oauthConfig := &oauth2.Config{ |
| ClientID: clientConfig.ID, |
| Endpoint: clientConfig.Endpoint(), |
| RedirectURL: callbackURL, |
| Scopes: clientConfig.Scopes, |
| } |
| |
| authCodeURL := oauthConfig.AuthCodeURL( |
| reqState, |
| oauth2.SetAuthURLParam("code_challenge", proofKeyChallenge), |
| oauth2.SetAuthURLParam("code_challenge_method", "S256"), |
| ) |
| |
| launchBrowserManually := false |
| if c.BrowserLauncher != nil { |
| err = c.BrowserLauncher.OpenURL(authCodeURL) |
| if err == nil { |
| c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the login 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", authCodeURL)) |
| } 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 login page for %s:\n %s\n", hostname.ForDisplay(), authCodeURL)) |
| } |
| |
| c.Ui.Output("Terraform will now wait for the host to signal that login was successful.\n") |
| |
| code, ok := <-codeCh |
| if !ok { |
| // If we got no code at all then the server wasn't able to start |
| // up, so we'll just give up. |
| return nil, diags |
| } |
| |
| if err := server.Close(); err != nil { |
| // The server will close soon enough when our process exits anyway, |
| // so we won't fuss about it for right now. |
| log.Printf("[WARN] login: callback server can't shut down: %s", err) |
| } |
| |
| ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpclient.New()) |
| token, err := oauthConfig.Exchange( |
| ctx, code, |
| oauth2.SetAuthURLParam("code_verifier", proofKey), |
| ) |
| if err != nil { |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Failed to obtain auth token", |
| fmt.Sprintf("The remote server did not assign an auth token: %s.", err), |
| )) |
| return nil, 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 |
| } |
| |
| c.Ui.Output("\n---------------------------------------------------------------------------------\n") |
| c.Ui.Output("Terraform must temporarily use your password to request an API token.\nThis password will NOT be saved locally.\n") |
| |
| username, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ |
| Id: "username", |
| Query: fmt.Sprintf("Username for %s:", hostname.ForDisplay()), |
| }) |
| if err != nil { |
| diags = diags.Append(fmt.Errorf("Failed to request username: %s", err)) |
| return nil, diags |
| } |
| password, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ |
| Id: "password", |
| Query: fmt.Sprintf("Password for %s:", hostname.ForDisplay()), |
| Secret: true, |
| }) |
| if err != nil { |
| diags = diags.Append(fmt.Errorf("Failed to request password: %s", err)) |
| return nil, diags |
| } |
| |
| oauthConfig := &oauth2.Config{ |
| ClientID: clientConfig.ID, |
| Endpoint: clientConfig.Endpoint(), |
| Scopes: clientConfig.Scopes, |
| } |
| token, err := oauthConfig.PasswordCredentialsToken(context.Background(), username, password) |
| if err != nil { |
| // FIXME: The OAuth2 library generates errors that are not appropriate |
| // for a Terraform end-user audience, so once we have more experience |
| // with which errors are most common we should try to recognize them |
| // here and produce better error messages for them. |
| diags = diags.Append(tfdiags.Sourceless( |
| tfdiags.Error, |
| "Failed to retrieve API token", |
| fmt.Sprintf("The remote host did not issue an API token: %s.", err), |
| )) |
| } |
| |
| return token, diags |
| } |
| |
| func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsCtx *loginCredentialsContext, service *url.URL) (svcauth.HostCredentialsToken, tfdiags.Diagnostics) { |
| var diags tfdiags.Diagnostics |
| |
| confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthGrantType(""), credsCtx) |
| diags = diags.Append(confirmDiags) |
| if !confirm { |
| diags = diags.Append(errors.New("Login cancelled")) |
| return "", diags |
| } |
| |
| c.Ui.Output("\n---------------------------------------------------------------------------------\n") |
| |
| 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 { |
| log.Printf("[DEBUG] error opening web browser: %s", err) |
| // 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.UIInput().Input(context.Background(), &terraform.InputOpts{ |
| Id: "token", |
| Query: fmt.Sprintf("Token for %s:", hostname.ForDisplay()), |
| Secret: true, |
| }) |
| 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 |
| } |
| |
| 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() { |
| 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: "approve", |
| Query: "Do you want to proceed?", |
| Description: `Only 'yes' will be accepted 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 |
| } |
| |
| return strings.ToLower(v) == "yes", diags |
| } |
| |
| func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) { |
| if minPort < 1024 || maxPort < 1024 { |
| // This should never happen because it should've been checked by |
| // the svchost/disco package when reading the service description, |
| // but we'll prefer to fail hard rather than inadvertently trying |
| // to open an unprivileged port if there are bugs at that layer. |
| panic("listenerForCallback called with privileged port number") |
| } |
| |
| availCount := int(maxPort) - int(minPort) |
| |
| // We're going to try port numbers within the range at random, so we need |
| // to terminate eventually in case _none_ of the ports are available. |
| // We'll make that 150% of the number of ports just to give us some room |
| // for the random number generator to generate the same port more than |
| // once. |
| // Note that we don't really care about true randomness here... we're just |
| // trying to hop around in the available port space rather than always |
| // working up from the lowest, because we have no information to predict |
| // that any particular number will be more likely to be available than |
| // another. |
| maxTries := availCount + (availCount / 2) |
| |
| for tries := 0; tries < maxTries; tries++ { |
| port := rand.Intn(availCount) + int(minPort) |
| addr := fmt.Sprintf("127.0.0.1:%d", port) |
| log.Printf("[TRACE] login: trying %s as a listen address for temporary OAuth callback server", addr) |
| l, err := net.Listen("tcp4", addr) |
| if err == nil { |
| // We use a path that doesn't end in a slash here because some |
| // OAuth server implementations don't allow callback URLs to |
| // end with slashes. |
| callbackURL := fmt.Sprintf("http://localhost:%d/login", port) |
| log.Printf("[TRACE] login: callback URL will be %s", callbackURL) |
| return l, callbackURL, nil |
| } |
| } |
| |
| return nil, "", fmt.Errorf("no suitable TCP ports (between %d and %d) are available for the temporary OAuth callback server", minPort, maxPort) |
| } |
| |
| func (c *LoginCommand) proofKey() (key, challenge string, err error) { |
| // Wel use a UUID-like string as the "proof key for code exchange" (PKCE) |
| // that will eventually authenticate our request to the token endpoint. |
| // Standard UUIDs are explicitly not suitable as secrets according to the |
| // UUID spec, but our go-uuid just generates totally random number sequences |
| // formatted in the conventional UUID syntax, so that concern does not |
| // apply here: this is just a 128-bit crypto-random number. |
| uu, err := uuid.GenerateUUID() |
| if err != nil { |
| return "", "", err |
| } |
| |
| key = fmt.Sprintf("%s.%09d", uu, rand.Intn(999999999)) |
| |
| h := sha256.New() |
| h.Write([]byte(key)) |
| challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) |
| |
| return key, challenge, nil |
| } |
| |
| type loginCredentialsContext struct { |
| Location cliconfig.CredentialsLocation |
| LocalFilename string |
| HelperType string |
| } |
| |
| const callbackSuccessMessage = ` |
| <html> |
| <head> |
| <title>Terraform Login</title> |
| <style type="text/css"> |
| body { |
| font-family: monospace; |
| color: #fff; |
| background-color: #000; |
| } |
| </style> |
| </head> |
| <body> |
| |
| <p>The login server has returned an authentication code to Terraform.</p> |
| <p>Now close this page and return to the terminal where <tt>terraform login</tt> |
| is running to see the result of the login process.</p> |
| |
| </body> |
| </html> |
| ` |