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