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