github.com/iaas-resource-provision/iaas-rpc@v1.0.7-0.20211021023331-ed21f798c408/internal/command/login.go (about) 1 package command 2 3 import ( 4 "context" 5 "crypto/sha256" 6 "encoding/base64" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io/ioutil" 11 "log" 12 "math/rand" 13 "net" 14 "net/http" 15 "net/url" 16 "path/filepath" 17 "strings" 18 19 tfe "github.com/hashicorp/go-tfe" 20 svchost "github.com/iaas-resource-provision/iaas-rpc-svchost" 21 svcauth "github.com/iaas-resource-provision/iaas-rpc-svchost/auth" 22 "github.com/iaas-resource-provision/iaas-rpc-svchost/disco" 23 "github.com/iaas-resource-provision/iaas-rpc/internal/command/cliconfig" 24 "github.com/iaas-resource-provision/iaas-rpc/internal/httpclient" 25 "github.com/iaas-resource-provision/iaas-rpc/internal/terraform" 26 "github.com/iaas-resource-provision/iaas-rpc/internal/tfdiags" 27 28 uuid "github.com/hashicorp/go-uuid" 29 "golang.org/x/oauth2" 30 ) 31 32 // LoginCommand is a Command implementation that runs an interactive login 33 // flow for a remote service host. It then stashes credentials in a tfrc 34 // file in the user's home directory. 35 type LoginCommand struct { 36 Meta 37 } 38 39 // Run implements cli.Command. 40 func (c *LoginCommand) Run(args []string) int { 41 args = c.Meta.process(args) 42 cmdFlags := c.Meta.extendedFlagSet("login") 43 cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } 44 if err := cmdFlags.Parse(args); err != nil { 45 return 1 46 } 47 48 args = cmdFlags.Args() 49 if len(args) > 1 { 50 c.Ui.Error( 51 "The login command expects at most one argument: the host to log in to.") 52 cmdFlags.Usage() 53 return 1 54 } 55 56 var diags tfdiags.Diagnostics 57 58 if !c.input { 59 diags = diags.Append(tfdiags.Sourceless( 60 tfdiags.Error, 61 "Login is an interactive command", 62 "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.", 63 )) 64 c.showDiagnostics(diags) 65 return 1 66 } 67 68 givenHostname := "app.terraform.io" 69 if len(args) != 0 { 70 givenHostname = args[0] 71 } 72 73 hostname, err := svchost.ForComparison(givenHostname) 74 if err != nil { 75 diags = diags.Append(tfdiags.Sourceless( 76 tfdiags.Error, 77 "Invalid hostname", 78 fmt.Sprintf("The given hostname %q is not valid: %s.", givenHostname, err.Error()), 79 )) 80 c.showDiagnostics(diags) 81 return 1 82 } 83 84 // From now on, since we've validated the given hostname, we should use 85 // dispHostname in the UI to ensure we're presenting it in the canonical 86 // form, in case that helpers users with debugging when things aren't 87 // working as expected. (Perhaps the normalization is part of the cause.) 88 dispHostname := hostname.ForDisplay() 89 90 host, err := c.Services.Discover(hostname) 91 if err != nil { 92 diags = diags.Append(tfdiags.Sourceless( 93 tfdiags.Error, 94 "Service discovery failed for "+dispHostname, 95 96 // Contrary to usual Go idiom, the Discover function returns 97 // full sentences with initial capitalization in its error messages, 98 // and they are written with the end-user as the audience. We 99 // only need to add the trailing period to make them consistent 100 // with our usual error reporting standards. 101 err.Error()+".", 102 )) 103 c.showDiagnostics(diags) 104 return 1 105 } 106 107 creds := c.Services.CredentialsSource().(*cliconfig.CredentialsSource) 108 filename, _ := creds.CredentialsFilePath() 109 credsCtx := &loginCredentialsContext{ 110 Location: creds.HostCredentialsLocation(hostname), 111 LocalFilename: filename, // empty in the very unlikely event that we can't select a config directory for this user 112 HelperType: creds.CredentialsHelperType(), 113 } 114 115 clientConfig, err := host.ServiceOAuthClient("login.v1") 116 switch err.(type) { 117 case nil: 118 // Great! No problem, then. 119 case *disco.ErrServiceNotProvided: 120 // This is also fine! We'll try the manual token creation process. 121 case *disco.ErrVersionNotSupported: 122 diags = diags.Append(tfdiags.Sourceless( 123 tfdiags.Warning, 124 "Host does not support Terraform login", 125 fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname), 126 )) 127 default: 128 diags = diags.Append(tfdiags.Sourceless( 129 tfdiags.Warning, 130 "Host does not support Terraform login", 131 fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err), 132 )) 133 } 134 135 // If login service is unavailable, check for a TFE v2 API as fallback 136 var tfeservice *url.URL 137 if clientConfig == nil { 138 tfeservice, err = host.ServiceURL("tfe.v2") 139 switch err.(type) { 140 case nil: 141 // Success! 142 case *disco.ErrServiceNotProvided: 143 diags = diags.Append(tfdiags.Sourceless( 144 tfdiags.Error, 145 "Host does not support Terraform tokens API", 146 fmt.Sprintf("The given hostname %q does not support creating Terraform authorization tokens.", dispHostname), 147 )) 148 case *disco.ErrVersionNotSupported: 149 diags = diags.Append(tfdiags.Sourceless( 150 tfdiags.Error, 151 "Host does not support Terraform tokens API", 152 fmt.Sprintf("The given hostname %q allows creating Terraform authorization tokens, but requires a newer version of Terraform CLI to do so.", dispHostname), 153 )) 154 default: 155 diags = diags.Append(tfdiags.Sourceless( 156 tfdiags.Error, 157 "Host does not support Terraform tokens API", 158 fmt.Sprintf("The given hostname %q cannot support \"terraform login\": %s.", dispHostname, err), 159 )) 160 } 161 } 162 163 if credsCtx.Location == cliconfig.CredentialsInOtherFile { 164 diags = diags.Append(tfdiags.Sourceless( 165 tfdiags.Error, 166 fmt.Sprintf("Credentials for %s are manually configured", dispHostname), 167 "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.", 168 )) 169 } 170 171 if diags.HasErrors() { 172 c.showDiagnostics(diags) 173 return 1 174 } 175 176 var token svcauth.HostCredentialsToken 177 var tokenDiags tfdiags.Diagnostics 178 179 // Prefer Terraform login if available 180 if clientConfig != nil { 181 var oauthToken *oauth2.Token 182 183 switch { 184 case clientConfig.SupportedGrantTypes.Has(disco.OAuthAuthzCodeGrant): 185 // We prefer an OAuth code grant if the server supports it. 186 oauthToken, tokenDiags = c.interactiveGetTokenByCode(hostname, credsCtx, clientConfig) 187 case clientConfig.SupportedGrantTypes.Has(disco.OAuthOwnerPasswordGrant) && hostname == svchost.Hostname("app.terraform.io"): 188 // The password grant type is allowed only for Terraform Cloud SaaS. 189 // Note this case is purely theoretical at this point, as TFC currently uses 190 // its own bespoke login protocol (tfe) 191 oauthToken, tokenDiags = c.interactiveGetTokenByPassword(hostname, credsCtx, clientConfig) 192 default: 193 tokenDiags = tokenDiags.Append(tfdiags.Sourceless( 194 tfdiags.Error, 195 "Host does not support Terraform login", 196 fmt.Sprintf("The given hostname %q does not allow any OAuth grant types that are supported by this version of Terraform.", dispHostname), 197 )) 198 } 199 if oauthToken != nil { 200 token = svcauth.HostCredentialsToken(oauthToken.AccessToken) 201 } 202 } else if tfeservice != nil { 203 token, tokenDiags = c.interactiveGetTokenByUI(hostname, credsCtx, tfeservice) 204 } 205 206 diags = diags.Append(tokenDiags) 207 if diags.HasErrors() { 208 c.showDiagnostics(diags) 209 return 1 210 } 211 212 err = creds.StoreForHost(hostname, token) 213 if err != nil { 214 diags = diags.Append(tfdiags.Sourceless( 215 tfdiags.Error, 216 "Failed to save API token", 217 fmt.Sprintf("The given host returned an API token, but Terraform failed to save it: %s.", err), 218 )) 219 } 220 221 c.showDiagnostics(diags) 222 if diags.HasErrors() { 223 return 1 224 } 225 226 c.Ui.Output("\n---------------------------------------------------------------------------------\n") 227 if hostname == "app.terraform.io" { // Terraform Cloud 228 var motd struct { 229 Message string `json:"msg"` 230 Errors []interface{} `json:"errors"` 231 } 232 233 // Throughout the entire process of fetching a MOTD from TFC, use a default 234 // message if the platform-provided message is unavailable for any reason - 235 // be it the service isn't provided, the request failed, or any sort of 236 // platform error returned. 237 238 motdServiceURL, err := host.ServiceURL("motd.v1") 239 if err != nil { 240 c.logMOTDError(err) 241 c.outputDefaultTFCLoginSuccess() 242 return 0 243 } 244 245 req, err := http.NewRequest("GET", motdServiceURL.String(), nil) 246 if err != nil { 247 c.logMOTDError(err) 248 c.outputDefaultTFCLoginSuccess() 249 return 0 250 } 251 252 req.Header.Set("Authorization", "Bearer "+token.Token()) 253 254 resp, err := httpclient.New().Do(req) 255 if err != nil { 256 c.logMOTDError(err) 257 c.outputDefaultTFCLoginSuccess() 258 return 0 259 } 260 261 body, err := ioutil.ReadAll(resp.Body) 262 if err != nil { 263 c.logMOTDError(err) 264 c.outputDefaultTFCLoginSuccess() 265 return 0 266 } 267 268 defer resp.Body.Close() 269 json.Unmarshal(body, &motd) 270 271 if motd.Errors == nil && motd.Message != "" { 272 c.Ui.Output( 273 c.Colorize().Color(motd.Message), 274 ) 275 return 0 276 } else { 277 c.logMOTDError(fmt.Errorf("platform responded with errors or an empty message")) 278 c.outputDefaultTFCLoginSuccess() 279 return 0 280 } 281 } 282 283 if tfeservice != nil { // Terraform Enterprise 284 c.outputDefaultTFELoginSuccess(dispHostname) 285 } else { 286 c.Ui.Output( 287 fmt.Sprintf( 288 c.Colorize().Color(strings.TrimSpace(` 289 [green][bold]Success![reset] [bold]Terraform has obtained and saved an API token.[reset] 290 291 The new API token will be used for any future Terraform command that must make 292 authenticated requests to %s. 293 `)), 294 dispHostname, 295 ) + "\n", 296 ) 297 } 298 299 return 0 300 } 301 302 func (c *LoginCommand) outputDefaultTFELoginSuccess(dispHostname string) { 303 c.Ui.Output( 304 fmt.Sprintf( 305 c.Colorize().Color(strings.TrimSpace(` 306 [green][bold]Success![reset] [bold]Logged in to Terraform Enterprise (%s)[reset] 307 `)), 308 dispHostname, 309 ) + "\n", 310 ) 311 } 312 313 func (c *LoginCommand) outputDefaultTFCLoginSuccess() { 314 c.Ui.Output( 315 fmt.Sprintf( 316 c.Colorize().Color(strings.TrimSpace(` 317 [green][bold]Success![reset] [bold]Logged in to Terraform Cloud[reset] 318 `)), 319 ) + "\n", 320 ) 321 } 322 323 func (c *LoginCommand) logMOTDError(err error) { 324 log.Printf("[TRACE] login: An error occurred attempting to fetch a message of the day for Terraform Cloud: %s", err) 325 } 326 327 // Help implements cli.Command. 328 func (c *LoginCommand) Help() string { 329 defaultFile := c.defaultOutputFile() 330 if defaultFile == "" { 331 // Because this is just for the help message and it's very unlikely 332 // that a user wouldn't have a functioning home directory anyway, 333 // we'll just use a placeholder here. The real command has some 334 // more complex behavior for this case. This result is not correct 335 // on all platforms, but given how unlikely we are to hit this case 336 // that seems okay. 337 defaultFile = "~/.terraform/credentials.tfrc.json" 338 } 339 340 helpText := fmt.Sprintf(` 341 Usage: terraform [global options] login [hostname] 342 343 Retrieves an authentication token for the given hostname, if it supports 344 automatic login, and saves it in a credentials file in your home directory. 345 346 If no hostname is provided, the default hostname is app.terraform.io, to 347 log in to Terraform Cloud. 348 349 If not overridden by credentials helper settings in the CLI configuration, 350 the credentials will be written to the following local file: 351 %s 352 `, defaultFile) 353 return strings.TrimSpace(helpText) 354 } 355 356 // Synopsis implements cli.Command. 357 func (c *LoginCommand) Synopsis() string { 358 return "Obtain and save credentials for a remote host" 359 } 360 361 func (c *LoginCommand) defaultOutputFile() string { 362 if c.CLIConfigDir == "" { 363 return "" // no default available 364 } 365 return filepath.Join(c.CLIConfigDir, "credentials.tfrc.json") 366 } 367 368 func (c *LoginCommand) interactiveGetTokenByCode(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) { 369 var diags tfdiags.Diagnostics 370 371 confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthAuthzCodeGrant, credsCtx) 372 diags = diags.Append(confirmDiags) 373 if !confirm { 374 diags = diags.Append(errors.New("Login cancelled")) 375 return nil, diags 376 } 377 378 // We'll use an entirely pseudo-random UUID for our temporary request 379 // state. The OAuth server must echo this back to us in the callback 380 // request to make it difficult for some other running process to 381 // interfere by sending its own request to our temporary server. 382 reqState, err := uuid.GenerateUUID() 383 if err != nil { 384 // This should be very unlikely, but could potentially occur if e.g. 385 // there's not enough pseudo-random entropy available. 386 diags = diags.Append(tfdiags.Sourceless( 387 tfdiags.Error, 388 "Can't generate login request state", 389 fmt.Sprintf("Cannot generate random request identifier for login request: %s.", err), 390 )) 391 return nil, diags 392 } 393 394 proofKey, proofKeyChallenge, err := c.proofKey() 395 if err != nil { 396 diags = diags.Append(tfdiags.Sourceless( 397 tfdiags.Error, 398 "Can't generate login request state", 399 fmt.Sprintf("Cannot generate random prrof key for login request: %s.", err), 400 )) 401 return nil, diags 402 } 403 404 listener, callbackURL, err := c.listenerForCallback(clientConfig.MinPort, clientConfig.MaxPort) 405 if err != nil { 406 diags = diags.Append(tfdiags.Sourceless( 407 tfdiags.Error, 408 "Can't start temporary login server", 409 fmt.Sprintf( 410 "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.", 411 clientConfig.MinPort, clientConfig.MaxPort, 412 ), 413 )) 414 return nil, diags 415 } 416 417 // codeCh will allow our temporary HTTP server to transmit the OAuth code 418 // to the main execution path that follows. 419 codeCh := make(chan string) 420 server := &http.Server{ 421 Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 422 log.Printf("[TRACE] login: request to callback server") 423 err := req.ParseForm() 424 if err != nil { 425 log.Printf("[ERROR] login: cannot ParseForm on callback request: %s", err) 426 resp.WriteHeader(400) 427 return 428 } 429 gotState := req.Form.Get("state") 430 if gotState != reqState { 431 log.Printf("[ERROR] login: incorrect \"state\" value in callback request") 432 resp.WriteHeader(400) 433 return 434 } 435 gotCode := req.Form.Get("code") 436 if gotCode == "" { 437 log.Printf("[ERROR] login: no \"code\" argument in callback request") 438 resp.WriteHeader(400) 439 return 440 } 441 442 log.Printf("[TRACE] login: request contains an authorization code") 443 444 // Send the code to our blocking wait below, so that the token 445 // fetching process can continue. 446 codeCh <- gotCode 447 close(codeCh) 448 449 log.Printf("[TRACE] login: returning response from callback server") 450 451 resp.Header().Add("Content-Type", "text/html") 452 resp.WriteHeader(200) 453 resp.Write([]byte(callbackSuccessMessage)) 454 }), 455 } 456 go func() { 457 err := server.Serve(listener) 458 if err != nil && err != http.ErrServerClosed { 459 diags = diags.Append(tfdiags.Sourceless( 460 tfdiags.Error, 461 "Can't start temporary login server", 462 fmt.Sprintf( 463 "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.", 464 clientConfig.MinPort, clientConfig.MaxPort, 465 ), 466 )) 467 close(codeCh) 468 } 469 }() 470 471 oauthConfig := &oauth2.Config{ 472 ClientID: clientConfig.ID, 473 Endpoint: clientConfig.Endpoint(), 474 RedirectURL: callbackURL, 475 Scopes: clientConfig.Scopes, 476 } 477 478 authCodeURL := oauthConfig.AuthCodeURL( 479 reqState, 480 oauth2.SetAuthURLParam("code_challenge", proofKeyChallenge), 481 oauth2.SetAuthURLParam("code_challenge_method", "S256"), 482 ) 483 484 launchBrowserManually := false 485 if c.BrowserLauncher != nil { 486 err = c.BrowserLauncher.OpenURL(authCodeURL) 487 if err == nil { 488 c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the login page for %s.\n", hostname.ForDisplay())) 489 c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n %s\n", authCodeURL)) 490 } else { 491 // Assume we're on a platform where opening a browser isn't possible. 492 launchBrowserManually = true 493 } 494 } else { 495 launchBrowserManually = true 496 } 497 498 if launchBrowserManually { 499 c.Ui.Output(fmt.Sprintf("Open the following URL to access the login page for %s:\n %s\n", hostname.ForDisplay(), authCodeURL)) 500 } 501 502 c.Ui.Output("Terraform will now wait for the host to signal that login was successful.\n") 503 504 code, ok := <-codeCh 505 if !ok { 506 // If we got no code at all then the server wasn't able to start 507 // up, so we'll just give up. 508 return nil, diags 509 } 510 511 if err := server.Close(); err != nil { 512 // The server will close soon enough when our process exits anyway, 513 // so we won't fuss about it for right now. 514 log.Printf("[WARN] login: callback server can't shut down: %s", err) 515 } 516 517 ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpclient.New()) 518 token, err := oauthConfig.Exchange( 519 ctx, code, 520 oauth2.SetAuthURLParam("code_verifier", proofKey), 521 ) 522 if err != nil { 523 diags = diags.Append(tfdiags.Sourceless( 524 tfdiags.Error, 525 "Failed to obtain auth token", 526 fmt.Sprintf("The remote server did not assign an auth token: %s.", err), 527 )) 528 return nil, diags 529 } 530 531 return token, diags 532 } 533 534 func (c *LoginCommand) interactiveGetTokenByPassword(hostname svchost.Hostname, credsCtx *loginCredentialsContext, clientConfig *disco.OAuthClient) (*oauth2.Token, tfdiags.Diagnostics) { 535 var diags tfdiags.Diagnostics 536 537 confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthOwnerPasswordGrant, credsCtx) 538 diags = diags.Append(confirmDiags) 539 if !confirm { 540 diags = diags.Append(errors.New("Login cancelled")) 541 return nil, diags 542 } 543 544 c.Ui.Output("\n---------------------------------------------------------------------------------\n") 545 c.Ui.Output("Terraform must temporarily use your password to request an API token.\nThis password will NOT be saved locally.\n") 546 547 username, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ 548 Id: "username", 549 Query: fmt.Sprintf("Username for %s:", hostname.ForDisplay()), 550 }) 551 if err != nil { 552 diags = diags.Append(fmt.Errorf("Failed to request username: %s", err)) 553 return nil, diags 554 } 555 password, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ 556 Id: "password", 557 Query: fmt.Sprintf("Password for %s:", hostname.ForDisplay()), 558 Secret: true, 559 }) 560 if err != nil { 561 diags = diags.Append(fmt.Errorf("Failed to request password: %s", err)) 562 return nil, diags 563 } 564 565 oauthConfig := &oauth2.Config{ 566 ClientID: clientConfig.ID, 567 Endpoint: clientConfig.Endpoint(), 568 Scopes: clientConfig.Scopes, 569 } 570 token, err := oauthConfig.PasswordCredentialsToken(context.Background(), username, password) 571 if err != nil { 572 // FIXME: The OAuth2 library generates errors that are not appropriate 573 // for a Terraform end-user audience, so once we have more experience 574 // with which errors are most common we should try to recognize them 575 // here and produce better error messages for them. 576 diags = diags.Append(tfdiags.Sourceless( 577 tfdiags.Error, 578 "Failed to retrieve API token", 579 fmt.Sprintf("The remote host did not issue an API token: %s.", err), 580 )) 581 } 582 583 return token, diags 584 } 585 586 func (c *LoginCommand) interactiveGetTokenByUI(hostname svchost.Hostname, credsCtx *loginCredentialsContext, service *url.URL) (svcauth.HostCredentialsToken, tfdiags.Diagnostics) { 587 var diags tfdiags.Diagnostics 588 589 confirm, confirmDiags := c.interactiveContextConsent(hostname, disco.OAuthGrantType(""), credsCtx) 590 diags = diags.Append(confirmDiags) 591 if !confirm { 592 diags = diags.Append(errors.New("Login cancelled")) 593 return "", diags 594 } 595 596 c.Ui.Output("\n---------------------------------------------------------------------------------\n") 597 598 tokensURL := url.URL{ 599 Scheme: "https", 600 Host: service.Hostname(), 601 Path: "/app/settings/tokens", 602 RawQuery: "source=terraform-login", 603 } 604 605 launchBrowserManually := false 606 if c.BrowserLauncher != nil { 607 err := c.BrowserLauncher.OpenURL(tokensURL.String()) 608 if err == nil { 609 c.Ui.Output(fmt.Sprintf("Terraform must now open a web browser to the tokens page for %s.\n", hostname.ForDisplay())) 610 c.Ui.Output(fmt.Sprintf("If a browser does not open this automatically, open the following URL to proceed:\n %s\n", tokensURL.String())) 611 } else { 612 log.Printf("[DEBUG] error opening web browser: %s", err) 613 // Assume we're on a platform where opening a browser isn't possible. 614 launchBrowserManually = true 615 } 616 } else { 617 launchBrowserManually = true 618 } 619 620 if launchBrowserManually { 621 c.Ui.Output(fmt.Sprintf("Open the following URL to access the tokens page for %s:\n %s\n", hostname.ForDisplay(), tokensURL.String())) 622 } 623 624 c.Ui.Output("\n---------------------------------------------------------------------------------\n") 625 c.Ui.Output("Generate a token using your browser, and copy-paste it into this prompt.\n") 626 627 // credsCtx might not be set if we're using a mock credentials source 628 // in a test, but it should always be set in normal use. 629 if credsCtx != nil { 630 switch credsCtx.Location { 631 case cliconfig.CredentialsViaHelper: 632 c.Ui.Output(fmt.Sprintf("Terraform will store the token in the configured %q credentials helper\nfor use by subsequent commands.\n", credsCtx.HelperType)) 633 case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable: 634 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)) 635 } 636 } 637 638 token, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ 639 Id: "token", 640 Query: fmt.Sprintf("Token for %s:", hostname.ForDisplay()), 641 Secret: true, 642 }) 643 if err != nil { 644 diags := diags.Append(fmt.Errorf("Failed to retrieve token: %s", err)) 645 return "", diags 646 } 647 648 token = strings.TrimSpace(token) 649 cfg := &tfe.Config{ 650 Address: service.String(), 651 BasePath: service.Path, 652 Token: token, 653 Headers: make(http.Header), 654 } 655 client, err := tfe.NewClient(cfg) 656 if err != nil { 657 diags = diags.Append(fmt.Errorf("Failed to create API client: %s", err)) 658 return "", diags 659 } 660 user, err := client.Users.ReadCurrent(context.Background()) 661 if err == tfe.ErrUnauthorized { 662 diags = diags.Append(fmt.Errorf("Token is invalid: %s", err)) 663 return "", diags 664 } else if err != nil { 665 diags = diags.Append(fmt.Errorf("Failed to retrieve user account details: %s", err)) 666 return "", diags 667 } 668 c.Ui.Output(fmt.Sprintf(c.Colorize().Color("\nRetrieved token for user [bold]%s[reset]\n"), user.Username)) 669 670 return svcauth.HostCredentialsToken(token), nil 671 } 672 673 func (c *LoginCommand) interactiveContextConsent(hostname svchost.Hostname, grantType disco.OAuthGrantType, credsCtx *loginCredentialsContext) (bool, tfdiags.Diagnostics) { 674 var diags tfdiags.Diagnostics 675 mechanism := "OAuth" 676 if grantType == "" { 677 mechanism = "your browser" 678 } 679 680 c.Ui.Output(fmt.Sprintf("Terraform will request an API token for %s using %s.\n", hostname.ForDisplay(), mechanism)) 681 682 if grantType.UsesAuthorizationEndpoint() { 683 c.Ui.Output( 684 "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", 685 ) 686 } 687 688 // credsCtx might not be set if we're using a mock credentials source 689 // in a test, but it should always be set in normal use. 690 if credsCtx != nil { 691 switch credsCtx.Location { 692 case cliconfig.CredentialsViaHelper: 693 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)) 694 case cliconfig.CredentialsInPrimaryFile, cliconfig.CredentialsNotAvailable: 695 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)) 696 } 697 } 698 699 v, err := c.UIInput().Input(context.Background(), &terraform.InputOpts{ 700 Id: "approve", 701 Query: "Do you want to proceed?", 702 Description: `Only 'yes' will be accepted to confirm.`, 703 }) 704 if err != nil { 705 // Should not happen because this command checks that input is enabled 706 // before we get to this point. 707 diags = diags.Append(err) 708 return false, diags 709 } 710 711 return strings.ToLower(v) == "yes", diags 712 } 713 714 func (c *LoginCommand) listenerForCallback(minPort, maxPort uint16) (net.Listener, string, error) { 715 if minPort < 1024 || maxPort < 1024 { 716 // This should never happen because it should've been checked by 717 // the svchost/disco package when reading the service description, 718 // but we'll prefer to fail hard rather than inadvertently trying 719 // to open an unprivileged port if there are bugs at that layer. 720 panic("listenerForCallback called with privileged port number") 721 } 722 723 availCount := int(maxPort) - int(minPort) 724 725 // We're going to try port numbers within the range at random, so we need 726 // to terminate eventually in case _none_ of the ports are available. 727 // We'll make that 150% of the number of ports just to give us some room 728 // for the random number generator to generate the same port more than 729 // once. 730 // Note that we don't really care about true randomness here... we're just 731 // trying to hop around in the available port space rather than always 732 // working up from the lowest, because we have no information to predict 733 // that any particular number will be more likely to be available than 734 // another. 735 maxTries := availCount + (availCount / 2) 736 737 for tries := 0; tries < maxTries; tries++ { 738 port := rand.Intn(availCount) + int(minPort) 739 addr := fmt.Sprintf("127.0.0.1:%d", port) 740 log.Printf("[TRACE] login: trying %s as a listen address for temporary OAuth callback server", addr) 741 l, err := net.Listen("tcp4", addr) 742 if err == nil { 743 // We use a path that doesn't end in a slash here because some 744 // OAuth server implementations don't allow callback URLs to 745 // end with slashes. 746 callbackURL := fmt.Sprintf("http://localhost:%d/login", port) 747 log.Printf("[TRACE] login: callback URL will be %s", callbackURL) 748 return l, callbackURL, nil 749 } 750 } 751 752 return nil, "", fmt.Errorf("no suitable TCP ports (between %d and %d) are available for the temporary OAuth callback server", minPort, maxPort) 753 } 754 755 func (c *LoginCommand) proofKey() (key, challenge string, err error) { 756 // Wel use a UUID-like string as the "proof key for code exchange" (PKCE) 757 // that will eventually authenticate our request to the token endpoint. 758 // Standard UUIDs are explicitly not suitable as secrets according to the 759 // UUID spec, but our go-uuid just generates totally random number sequences 760 // formatted in the conventional UUID syntax, so that concern does not 761 // apply here: this is just a 128-bit crypto-random number. 762 uu, err := uuid.GenerateUUID() 763 if err != nil { 764 return "", "", err 765 } 766 767 key = fmt.Sprintf("%s.%09d", uu, rand.Intn(999999999)) 768 769 h := sha256.New() 770 h.Write([]byte(key)) 771 challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 772 773 return key, challenge, nil 774 } 775 776 type loginCredentialsContext struct { 777 Location cliconfig.CredentialsLocation 778 LocalFilename string 779 HelperType string 780 } 781 782 const callbackSuccessMessage = ` 783 <html> 784 <head> 785 <title>Terraform Login</title> 786 <style type="text/css"> 787 body { 788 font-family: monospace; 789 color: #fff; 790 background-color: #000; 791 } 792 </style> 793 </head> 794 <body> 795 796 <p>The login server has returned an authentication code to Terraform.</p> 797 <p>Now close this page and return to the terminal where <tt>terraform login</tt> 798 is running to see the result of the login process.</p> 799 800 </body> 801 </html> 802 `