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