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