github.com/secman-team/gh-api@v1.8.2/pkg/cmd/auth/login/login.go (about)

     1  package login
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"net/http"
     8  	"strings"
     9  
    10  	"github.com/AlecAivazis/survey/v2"
    11  	"github.com/MakeNowJust/heredoc"
    12  	"github.com/secman-team/gh-api/core/config"
    13  	"github.com/secman-team/gh-api/core/ghinstance"
    14  	"github.com/secman-team/gh-api/pkg/cmd/auth/shared"
    15  	"github.com/secman-team/gh-api/pkg/cmdutil"
    16  	"github.com/secman-team/gh-api/pkg/iostreams"
    17  	"github.com/secman-team/gh-api/pkg/prompt"
    18  	"github.com/spf13/cobra"
    19  )
    20  
    21  type LoginOptions struct {
    22  	IO         *iostreams.IOStreams
    23  	Config     func() (config.Config, error)
    24  	HttpClient func() (*http.Client, error)
    25  
    26  	MainExecutable string
    27  
    28  	Interactive bool
    29  
    30  	Hostname string
    31  	Scopes   []string
    32  	Token    string
    33  	Web      bool
    34  }
    35  
    36  func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
    37  	opts := &LoginOptions{
    38  		IO:         f.IOStreams,
    39  		Config:     f.Config,
    40  		HttpClient: f.HttpClient,
    41  
    42  		MainExecutable: f.Executable,
    43  	}
    44  
    45  	var tokenStdin bool
    46  
    47  	cmd := &cobra.Command{
    48  		Use:   "login",
    49  		Args:  cobra.ExactArgs(0),
    50  		Short: "Authenticate with a GitHub host",
    51  		Long: heredoc.Docf(`
    52  			Authenticate with a GitHub host.
    53  
    54  			The default authentication mode is a web-based browser flow.
    55  
    56  			Alternatively, pass in a token on standard input by using %[1]s--with-token%[1]s.
    57  			The minimum required scopes for the token are: "repo", "read:org".
    58  
    59  			The --scopes flag accepts a comma separated list of scopes you want your gh credentials to have. If
    60  			absent, this command ensures that gh has access to a minimum set of scopes.
    61  		`, "`"),
    62  		Example: heredoc.Doc(`
    63  			# start interactive setup
    64  			$ gh auth login
    65  
    66  			# authenticate against github.com by reading the token from a file
    67  			$ gh auth login --with-token < mytoken.txt
    68  
    69  			# authenticate with a specific GitHub Enterprise Server instance
    70  			$ gh auth login --hostname enterprise.internal
    71  		`),
    72  		RunE: func(cmd *cobra.Command, args []string) error {
    73  			if !opts.IO.CanPrompt() && !(tokenStdin || opts.Web) {
    74  				return &cmdutil.FlagError{Err: errors.New("--web or --with-token required when not running interactively")}
    75  			}
    76  
    77  			if tokenStdin && opts.Web {
    78  				return &cmdutil.FlagError{Err: errors.New("specify only one of --web or --with-token")}
    79  			}
    80  
    81  			if tokenStdin {
    82  				defer opts.IO.In.Close()
    83  				token, err := ioutil.ReadAll(opts.IO.In)
    84  				if err != nil {
    85  					return fmt.Errorf("failed to read token from STDIN: %w", err)
    86  				}
    87  				opts.Token = strings.TrimSpace(string(token))
    88  			}
    89  
    90  			if opts.IO.CanPrompt() && opts.Token == "" && !opts.Web {
    91  				opts.Interactive = true
    92  			}
    93  
    94  			if cmd.Flags().Changed("hostname") {
    95  				if err := ghinstance.HostnameValidator(opts.Hostname); err != nil {
    96  					return &cmdutil.FlagError{Err: fmt.Errorf("error parsing --hostname: %w", err)}
    97  				}
    98  			}
    99  
   100  			if !opts.Interactive {
   101  				if opts.Hostname == "" {
   102  					opts.Hostname = ghinstance.Default()
   103  				}
   104  			}
   105  
   106  			if runF != nil {
   107  				return runF(opts)
   108  			}
   109  
   110  			return loginRun(opts)
   111  		},
   112  	}
   113  
   114  	cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to authenticate with")
   115  	cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have")
   116  	cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input")
   117  	cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate")
   118  
   119  	return cmd
   120  }
   121  
   122  func loginRun(opts *LoginOptions) error {
   123  	cfg, err := opts.Config()
   124  	if err != nil {
   125  		return err
   126  	}
   127  
   128  	hostname := opts.Hostname
   129  	if hostname == "" {
   130  		if opts.Interactive {
   131  			var err error
   132  			hostname, err = promptForHostname()
   133  			if err != nil {
   134  				return err
   135  			}
   136  		} else {
   137  			return errors.New("must specify --hostname")
   138  		}
   139  	}
   140  
   141  	if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil {
   142  		var roErr *config.ReadOnlyEnvError
   143  		if errors.As(err, &roErr) {
   144  			fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable)
   145  			fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n")
   146  			return cmdutil.SilentError
   147  		}
   148  		return err
   149  	}
   150  
   151  	httpClient, err := opts.HttpClient()
   152  	if err != nil {
   153  		return err
   154  	}
   155  
   156  	if opts.Token != "" {
   157  		err := cfg.Set(hostname, "oauth_token", opts.Token)
   158  		if err != nil {
   159  			return err
   160  		}
   161  
   162  		if err := shared.HasMinimumScopes(httpClient, hostname, opts.Token); err != nil {
   163  			return fmt.Errorf("error validating token: %w", err)
   164  		}
   165  
   166  		return cfg.Write()
   167  	}
   168  
   169  	existingToken, _ := cfg.Get(hostname, "oauth_token")
   170  	if existingToken != "" && opts.Interactive {
   171  		if err := shared.HasMinimumScopes(httpClient, hostname, existingToken); err == nil {
   172  			var keepGoing bool
   173  			err = prompt.SurveyAskOne(&survey.Confirm{
   174  				Message: fmt.Sprintf(
   175  					"You're already logged into %s. Do you want to re-authenticate?",
   176  					hostname),
   177  				Default: false,
   178  			}, &keepGoing)
   179  			if err != nil {
   180  				return fmt.Errorf("could not prompt: %w", err)
   181  			}
   182  			if !keepGoing {
   183  				return nil
   184  			}
   185  		}
   186  	}
   187  
   188  	return shared.Login(&shared.LoginOptions{
   189  		IO:          opts.IO,
   190  		Config:      cfg,
   191  		HTTPClient:  httpClient,
   192  		Hostname:    hostname,
   193  		Interactive: opts.Interactive,
   194  		Web:         opts.Web,
   195  		Scopes:      opts.Scopes,
   196  		Executable:  opts.MainExecutable,
   197  	})
   198  }
   199  
   200  func promptForHostname() (string, error) {
   201  	var hostType int
   202  	err := prompt.SurveyAskOne(&survey.Select{
   203  		Message: "What account do you want to log into?",
   204  		Options: []string{
   205  			"GitHub.com",
   206  			"GitHub Enterprise Server",
   207  		},
   208  	}, &hostType)
   209  
   210  	if err != nil {
   211  		return "", fmt.Errorf("could not prompt: %w", err)
   212  	}
   213  
   214  	isEnterprise := hostType == 1
   215  
   216  	hostname := ghinstance.Default()
   217  	if isEnterprise {
   218  		err := prompt.SurveyAskOne(&survey.Input{
   219  			Message: "GHE hostname:",
   220  		}, &hostname, survey.WithValidator(ghinstance.HostnameValidator))
   221  		if err != nil {
   222  			return "", fmt.Errorf("could not prompt: %w", err)
   223  		}
   224  	}
   225  
   226  	return hostname, nil
   227  }