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