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