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