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 tokenAlreadyExist(repo repository.RepoCommon, id string) (bool, error) {
322	tokens, err := core.LoadTokens(repo)
323	if err != nil {
324		return false, err
325	}
326
327	for _, token := range tokens {
328		if token.Target == target && token.Value == id {
329			return true, nil
330		}
331	}
332
333	return false, nil
334}
335
336func promptToken() (string, error) {
337	fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
338	fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
339	fmt.Println()
340	fmt.Println("The access scope depend on the type of repository.")
341	fmt.Println("Public:")
342	fmt.Println("  - 'public_repo': to be able to read public repositories")
343	fmt.Println("Private:")
344	fmt.Println("  - 'repo'       : to be able to read private repositories")
345	fmt.Println()
346
347	re, err := regexp.Compile(`^[a-zA-Z0-9]{40}`)
348	if err != nil {
349		panic("regexp compile:" + err.Error())
350	}
351
352	for {
353		fmt.Print("Enter token: ")
354
355		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
356		if err != nil {
357			return "", err
358		}
359
360		token := strings.TrimRight(line, "\n")
361		if re.MatchString(token) {
362			return token, nil
363		}
364
365		fmt.Println("token is invalid")
366	}
367}
368
369func loginAndRequestToken(owner, project string) (string, error) {
370	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.")
371	fmt.Println()
372	fmt.Println("The access scope depend on the type of repository.")
373	fmt.Println("Public:")
374	fmt.Println("  - 'public_repo': to be able to read public repositories")
375	fmt.Println("Private:")
376	fmt.Println("  - 'repo'       : to be able to read private repositories")
377	fmt.Println()
378
379	// prompt project visibility to know the token scope needed for the repository
380	isPublic, err := promptProjectVisibility()
381	if err != nil {
382		return "", err
383	}
384
385	username, err := promptUsername()
386	if err != nil {
387		return "", err
388	}
389
390	password, err := promptPassword()
391	if err != nil {
392		return "", err
393	}
394
395	var scope string
396	if isPublic {
397		// public_repo is requested to be able to read public repositories
398		scope = "public_repo"
399	} else {
400		// 'repo' is request to be able to read private repositories
401		// /!\ token will have read/write rights on every private repository you have access to
402		scope = "repo"
403	}
404
405	// Attempt to authenticate and create a token
406
407	note := fmt.Sprintf("git-bug - %s/%s", owner, project)
408
409	resp, err := requestToken(note, username, password, scope)
410	if err != nil {
411		return "", err
412	}
413
414	defer resp.Body.Close()
415
416	// Handle 2FA is needed
417	OTPHeader := resp.Header.Get("X-GitHub-OTP")
418	if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
419		otpCode, err := prompt2FA()
420		if err != nil {
421			return "", err
422		}
423
424		resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
425		if err != nil {
426			return "", err
427		}
428
429		defer resp.Body.Close()
430	}
431
432	if resp.StatusCode == http.StatusCreated {
433		return decodeBody(resp.Body)
434	}
435
436	b, _ := ioutil.ReadAll(resp.Body)
437	return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
438}
439
440func promptUsername() (string, error) {
441	for {
442		fmt.Print("username: ")
443
444		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
445		if err != nil {
446			return "", err
447		}
448
449		line = strings.TrimRight(line, "\n")
450
451		ok, err := validateUsername(line)
452		if err != nil {
453			return "", err
454		}
455		if ok {
456			return line, nil
457		}
458
459		fmt.Println("invalid username")
460	}
461}
462
463func promptURL(remotes map[string]string) (string, string, error) {
464	validRemotes := getValidGithubRemoteURLs(remotes)
465	if len(validRemotes) > 0 {
466		for {
467			fmt.Println("\nDetected projects:")
468
469			// print valid remote github urls
470			for i, remote := range validRemotes {
471				fmt.Printf("[%d]: %v\n", i+1, remote)
472			}
473
474			fmt.Printf("\n[0]: Another project\n\n")
475			fmt.Printf("Select option: ")
476
477			line, err := bufio.NewReader(os.Stdin).ReadString('\n')
478			if err != nil {
479				return "", "", err
480			}
481
482			line = strings.TrimRight(line, "\n")
483
484			index, err := strconv.Atoi(line)
485			if err != nil || index < 0 || index > len(validRemotes) {
486				fmt.Println("invalid input")
487				continue
488			}
489
490			// if user want to enter another project url break this loop
491			if index == 0 {
492				break
493			}
494
495			// get owner and project with index
496			owner, project, _ := splitURL(validRemotes[index-1])
497			return owner, project, nil
498		}
499	}
500
501	// manually enter github url
502	for {
503		fmt.Print("Github project URL: ")
504
505		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
506		if err != nil {
507			return "", "", err
508		}
509
510		line = strings.TrimRight(line, "\n")
511		if line == "" {
512			fmt.Println("URL is empty")
513			continue
514		}
515
516		// get owner and project from url
517		owner, project, err := splitURL(line)
518		if err != nil {
519			fmt.Println(err)
520			continue
521		}
522
523		return owner, project, nil
524	}
525}
526
527// splitURL extract the owner and project from a github repository URL. It will remove the
528// '.git' extension from the URL before parsing it.
529// Note that Github removes the '.git' extension from projects names at their creation
530func splitURL(url string) (owner string, project string, err error) {
531	cleanURL := strings.TrimSuffix(url, ".git")
532
533	re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
534	if err != nil {
535		panic("regexp compile:" + err.Error())
536	}
537
538	res := re.FindStringSubmatch(cleanURL)
539	if res == nil {
540		return "", "", ErrBadProjectURL
541	}
542
543	owner = res[1]
544	project = res[2]
545	return
546}
547
548func getValidGithubRemoteURLs(remotes map[string]string) []string {
549	urls := make([]string, 0, len(remotes))
550	for _, url := range remotes {
551		// split url can work again with shortURL
552		owner, project, err := splitURL(url)
553		if err == nil {
554			shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
555			urls = append(urls, shortURL)
556		}
557	}
558
559	sort.Strings(urls)
560
561	return urls
562}
563
564func validateUsername(username string) (bool, error) {
565	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
566
567	client := &http.Client{
568		Timeout: defaultTimeout,
569	}
570
571	resp, err := client.Get(url)
572	if err != nil {
573		return false, err
574	}
575
576	err = resp.Body.Close()
577	if err != nil {
578		return false, err
579	}
580
581	return resp.StatusCode == http.StatusOK, nil
582}
583
584func validateProject(owner, project, token string) (bool, error) {
585	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
586
587	req, err := http.NewRequest("GET", url, nil)
588	if err != nil {
589		return false, err
590	}
591
592	// need the token for private repositories
593	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
594
595	client := &http.Client{
596		Timeout: defaultTimeout,
597	}
598
599	resp, err := client.Do(req)
600	if err != nil {
601		return false, err
602	}
603
604	err = resp.Body.Close()
605	if err != nil {
606		return false, err
607	}
608
609	return resp.StatusCode == http.StatusOK, nil
610}
611
612func promptPassword() (string, error) {
613	termState, err := terminal.GetState(int(syscall.Stdin))
614	if err != nil {
615		return "", err
616	}
617
618	cancel := interrupt.RegisterCleaner(func() error {
619		return terminal.Restore(int(syscall.Stdin), termState)
620	})
621	defer cancel()
622
623	for {
624		fmt.Print("password: ")
625
626		bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
627		// new line for coherent formatting, ReadPassword clip the normal new line
628		// entered by the user
629		fmt.Println()
630
631		if err != nil {
632			return "", err
633		}
634
635		if len(bytePassword) > 0 {
636			return string(bytePassword), nil
637		}
638
639		fmt.Println("password is empty")
640	}
641}
642
643func prompt2FA() (string, error) {
644	termState, err := terminal.GetState(int(syscall.Stdin))
645	if err != nil {
646		return "", err
647	}
648
649	cancel := interrupt.RegisterCleaner(func() error {
650		return terminal.Restore(int(syscall.Stdin), termState)
651	})
652	defer cancel()
653
654	for {
655		fmt.Print("two-factor authentication code: ")
656
657		byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
658		fmt.Println()
659		if err != nil {
660			return "", err
661		}
662
663		if len(byte2fa) > 0 {
664			return string(byte2fa), nil
665		}
666
667		fmt.Println("code is empty")
668	}
669}
670
671func promptProjectVisibility() (bool, error) {
672	for {
673		fmt.Println("[1]: public")
674		fmt.Println("[2]: private")
675		fmt.Print("repository visibility: ")
676
677		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
678		fmt.Println()
679		if err != nil {
680			return false, err
681		}
682
683		line = strings.TrimRight(line, "\n")
684
685		index, err := strconv.Atoi(line)
686		if err != nil || (index != 1 && index != 2) {
687			fmt.Println("invalid input")
688			continue
689		}
690
691		// return true for public repositories, false for private
692		return index == 1, nil
693	}
694}