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