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("[0]: user provided token")
201		fmt.Println("[1]: 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 != 0 && index != 1) {
214			fmt.Println("invalid input")
215			continue
216		}
217
218		if index == 0 {
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	for {
238		fmt.Print("Enter token: ")
239
240		byteToken, err := terminal.ReadPassword(int(syscall.Stdin))
241		fmt.Println()
242
243		if err != nil {
244			return "", err
245		}
246
247		if len(byteToken) > 0 {
248			return string(byteToken), nil
249		}
250
251		fmt.Println("token is empty")
252	}
253}
254
255func loginAndRequestToken(owner, project string) (string, error) {
256	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.")
257	fmt.Println()
258	fmt.Println("The access scope depend on the type of repository.")
259	fmt.Println("Public:")
260	fmt.Println("  - 'public_repo': to be able to read public repositories")
261	fmt.Println("Private:")
262	fmt.Println("  - 'repo'       : to be able to read private repositories")
263	fmt.Println()
264
265	// prompt project visibility to know the token scope needed for the repository
266	isPublic, err := promptProjectVisibility()
267	if err != nil {
268		return "", err
269	}
270
271	username, err := promptUsername()
272	if err != nil {
273		return "", err
274	}
275
276	password, err := promptPassword()
277	if err != nil {
278		return "", err
279	}
280
281	var scope string
282	if isPublic {
283		// public_repo is requested to be able to read public repositories
284		scope = "public_repo"
285	} else {
286		// 'repo' is request to be able to read private repositories
287		// /!\ token will have read/write rights on every private repository you have access to
288		scope = "repo"
289	}
290
291	// Attempt to authenticate and create a token
292
293	note := fmt.Sprintf("git-bug - %s/%s", owner, project)
294
295	resp, err := requestToken(note, username, password, scope)
296	if err != nil {
297		return "", err
298	}
299
300	defer resp.Body.Close()
301
302	// Handle 2FA is needed
303	OTPHeader := resp.Header.Get("X-GitHub-OTP")
304	if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
305		otpCode, err := prompt2FA()
306		if err != nil {
307			return "", err
308		}
309
310		resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
311		if err != nil {
312			return "", err
313		}
314
315		defer resp.Body.Close()
316	}
317
318	if resp.StatusCode == http.StatusCreated {
319		return decodeBody(resp.Body)
320	}
321
322	b, _ := ioutil.ReadAll(resp.Body)
323	return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
324}
325
326func promptUsername() (string, error) {
327	for {
328		fmt.Print("username: ")
329
330		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
331		if err != nil {
332			return "", err
333		}
334
335		line = strings.TrimRight(line, "\n")
336
337		ok, err := validateUsername(line)
338		if err != nil {
339			return "", err
340		}
341		if ok {
342			return line, nil
343		}
344
345		fmt.Println("invalid username")
346	}
347}
348
349func promptURL(remotes map[string]string) (string, string, error) {
350	validRemotes := getValidGithubRemoteURLs(remotes)
351	if len(validRemotes) > 0 {
352		for {
353			fmt.Println("\nDetected projects:")
354
355			// print valid remote github urls
356			for i, remote := range validRemotes {
357				fmt.Printf("[%d]: %v\n", i+1, remote)
358			}
359
360			fmt.Printf("\n[0]: Another project\n\n")
361			fmt.Printf("Select option: ")
362
363			line, err := bufio.NewReader(os.Stdin).ReadString('\n')
364			if err != nil {
365				return "", "", err
366			}
367
368			line = strings.TrimRight(line, "\n")
369
370			index, err := strconv.Atoi(line)
371			if err != nil || (index < 0 && index >= len(validRemotes)) {
372				fmt.Println("invalid input")
373				continue
374			}
375
376			// if user want to enter another project url break this loop
377			if index == 0 {
378				break
379			}
380
381			// get owner and project with index
382			owner, project, _ := splitURL(validRemotes[index-1])
383			return owner, project, nil
384		}
385	}
386
387	// manually enter github url
388	for {
389		fmt.Print("Github project URL: ")
390
391		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
392		if err != nil {
393			return "", "", err
394		}
395
396		line = strings.TrimRight(line, "\n")
397		if line == "" {
398			fmt.Println("URL is empty")
399			continue
400		}
401
402		// get owner and project from url
403		owner, project, err := splitURL(line)
404		if err != nil {
405			fmt.Println(err)
406			continue
407		}
408
409		return owner, project, nil
410	}
411}
412
413// splitURL extract the owner and project from a github repository URL. It will remove the
414// '.git' extension from the URL before parsing it.
415// Note that Github removes the '.git' extension from projects names at their creation
416func splitURL(url string) (owner string, project string, err error) {
417	cleanURL := strings.TrimSuffix(url, ".git")
418
419	re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
420	if err != nil {
421		panic("regexp compile:" + err.Error())
422	}
423
424	res := re.FindStringSubmatch(cleanURL)
425	if res == nil {
426		return "", "", ErrBadProjectURL
427	}
428
429	owner = res[1]
430	project = res[2]
431	return
432}
433
434func getValidGithubRemoteURLs(remotes map[string]string) []string {
435	urls := make([]string, 0, len(remotes))
436	for _, url := range remotes {
437		// split url can work again with shortURL
438		owner, project, err := splitURL(url)
439		if err == nil {
440			shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
441			urls = append(urls, shortURL)
442		}
443	}
444
445	return urls
446}
447
448func validateUsername(username string) (bool, error) {
449	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
450
451	client := &http.Client{
452		Timeout: defaultTimeout,
453	}
454
455	resp, err := client.Get(url)
456	if err != nil {
457		return false, err
458	}
459
460	err = resp.Body.Close()
461	if err != nil {
462		return false, err
463	}
464
465	return resp.StatusCode == http.StatusOK, nil
466}
467
468func validateProject(owner, project, token string) (bool, error) {
469	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
470
471	req, err := http.NewRequest("GET", url, nil)
472	if err != nil {
473		return false, err
474	}
475
476	// need the token for private repositories
477	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
478
479	client := &http.Client{
480		Timeout: defaultTimeout,
481	}
482
483	resp, err := client.Do(req)
484	if err != nil {
485		return false, err
486	}
487
488	err = resp.Body.Close()
489	if err != nil {
490		return false, err
491	}
492
493	return resp.StatusCode == http.StatusOK, nil
494}
495
496func promptPassword() (string, error) {
497	for {
498		fmt.Print("password: ")
499
500		bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
501		// new line for coherent formatting, ReadPassword clip the normal new line
502		// entered by the user
503		fmt.Println()
504
505		if err != nil {
506			return "", err
507		}
508
509		if len(bytePassword) > 0 {
510			return string(bytePassword), nil
511		}
512
513		fmt.Println("password is empty")
514	}
515}
516
517func prompt2FA() (string, error) {
518	for {
519		fmt.Print("two-factor authentication code: ")
520
521		byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
522		fmt.Println()
523		if err != nil {
524			return "", err
525		}
526
527		if len(byte2fa) > 0 {
528			return string(byte2fa), nil
529		}
530
531		fmt.Println("code is empty")
532	}
533}
534
535func promptProjectVisibility() (bool, error) {
536	for {
537		fmt.Println("[0]: public")
538		fmt.Println("[1]: private")
539		fmt.Print("repository visibility: ")
540
541		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
542		fmt.Println()
543		if err != nil {
544			return false, err
545		}
546
547		line = strings.TrimRight(line, "\n")
548
549		index, err := strconv.Atoi(line)
550		if err != nil || (index != 0 && index != 1) {
551			fmt.Println("invalid input")
552			continue
553		}
554
555		// return true for public repositories, false for private
556		return index == 0, nil
557	}
558}