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