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