config.go

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