config.go

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