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