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