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