config.go

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