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