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