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