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