github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/auth/shared/login_flow.go (about)

     1  package shared
     2  
     3  import (
     4  	"fmt"
     5  	"net/http"
     6  	"os"
     7  	"strings"
     8  
     9  	"github.com/MakeNowJust/heredoc"
    10  	"github.com/ungtb10d/cli/v2/api"
    11  	"github.com/ungtb10d/cli/v2/git"
    12  	"github.com/ungtb10d/cli/v2/internal/authflow"
    13  	"github.com/ungtb10d/cli/v2/internal/ghinstance"
    14  	"github.com/ungtb10d/cli/v2/pkg/cmd/ssh-key/add"
    15  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    16  	"github.com/ungtb10d/cli/v2/pkg/ssh"
    17  )
    18  
    19  const defaultSSHKeyTitle = "GitHub CLI"
    20  
    21  type iconfig interface {
    22  	Get(string, string) (string, error)
    23  	Set(string, string, string)
    24  	Write() error
    25  }
    26  
    27  type LoginOptions struct {
    28  	IO          *iostreams.IOStreams
    29  	Config      iconfig
    30  	HTTPClient  *http.Client
    31  	GitClient   *git.Client
    32  	Hostname    string
    33  	Interactive bool
    34  	Web         bool
    35  	Scopes      []string
    36  	Executable  string
    37  	GitProtocol string
    38  	Prompter    Prompt
    39  
    40  	sshContext ssh.Context
    41  }
    42  
    43  func Login(opts *LoginOptions) error {
    44  	cfg := opts.Config
    45  	hostname := opts.Hostname
    46  	httpClient := opts.HTTPClient
    47  	cs := opts.IO.ColorScheme()
    48  
    49  	gitProtocol := strings.ToLower(opts.GitProtocol)
    50  	if opts.Interactive && gitProtocol == "" {
    51  		options := []string{
    52  			"HTTPS",
    53  			"SSH",
    54  		}
    55  		result, err := opts.Prompter.Select(
    56  			"What is your preferred protocol for Git operations?",
    57  			options[0],
    58  			options)
    59  		if err != nil {
    60  			return err
    61  		}
    62  		proto := options[result]
    63  		gitProtocol = strings.ToLower(proto)
    64  	}
    65  
    66  	var additionalScopes []string
    67  
    68  	credentialFlow := &GitCredentialFlow{
    69  		Executable: opts.Executable,
    70  		Prompter:   opts.Prompter,
    71  		GitClient:  opts.GitClient,
    72  	}
    73  	if opts.Interactive && gitProtocol == "https" {
    74  		if err := credentialFlow.Prompt(hostname); err != nil {
    75  			return err
    76  		}
    77  		additionalScopes = append(additionalScopes, credentialFlow.Scopes()...)
    78  	}
    79  
    80  	var keyToUpload string
    81  	keyTitle := defaultSSHKeyTitle
    82  	if opts.Interactive && gitProtocol == "ssh" {
    83  		pubKeys, err := opts.sshContext.LocalPublicKeys()
    84  		if err != nil {
    85  			return err
    86  		}
    87  
    88  		if len(pubKeys) > 0 {
    89  			options := append(pubKeys, "Skip")
    90  			keyChoice, err := opts.Prompter.Select(
    91  				"Upload your SSH public key to your GitHub account?",
    92  				options[0],
    93  				options)
    94  			if err != nil {
    95  				return err
    96  			}
    97  			if keyChoice < len(pubKeys) {
    98  				keyToUpload = pubKeys[keyChoice]
    99  			}
   100  		} else if opts.sshContext.HasKeygen() {
   101  			sshChoice, err := opts.Prompter.Confirm("Generate a new SSH key to add to your GitHub account?", true)
   102  			if err != nil {
   103  				return err
   104  			}
   105  
   106  			if sshChoice {
   107  				passphrase, err := opts.Prompter.Password(
   108  					"Enter a passphrase for your new SSH key (Optional)")
   109  				if err != nil {
   110  					return err
   111  				}
   112  				keyPair, err := opts.sshContext.GenerateSSHKey("id_ed25519", passphrase)
   113  				if err != nil {
   114  					return err
   115  				}
   116  				keyToUpload = keyPair.PublicKeyPath
   117  			}
   118  		}
   119  
   120  		if keyToUpload != "" {
   121  			var err error
   122  			keyTitle, err = opts.Prompter.Input(
   123  				"Title for your SSH key:", defaultSSHKeyTitle)
   124  			if err != nil {
   125  				return err
   126  			}
   127  
   128  			additionalScopes = append(additionalScopes, "admin:public_key")
   129  		}
   130  	}
   131  
   132  	var authMode int
   133  	if opts.Web {
   134  		authMode = 0
   135  	} else if opts.Interactive {
   136  		options := []string{"Login with a web browser", "Paste an authentication token"}
   137  		var err error
   138  		authMode, err = opts.Prompter.Select(
   139  			"How would you like to authenticate GitHub CLI?",
   140  			options[0],
   141  			options)
   142  		if err != nil {
   143  			return err
   144  		}
   145  	}
   146  
   147  	var authToken string
   148  	userValidated := false
   149  
   150  	if authMode == 0 {
   151  		var err error
   152  		authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...), opts.Interactive)
   153  		if err != nil {
   154  			return fmt.Errorf("failed to authenticate via web browser: %w", err)
   155  		}
   156  		fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon())
   157  		userValidated = true
   158  	} else {
   159  		minimumScopes := append([]string{"repo", "read:org"}, additionalScopes...)
   160  		fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(`
   161  			Tip: you can generate a Personal Access Token here https://%s/settings/tokens
   162  			The minimum required scopes are %s.
   163  		`, hostname, scopesSentence(minimumScopes, ghinstance.IsEnterprise(hostname))))
   164  
   165  		authToken, err := opts.Prompter.AuthToken()
   166  		if err != nil {
   167  			return err
   168  		}
   169  
   170  		if err := HasMinimumScopes(httpClient, hostname, authToken); err != nil {
   171  			return fmt.Errorf("error validating token: %w", err)
   172  		}
   173  
   174  		cfg.Set(hostname, "oauth_token", authToken)
   175  	}
   176  
   177  	var username string
   178  	if userValidated {
   179  		username, _ = cfg.Get(hostname, "user")
   180  	} else {
   181  		apiClient := api.NewClientFromHTTP(httpClient)
   182  		var err error
   183  		username, err = api.CurrentLoginName(apiClient, hostname)
   184  		if err != nil {
   185  			return fmt.Errorf("error using api: %w", err)
   186  		}
   187  
   188  		cfg.Set(hostname, "user", username)
   189  	}
   190  
   191  	if gitProtocol != "" {
   192  		fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h %s git_protocol %s\n", hostname, gitProtocol)
   193  		cfg.Set(hostname, "git_protocol", gitProtocol)
   194  		fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", cs.SuccessIcon())
   195  	}
   196  
   197  	err := cfg.Write()
   198  	if err != nil {
   199  		return err
   200  	}
   201  
   202  	if credentialFlow.ShouldSetup() {
   203  		err := credentialFlow.Setup(hostname, username, authToken)
   204  		if err != nil {
   205  			return err
   206  		}
   207  	}
   208  
   209  	if keyToUpload != "" {
   210  		err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle)
   211  		if err != nil {
   212  			return err
   213  		}
   214  		fmt.Fprintf(opts.IO.ErrOut, "%s Uploaded the SSH key to your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload))
   215  	}
   216  
   217  	fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username))
   218  	return nil
   219  }
   220  
   221  func scopesSentence(scopes []string, isEnterprise bool) string {
   222  	quoted := make([]string, len(scopes))
   223  	for i, s := range scopes {
   224  		quoted[i] = fmt.Sprintf("'%s'", s)
   225  		if s == "workflow" && isEnterprise {
   226  			// remove when GHE 2.x reaches EOL
   227  			quoted[i] += " (GHE 3.0+)"
   228  		}
   229  	}
   230  	return strings.Join(quoted, ", ")
   231  }
   232  
   233  func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string) error {
   234  	f, err := os.Open(keyFile)
   235  	if err != nil {
   236  		return err
   237  	}
   238  	defer f.Close()
   239  
   240  	return add.SSHKeyUpload(httpClient, hostname, f, title)
   241  }