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