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 authentiation 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	re := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
369
370	var login string
371
372	validator := func(name string, value string) (complaint string, err error) {
373		if !re.MatchString(value) {
374			return "token has incorrect format", nil
375		}
376		login, err = getLoginFromToken(auth.NewToken(target, value))
377		if err != nil {
378			return fmt.Sprintf("token is invalid: %v", err), nil
379		}
380		return "", nil
381	}
382
383	rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
384	if err != nil {
385		return nil, err
386	}
387
388	token := auth.NewToken(target, rawToken)
389	token.SetMetadata(auth.MetaKeyLogin, login)
390
391	return token, nil
392}
393
394func promptURL(repo repository.RepoCommon) (string, string, error) {
395	validRemotes, err := getValidGithubRemoteURLs(repo)
396	if err != nil {
397		return "", "", err
398	}
399
400	validator := func(name, value string) (string, error) {
401		_, _, err := splitURL(value)
402		if err != nil {
403			return err.Error(), nil
404		}
405		return "", nil
406	}
407
408	url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
409	if err != nil {
410		return "", "", err
411	}
412
413	return splitURL(url)
414}
415
416// splitURL extract the owner and project from a github repository URL. It will remove the
417// '.git' extension from the URL before parsing it.
418// Note that Github removes the '.git' extension from projects names at their creation
419func splitURL(url string) (owner string, project string, err error) {
420	cleanURL := strings.TrimSuffix(url, ".git")
421
422	re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
423
424	res := re.FindStringSubmatch(cleanURL)
425	if res == nil {
426		return "", "", ErrBadProjectURL
427	}
428
429	owner = res[1]
430	project = res[2]
431	return
432}
433
434func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
435	remotes, err := repo.GetRemotes()
436	if err != nil {
437		return nil, err
438	}
439
440	urls := make([]string, 0, len(remotes))
441	for _, url := range remotes {
442		// split url can work again with shortURL
443		owner, project, err := splitURL(url)
444		if err == nil {
445			shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
446			urls = append(urls, shortURL)
447		}
448	}
449
450	sort.Strings(urls)
451
452	return urls, nil
453}
454
455func promptLogin() (string, error) {
456	var login string
457
458	validator := func(_ string, value string) (string, error) {
459		ok, fixed, err := validateUsername(value)
460		if err != nil {
461			return "", err
462		}
463		if !ok {
464			return "invalid login", nil
465		}
466		login = fixed
467		return "", nil
468	}
469
470	_, err := input.Prompt("Github login", "login", input.Required, validator)
471	if err != nil {
472		return "", err
473	}
474
475	return login, nil
476}
477
478func validateUsername(username string) (bool, string, error) {
479	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
480
481	client := &http.Client{
482		Timeout: defaultTimeout,
483	}
484
485	resp, err := client.Get(url)
486	if err != nil {
487		return false, "", err
488	}
489
490	if resp.StatusCode != http.StatusOK {
491		return false, "", nil
492	}
493
494	data, err := ioutil.ReadAll(resp.Body)
495	if err != nil {
496		return false, "", err
497	}
498
499	err = resp.Body.Close()
500	if err != nil {
501		return false, "", err
502	}
503
504	var decoded struct {
505		Login string `json:"login"`
506	}
507	err = json.Unmarshal(data, &decoded)
508	if err != nil {
509		return false, "", err
510	}
511
512	if decoded.Login == "" {
513		return false, "", fmt.Errorf("validateUsername: missing login in the response")
514	}
515
516	return true, decoded.Login, nil
517}
518
519func validateProject(owner, project string, token *auth.Token) (bool, error) {
520	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
521
522	req, err := http.NewRequest("GET", url, nil)
523	if err != nil {
524		return false, err
525	}
526
527	// need the token for private repositories
528	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
529
530	client := &http.Client{
531		Timeout: defaultTimeout,
532	}
533
534	resp, err := client.Do(req)
535	if err != nil {
536		return false, err
537	}
538
539	err = resp.Body.Close()
540	if err != nil {
541		return false, err
542	}
543
544	return resp.StatusCode == http.StatusOK, nil
545}
546
547func getLoginFromToken(token *auth.Token) (string, error) {
548	ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
549	defer cancel()
550
551	client := buildClient(token)
552
553	var q loginQuery
554
555	err := client.Query(ctx, &q, nil)
556	if err != nil {
557		return "", err
558	}
559	if q.Viewer.Login == "" {
560		return "", fmt.Errorf("github say username is empty")
561	}
562
563	return q.Viewer.Login, nil
564}