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