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