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