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