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