config.go

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