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