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