2019-07-09 21:06:20 +02:00
package command
import (
"context"
"crypto/sha256"
"encoding/base64"
2021-04-22 04:23:42 +02:00
"encoding/json"
2019-08-09 02:11:37 +02:00
"errors"
2019-07-09 21:06:20 +02:00
"fmt"
2021-04-22 04:23:42 +02:00
"io/ioutil"
2019-07-09 21:06:20 +02:00
"log"
"math/rand"
"net"
"net/http"
2020-01-24 20:43:15 +01:00
"net/url"
2019-07-09 21:06:20 +02:00
"path/filepath"
"strings"
2020-02-04 00:12:57 +01:00
tfe "github.com/hashicorp/go-tfe"
2020-01-24 20:43:15 +01:00
svchost "github.com/hashicorp/terraform-svchost"
2019-10-11 11:34:26 +02:00
svcauth "github.com/hashicorp/terraform-svchost/auth"
"github.com/hashicorp/terraform-svchost/disco"
2021-05-17 21:07:38 +02:00
"github.com/hashicorp/terraform/internal/command/cliconfig"
2021-05-17 18:54:53 +02:00
"github.com/hashicorp/terraform/internal/httpclient"
2021-10-27 22:33:35 +02:00
"github.com/hashicorp/terraform/internal/logging"
2021-05-17 21:46:19 +02:00
"github.com/hashicorp/terraform/internal/terraform"
2021-05-17 19:11:06 +02:00
"github.com/hashicorp/terraform/internal/tfdiags"
2019-07-09 21:06:20 +02:00
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 {
2020-04-01 21:01:08 +02:00
args = c . Meta . process ( args )
2019-08-09 02:11:37 +02:00
cmdFlags := c . Meta . extendedFlagSet ( "login" )
2019-07-09 21:06:20 +02:00
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
2019-08-09 02:11:37 +02:00
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
}
2019-07-09 21:06:20 +02:00
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 ( ) ) ,
) )
2019-08-09 02:11:37 +02:00
c . showDiagnostics ( diags )
return 1
2019-07-09 21:06:20 +02:00
}
// 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 ,
2019-08-30 03:05:28 +02:00
"Service discovery failed for " + dispHostname ,
2019-07-09 21:06:20 +02:00
// 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 ( ) + "." ,
) )
2019-08-09 02:11:37 +02:00
c . showDiagnostics ( diags )
return 1
}
2020-02-06 21:30:49 +01:00
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 ( ) ,
2019-07-09 21:06:20 +02:00
}
clientConfig , err := host . ServiceOAuthClient ( "login.v1" )
switch err . ( type ) {
case nil :
// Great! No problem, then.
case * disco . ErrServiceNotProvided :
2020-01-24 20:43:15 +01:00
// This is also fine! We'll try the manual token creation process.
2019-07-09 21:06:20 +02:00
case * disco . ErrVersionNotSupported :
diags = diags . Append ( tfdiags . Sourceless (
2020-01-24 20:43:15 +01:00
tfdiags . Warning ,
2019-07-09 21:06:20 +02:00
"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 (
2020-01-24 20:43:15 +01:00
tfdiags . Warning ,
2019-07-09 21:06:20 +02:00
"Host does not support Terraform login" ,
fmt . Sprintf ( "The given hostname %q cannot support \"terraform login\": %s." , dispHostname , err ) ,
) )
}
2020-01-24 20:43:15 +01:00
// If login service is unavailable, check for a TFE v2 API as fallback
2021-04-22 04:23:42 +02:00
var tfeservice * url . URL
2020-01-24 20:43:15 +01:00
if clientConfig == nil {
2021-04-22 04:23:42 +02:00
tfeservice , err = host . ServiceURL ( "tfe.v2" )
2020-01-24 20:43:15 +01:00
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 ) ,
) )
}
}
2019-08-09 02:11:37 +02:00
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." ,
) )
}
2019-07-09 21:06:20 +02:00
if diags . HasErrors ( ) {
c . showDiagnostics ( diags )
return 1
}
2020-01-24 20:43:15 +01:00
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.
2021-04-22 04:23:42 +02:00
// Note this case is purely theoretical at this point, as TFC currently uses
// its own bespoke login protocol (tfe)
2020-01-24 20:43:15 +01:00
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 ) ,
) )
2019-08-09 02:11:37 +02:00
}
2020-01-24 20:43:15 +01:00
if oauthToken != nil {
token = svcauth . HostCredentialsToken ( oauthToken . AccessToken )
2019-08-09 02:11:37 +02:00
}
2021-04-22 04:23:42 +02:00
} else if tfeservice != nil {
token , tokenDiags = c . interactiveGetTokenByUI ( hostname , credsCtx , tfeservice )
2020-01-24 20:43:15 +01:00
}
diags = diags . Append ( tokenDiags )
if diags . HasErrors ( ) {
2019-07-09 21:06:20 +02:00
c . showDiagnostics ( diags )
return 1
}
2020-01-24 20:43:15 +01:00
err = creds . StoreForHost ( hostname , token )
2019-08-22 00:18:07 +02:00
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 ) ,
) )
}
2019-07-09 21:06:20 +02:00
c . showDiagnostics ( diags )
if diags . HasErrors ( ) {
return 1
}
2019-08-22 00:18:07 +02:00
2019-08-22 00:37:36 +02:00
c . Ui . Output ( "\n---------------------------------------------------------------------------------\n" )
2021-04-22 04:23:42 +02:00
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 {
2021-04-22 15:55:59 +02:00
c . logMOTDError ( err )
c . outputDefaultTFCLoginSuccess ( )
2021-04-22 04:23:42 +02:00
return 0
}
req , err := http . NewRequest ( "GET" , motdServiceURL . String ( ) , nil )
if err != nil {
2021-04-22 15:55:59 +02:00
c . logMOTDError ( err )
c . outputDefaultTFCLoginSuccess ( )
2021-04-22 04:23:42 +02:00
return 0
}
req . Header . Set ( "Authorization" , "Bearer " + token . Token ( ) )
resp , err := httpclient . New ( ) . Do ( req )
if err != nil {
2021-04-22 15:55:59 +02:00
c . logMOTDError ( err )
c . outputDefaultTFCLoginSuccess ( )
2021-04-22 04:23:42 +02:00
return 0
}
body , err := ioutil . ReadAll ( resp . Body )
if err != nil {
2021-04-22 15:55:59 +02:00
c . logMOTDError ( err )
c . outputDefaultTFCLoginSuccess ( )
2021-04-22 04:23:42 +02:00
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 {
2021-04-22 15:55:59 +02:00
c . logMOTDError ( fmt . Errorf ( "platform responded with errors or an empty message" ) )
c . outputDefaultTFCLoginSuccess ( )
2021-04-22 04:23:42 +02:00
return 0
}
}
if tfeservice != nil { // Terraform Enterprise
c . outputDefaultTFELoginSuccess ( dispHostname )
} else {
c . Ui . Output (
fmt . Sprintf (
c . Colorize ( ) . Color ( strings . TrimSpace ( `
2019-08-22 00:18:07 +02:00
[ 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 .
2021-04-22 04:23:42 +02:00
` ) ) ,
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 ]
2019-08-22 00:18:07 +02:00
` ) ) ,
dispHostname ,
) + "\n" ,
)
2021-04-22 04:23:42 +02:00
}
2019-08-22 00:18:07 +02:00
2021-04-22 15:55:59 +02:00
func ( c * LoginCommand ) outputDefaultTFCLoginSuccess ( ) {
2021-08-31 23:33:26 +02:00
c . Ui . Output ( c . Colorize ( ) . Color ( strings . TrimSpace ( `
2021-04-22 04:23:42 +02:00
[ green ] [ bold ] Success ! [ reset ] [ bold ] Logged in to Terraform Cloud [ reset ]
2021-08-31 23:33:26 +02:00
` + "\n" ) ) )
2019-07-09 21:06:20 +02:00
}
2021-04-22 15:55:59 +02:00
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 )
}
2019-07-09 21:06:20 +02:00
// 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.
2019-08-31 00:12:07 +02:00
defaultFile = "~/.terraform/credentials.tfrc.json"
2019-07-09 21:06:20 +02:00
}
helpText := fmt . Sprintf ( `
2021-02-22 15:25:56 +01:00
Usage : terraform [ global options ] login [ hostname ]
2019-07-09 21:06:20 +02:00
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 .
2019-08-31 00:12:07 +02:00
If not overridden by credentials helper settings in the CLI configuration ,
the credentials will be written to the following local file :
2019-07-09 21:06:20 +02:00
% 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
}
2019-08-31 00:12:07 +02:00
return filepath . Join ( c . CLIConfigDir , "credentials.tfrc.json" )
2019-07-09 21:06:20 +02:00
}
2019-08-09 02:11:37 +02:00
func ( c * LoginCommand ) interactiveGetTokenByCode ( hostname svchost . Hostname , credsCtx * loginCredentialsContext , clientConfig * disco . OAuthClient ) ( * oauth2 . Token , tfdiags . Diagnostics ) {
2019-07-09 21:06:20 +02:00
var diags tfdiags . Diagnostics
2019-08-09 02:11:37 +02:00
confirm , confirmDiags := c . interactiveContextConsent ( hostname , disco . OAuthAuthzCodeGrant , credsCtx )
diags = diags . Append ( confirmDiags )
if ! confirm {
diags = diags . Append ( errors . New ( "Login cancelled" ) )
return nil , diags
}
2019-07-09 21:06:20 +02:00
// 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 ) {
2019-08-30 03:05:28 +02:00
log . Printf ( "[TRACE] login: request to callback server" )
2019-07-09 21:06:20 +02:00
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
}
2019-08-30 03:05:28 +02:00
log . Printf ( "[TRACE] login: request contains an authorization code" )
2019-07-09 21:06:20 +02:00
// Send the code to our blocking wait below, so that the token
// fetching process can continue.
codeCh <- gotCode
close ( codeCh )
2019-08-30 03:05:28 +02:00
log . Printf ( "[TRACE] login: returning response from callback server" )
2019-07-09 21:06:20 +02:00
resp . Header ( ) . Add ( "Content-Type" , "text/html" )
resp . WriteHeader ( 200 )
resp . Write ( [ ] byte ( callbackSuccessMessage ) )
} ) ,
}
go func ( ) {
2021-10-27 22:33:35 +02:00
defer logging . PanicHandler ( )
2020-04-08 16:12:46 +02:00
err := server . Serve ( listener )
2019-07-09 21:06:20 +02:00
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 ,
2020-09-14 18:21:20 +02:00
Scopes : clientConfig . Scopes ,
2019-07-09 21:06:20 +02:00
}
authCodeURL := oauthConfig . AuthCodeURL (
reqState ,
oauth2 . SetAuthURLParam ( "code_challenge" , proofKeyChallenge ) ,
oauth2 . SetAuthURLParam ( "code_challenge_method" , "S256" ) ,
)
2019-08-30 00:50:03 +02:00
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
}
2019-07-09 21:06:20 +02:00
} else {
2019-08-30 00:50:03 +02:00
launchBrowserManually = true
}
if launchBrowserManually {
2019-07-09 21:06:20 +02:00
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
}
2020-04-08 16:12:46 +02:00
if err := server . Close ( ) ; err != nil {
2019-07-09 21:06:20 +02:00
// 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
}
2019-08-09 02:11:37 +02:00
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
}
2019-08-22 00:37:36 +02:00
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" )
2019-08-21 01:10:37 +02:00
2020-06-24 20:47:59 +02:00
username , err := c . UIInput ( ) . Input ( context . Background ( ) , & terraform . InputOpts {
Id : "username" ,
Query : fmt . Sprintf ( "Username for %s:" , hostname . ForDisplay ( ) ) ,
} )
2019-08-21 01:10:37 +02:00
if err != nil {
diags = diags . Append ( fmt . Errorf ( "Failed to request username: %s" , err ) )
return nil , diags
}
2020-06-24 20:47:59 +02:00
password , err := c . UIInput ( ) . Input ( context . Background ( ) , & terraform . InputOpts {
Id : "password" ,
Query : fmt . Sprintf ( "Password for %s:" , hostname . ForDisplay ( ) ) ,
Secret : true ,
} )
2019-08-21 01:10:37 +02:00
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 ( ) ,
2020-09-16 01:32:41 +02:00
Scopes : clientConfig . Scopes ,
2019-08-21 01:10:37 +02:00
}
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
2019-08-09 02:11:37 +02:00
}
2020-01-24 20:43:15 +01:00
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
}
2020-06-24 20:47:59 +02:00
c . Ui . Output ( "\n---------------------------------------------------------------------------------\n" )
2020-01-24 20:43:15 +01:00
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 {
2020-06-24 20:47:59 +02:00
log . Printf ( "[DEBUG] error opening web browser: %s" , err )
2020-01-24 20:43:15 +01:00
// 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 ) )
}
}
2020-06-24 20:47:59 +02:00
token , err := c . UIInput ( ) . Input ( context . Background ( ) , & terraform . InputOpts {
Id : "token" ,
Query : fmt . Sprintf ( "Token for %s:" , hostname . ForDisplay ( ) ) ,
Secret : true ,
} )
2020-01-24 20:43:15 +01:00
if err != nil {
diags := diags . Append ( fmt . Errorf ( "Failed to retrieve token: %s" , err ) )
return "" , diags
}
2020-02-04 00:12:57 +01:00
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 ) )
2020-01-24 20:43:15 +01:00
return svcauth . HostCredentialsToken ( token ) , nil
}
2019-08-09 02:11:37 +02:00
func ( c * LoginCommand ) interactiveContextConsent ( hostname svchost . Hostname , grantType disco . OAuthGrantType , credsCtx * loginCredentialsContext ) ( bool , tfdiags . Diagnostics ) {
var diags tfdiags . Diagnostics
2020-01-24 20:43:15 +01:00
mechanism := "OAuth"
if grantType == "" {
mechanism = "your browser"
}
2019-08-09 02:11:37 +02:00
2020-01-24 20:43:15 +01:00
c . Ui . Output ( fmt . Sprintf ( "Terraform will request an API token for %s using %s.\n" , hostname . ForDisplay ( ) , mechanism ) )
2019-08-09 02:11:37 +02:00
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 ) )
}
}
2020-06-24 20:47:59 +02:00
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. ` ,
} )
2019-08-09 02:11:37 +02:00
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
}
2020-06-24 20:47:59 +02:00
return strings . ToLower ( v ) == "yes" , diags
2019-08-09 02:11:37 +02:00
}
2019-07-09 21:06:20 +02:00
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.
2020-05-05 18:58:48 +02:00
uu , err := uuid . GenerateUUID ( )
2019-07-09 21:06:20 +02:00
if err != nil {
return "" , "" , err
}
2020-05-05 18:58:48 +02:00
key = fmt . Sprintf ( "%s.%09d" , uu , rand . Intn ( 999999999 ) )
2019-07-09 21:06:20 +02:00
h := sha256 . New ( )
h . Write ( [ ] byte ( key ) )
2020-05-05 18:58:48 +02:00
challenge = base64 . RawURLEncoding . EncodeToString ( h . Sum ( nil ) )
2019-07-09 21:06:20 +02:00
return key , challenge , nil
}
2019-08-09 02:11:37 +02:00
type loginCredentialsContext struct {
Location cliconfig . CredentialsLocation
LocalFilename string
HelperType string
}
2019-07-09 21:06:20 +02:00
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 >
`