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