config.go

  1package github
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"io/ioutil"
  8	"net/http"
  9	"net/url"
 10	"regexp"
 11	"sort"
 12	"strconv"
 13	"strings"
 14	"time"
 15
 16	"github.com/pkg/errors"
 17
 18	"github.com/MichaelMure/git-bug/bridge/core"
 19	"github.com/MichaelMure/git-bug/bridge/core/auth"
 20	"github.com/MichaelMure/git-bug/cache"
 21	"github.com/MichaelMure/git-bug/commands/input"
 22	"github.com/MichaelMure/git-bug/repository"
 23)
 24
 25const githubClientID = "ce3600aa56c2e69f18a5" // git-bug org
 26
 27var (
 28	ErrBadProjectURL = errors.New("bad project url")
 29)
 30
 31func (g *Github) ValidParams() map[string]interface{} {
 32	return map[string]interface{}{
 33		"URL":        nil,
 34		"Login":      nil,
 35		"CredPrefix": nil,
 36		"TokenRaw":   nil,
 37		"Owner":      nil,
 38		"Project":    nil,
 39	}
 40}
 41
 42func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams, interactive bool) (core.Configuration, error) {
 43	var err error
 44	var owner string
 45	var project string
 46	var ok bool
 47
 48	// getting owner and project name
 49	switch {
 50	case params.Owner != "" && params.Project != "":
 51		// first try to use params if both or project and owner are provided
 52		owner = params.Owner
 53		project = params.Project
 54	case params.URL != "":
 55		// try to parse params URL and extract owner and project
 56		owner, project, err = splitURL(params.URL)
 57		if err != nil {
 58			return nil, err
 59		}
 60	default:
 61		// terminal prompt
 62		if !interactive {
 63			return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the remote repository with --owner and --project, or via --url option.")
 64		}
 65		owner, project, err = promptURL(repo)
 66		if err != nil {
 67			return nil, err
 68		}
 69	}
 70
 71	// validate project owner and override with the correct case
 72	ok, owner, err = validateUsername(owner)
 73	if err != nil {
 74		return nil, err
 75	}
 76	if !ok {
 77		return nil, fmt.Errorf("invalid parameter owner: %v", owner)
 78	}
 79
 80	var login string
 81	var cred auth.Credential
 82
 83	switch {
 84	case params.CredPrefix != "":
 85		cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
 86		if err != nil {
 87			return nil, err
 88		}
 89		l, ok := cred.GetMetadata(auth.MetaKeyLogin)
 90		if !ok {
 91			return nil, fmt.Errorf("credential doesn't have a login")
 92		}
 93		login = l
 94	case params.TokenRaw != "":
 95		token := auth.NewToken(target, params.TokenRaw)
 96		login, err = getLoginFromToken(token)
 97		if err != nil {
 98			return nil, err
 99		}
100		token.SetMetadata(auth.MetaKeyLogin, login)
101		cred = token
102	default:
103		if params.Login == "" {
104			if !interactive {
105				return nil, fmt.Errorf("Non-interactive-mode is active. Please specify a login via the --login option.")
106			}
107			login, err = promptLogin()
108		} else {
109			// validate login and override with the correct case
110			ok, login, err = validateUsername(params.Login)
111			if !ok {
112				return nil, fmt.Errorf("invalid parameter login: %v", params.Login)
113			}
114		}
115		if err != nil {
116			return nil, err
117		}
118
119		if !interactive {
120			return nil, fmt.Errorf("Non-interactive-mode is active. Please specify a access token via the --token option.")
121		}
122		cred, err = promptTokenOptions(repo, login, owner, project)
123		if err != nil {
124			return nil, err
125		}
126	}
127
128	token, ok := cred.(*auth.Token)
129	if !ok {
130		return nil, fmt.Errorf("the Github bridge only handle token credentials")
131	}
132
133	// verify access to the repository with token
134	ok, err = validateProject(owner, project, token)
135	if err != nil {
136		return nil, err
137	}
138	if !ok {
139		return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope")
140	}
141
142	conf := make(core.Configuration)
143	conf[core.ConfigKeyTarget] = target
144	conf[confKeyOwner] = owner
145	conf[confKeyProject] = project
146	conf[confKeyDefaultLogin] = login
147
148	err = g.ValidateConfig(conf)
149	if err != nil {
150		return nil, err
151	}
152
153	// don't forget to store the now known valid token
154	if !auth.IdExist(repo, cred.ID()) {
155		err = auth.Store(repo, cred)
156		if err != nil {
157			return nil, err
158		}
159	}
160
161	return conf, core.FinishConfig(repo, metaKeyGithubLogin, login)
162}
163
164func (*Github) ValidateConfig(conf core.Configuration) error {
165	if v, ok := conf[core.ConfigKeyTarget]; !ok {
166		return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
167	} else if v != target {
168		return fmt.Errorf("unexpected target name: %v", v)
169	}
170	if _, ok := conf[confKeyOwner]; !ok {
171		return fmt.Errorf("missing %s key", confKeyOwner)
172	}
173	if _, ok := conf[confKeyProject]; !ok {
174		return fmt.Errorf("missing %s key", confKeyProject)
175	}
176	if _, ok := conf[confKeyDefaultLogin]; !ok {
177		return fmt.Errorf("missing %s key", confKeyDefaultLogin)
178	}
179
180	return nil
181}
182
183func requestToken() (string, error) {
184	scope, err := promptUserForProjectVisibility()
185	if err != nil {
186		return "", errors.WithStack(err)
187	}
188	resp, err := requestUserVerificationCode(scope)
189	if err != nil {
190		return "", err
191	}
192	promptUserToGoToBrowser(resp.uri, resp.userCode)
193	return pollGithubForAuthorization(resp.deviceCode, resp.interval)
194}
195
196func promptUserForProjectVisibility() (string, error) {
197	fmt.Println("git-bug will now generate an access token in your Github profile. The token is stored in the global git config.")
198	fmt.Println()
199	fmt.Println("The access scope depend on the type of repository.")
200	fmt.Println("Public:")
201	fmt.Println("  - 'public_repo': to be able to read public repositories")
202	fmt.Println("Private:")
203	fmt.Println("  - 'repo'       : to be able to read private repositories")
204	fmt.Println()
205	index, err := input.PromptChoice("repository visibility", []string{"public", "private"})
206	if err != nil {
207		return "", err
208	}
209	return []string{"public_repo", "repo"}[index], nil
210}
211
212type githRespT struct {
213	uri        string
214	userCode   string
215	deviceCode string
216	interval   int64
217}
218
219func requestUserVerificationCode(scope string) (*githRespT, error) {
220	params := url.Values{}
221	params.Set("client_id", githubClientID)
222	params.Set("scope", scope)
223	client := &http.Client{
224		Timeout: defaultTimeout,
225	}
226
227	resp, err := client.PostForm("https://github.com/login/device/code", params)
228	if err != nil {
229		return nil, errors.Wrap(err, "error requesting user verification code")
230	}
231	defer resp.Body.Close()
232	if resp.StatusCode != http.StatusOK {
233		return nil, fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode)
234	}
235
236	data, err := ioutil.ReadAll(resp.Body)
237	if err != nil {
238		return nil, errors.Wrap(err, "error requesting user verification code")
239	}
240
241	vals, err := url.ParseQuery(string(data))
242	if err != nil {
243		return nil, errors.Wrap(err, "error decoding Github API response")
244	}
245
246	interval, err := strconv.ParseInt(vals.Get("interval"), 10, 64) // base 10, bitSize 64
247	if err != nil {
248		return nil, errors.Wrap(err, "Error parsing integer received from Github API")
249	}
250
251	return &githRespT{
252		uri:        vals.Get("verification_uri"),
253		userCode:   vals.Get("user_code"),
254		deviceCode: vals.Get("device_code"),
255		interval:   interval,
256	}, nil
257}
258
259func promptUserToGoToBrowser(url, userCode string) {
260	fmt.Println("Please visit the following Github URL in a browser and enter your user authentication code.")
261	fmt.Println()
262	fmt.Println("  URL:", url)
263	fmt.Println("  user authentication code:", userCode)
264	fmt.Println()
265}
266
267func pollGithubForAuthorization(deviceCode string, intervalSec int64) (string, error) {
268	params := url.Values{}
269	params.Set("client_id", githubClientID)
270	params.Set("device_code", deviceCode)
271	params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") // fixed by RFC 8628
272	client := &http.Client{
273		Timeout: defaultTimeout,
274	}
275	interval := time.Duration(intervalSec * 1100) // milliseconds, add 10% margin
276
277	for {
278		resp, err := client.PostForm("https://github.com/login/oauth/access_token", params)
279		if err != nil {
280			fmt.Printf("error polling the Github API: %s\n", err)
281			time.Sleep(interval * time.Millisecond)
282			continue
283		}
284		if resp.StatusCode != http.StatusOK {
285			_ = resp.Body.Close()
286			return "", fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode)
287		}
288
289		data, err := ioutil.ReadAll(resp.Body)
290		if err != nil {
291			_ = resp.Body.Close()
292			fmt.Printf("error polling the Github API: %s\n", err)
293			time.Sleep(interval * time.Millisecond)
294			continue
295		}
296		_ = resp.Body.Close()
297
298		values, err := url.ParseQuery(string(data))
299		if err != nil {
300			fmt.Printf("error decoding Github API response: %s\n", err)
301			time.Sleep(interval * time.Millisecond)
302			continue
303		}
304
305		if token := values.Get("access_token"); token != "" {
306			return token, nil
307		}
308
309		switch apiError := values.Get("error"); apiError {
310		case "slow_down":
311			interval += 5500 // add 5 seconds (RFC 8628), plus some margin
312			time.Sleep(interval * time.Millisecond)
313			continue
314		case "authorization_pending":
315			time.Sleep(interval * time.Millisecond)
316			continue
317		case "":
318			return "", errors.New("unexpected response from Github API")
319		default:
320			// apiError should equal one of: "expired_token", "unsupported_grant_type",
321			// "incorrect_client_credentials", "incorrect_device_code", or "access_denied"
322			return "", fmt.Errorf("error creating token: %v, %v", apiError, values.Get("error_description"))
323		}
324	}
325}
326
327func promptTokenOptions(repo repository.RepoKeyring, login, owner, project string) (auth.Credential, error) {
328	creds, err := auth.List(repo,
329		auth.WithTarget(target),
330		auth.WithKind(auth.KindToken),
331		auth.WithMeta(auth.MetaKeyLogin, login),
332	)
333	if err != nil {
334		return nil, err
335	}
336
337	cred, index, err := input.PromptCredential(target, "token", creds, []string{
338		"enter my token",
339		"interactive token creation",
340	})
341	switch {
342	case err != nil:
343		return nil, err
344	case cred != nil:
345		return cred, nil
346	case index == 0:
347		return promptToken()
348	case index == 1:
349		value, err := requestToken()
350		if err != nil {
351			return nil, err
352		}
353		token := auth.NewToken(target, value)
354		token.SetMetadata(auth.MetaKeyLogin, login)
355		return token, nil
356	default:
357		panic("missed case")
358	}
359}
360
361func promptToken() (*auth.Token, error) {
362	fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
363	fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
364	fmt.Println()
365	fmt.Println("The access scope depend on the type of repository.")
366	fmt.Println("Public:")
367	fmt.Println("  - 'public_repo': to be able to read public repositories")
368	fmt.Println("Private:")
369	fmt.Println("  - 'repo'       : to be able to read private repositories")
370	fmt.Println()
371
372	legacyRe := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
373	re := regexp.MustCompile(`^(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,255}$`)
374
375	var login string
376
377	validator := func(name string, value string) (complaint string, err error) {
378		if !re.MatchString(value) && !legacyRe.MatchString(value) {
379			return "token has incorrect format", nil
380		}
381		login, err = getLoginFromToken(auth.NewToken(target, value))
382		if err != nil {
383			return fmt.Sprintf("token is invalid: %v", err), nil
384		}
385		return "", nil
386	}
387
388	rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
389	if err != nil {
390		return nil, err
391	}
392
393	token := auth.NewToken(target, rawToken)
394	token.SetMetadata(auth.MetaKeyLogin, login)
395
396	return token, nil
397}
398
399func promptURL(repo repository.RepoCommon) (string, string, error) {
400	validRemotes, err := getValidGithubRemoteURLs(repo)
401	if err != nil {
402		return "", "", err
403	}
404
405	validator := func(name, value string) (string, error) {
406		_, _, err := splitURL(value)
407		if err != nil {
408			return err.Error(), nil
409		}
410		return "", nil
411	}
412
413	url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
414	if err != nil {
415		return "", "", err
416	}
417
418	return splitURL(url)
419}
420
421// splitURL extract the owner and project from a github repository URL. It will remove the
422// '.git' extension from the URL before parsing it.
423// Note that Github removes the '.git' extension from projects names at their creation
424func splitURL(url string) (owner string, project string, err error) {
425	cleanURL := strings.TrimSuffix(url, ".git")
426
427	re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
428
429	res := re.FindStringSubmatch(cleanURL)
430	if res == nil {
431		return "", "", ErrBadProjectURL
432	}
433
434	owner = res[1]
435	project = res[2]
436	return
437}
438
439func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
440	remotes, err := repo.GetRemotes()
441	if err != nil {
442		return nil, err
443	}
444
445	urls := make([]string, 0, len(remotes))
446	for _, url := range remotes {
447		// split url can work again with shortURL
448		owner, project, err := splitURL(url)
449		if err == nil {
450			shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
451			urls = append(urls, shortURL)
452		}
453	}
454
455	sort.Strings(urls)
456
457	return urls, nil
458}
459
460func promptLogin() (string, error) {
461	var login string
462
463	validator := func(_ string, value string) (string, error) {
464		ok, fixed, err := validateUsername(value)
465		if err != nil {
466			return "", err
467		}
468		if !ok {
469			return "invalid login", nil
470		}
471		login = fixed
472		return "", nil
473	}
474
475	_, err := input.Prompt("Github login", "login", input.Required, validator)
476	if err != nil {
477		return "", err
478	}
479
480	return login, nil
481}
482
483func validateUsername(username string) (bool, string, error) {
484	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
485
486	client := &http.Client{
487		Timeout: defaultTimeout,
488	}
489
490	resp, err := client.Get(url)
491	if err != nil {
492		return false, "", err
493	}
494
495	if resp.StatusCode != http.StatusOK {
496		return false, "", nil
497	}
498
499	data, err := ioutil.ReadAll(resp.Body)
500	if err != nil {
501		return false, "", err
502	}
503
504	err = resp.Body.Close()
505	if err != nil {
506		return false, "", err
507	}
508
509	var decoded struct {
510		Login string `json:"login"`
511	}
512	err = json.Unmarshal(data, &decoded)
513	if err != nil {
514		return false, "", err
515	}
516
517	if decoded.Login == "" {
518		return false, "", fmt.Errorf("validateUsername: missing login in the response")
519	}
520
521	return true, decoded.Login, nil
522}
523
524func validateProject(owner, project string, token *auth.Token) (bool, error) {
525	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
526
527	req, err := http.NewRequest("GET", url, nil)
528	if err != nil {
529		return false, err
530	}
531
532	// need the token for private repositories
533	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
534
535	client := &http.Client{
536		Timeout: defaultTimeout,
537	}
538
539	resp, err := client.Do(req)
540	if err != nil {
541		return false, err
542	}
543
544	err = resp.Body.Close()
545	if err != nil {
546		return false, err
547	}
548
549	return resp.StatusCode == http.StatusOK, nil
550}
551
552func getLoginFromToken(token *auth.Token) (string, error) {
553	ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
554	defer cancel()
555
556	client := buildClient(token)
557
558	var q loginQuery
559
560	err := client.queryPrintMsgs(ctx, &q, nil)
561	if err != nil {
562		return "", err
563	}
564	if q.Viewer.Login == "" {
565		return "", fmt.Errorf("github say username is empty")
566	}
567
568	return q.Viewer.Login, nil
569}