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 := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
293
294	var login string
295
296	validator := func(name string, value string) (complaint string, err error) {
297		if !re.MatchString(value) {
298			return "token has incorrect format", nil
299		}
300		login, err = getLoginFromToken(auth.NewToken(target, value))
301		if err != nil {
302			return fmt.Sprintf("token is invalid: %v", err), nil
303		}
304		return "", nil
305	}
306
307	rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
308	if err != nil {
309		return nil, err
310	}
311
312	token := auth.NewToken(target, rawToken)
313	token.SetMetadata(auth.MetaKeyLogin, login)
314
315	return token, nil
316}
317
318func loginAndRequestToken(login, owner, project string) (string, error) {
319	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.")
320	fmt.Println()
321	fmt.Println("The access scope depend on the type of repository.")
322	fmt.Println("Public:")
323	fmt.Println("  - 'public_repo': to be able to read public repositories")
324	fmt.Println("Private:")
325	fmt.Println("  - 'repo'       : to be able to read private repositories")
326	fmt.Println()
327
328	// prompt project visibility to know the token scope needed for the repository
329	index, err := input.PromptChoice("repository visibility", []string{"public", "private"})
330	if err != nil {
331		return "", err
332	}
333	scope := []string{"public_repo", "repo"}[index]
334
335	password, err := input.PromptPassword("Password", "password", input.Required)
336	if err != nil {
337		return "", err
338	}
339
340	// Attempt to authenticate and create a token
341	note := fmt.Sprintf("git-bug - %s/%s", owner, project)
342	resp, err := requestToken(note, login, password, scope)
343	if err != nil {
344		return "", err
345	}
346
347	defer resp.Body.Close()
348
349	// Handle 2FA is needed
350	OTPHeader := resp.Header.Get("X-GitHub-OTP")
351	if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
352		otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required)
353		if err != nil {
354			return "", err
355		}
356
357		resp, err = requestTokenWith2FA(note, login, password, otpCode, scope)
358		if err != nil {
359			return "", err
360		}
361
362		defer resp.Body.Close()
363	}
364
365	if resp.StatusCode == http.StatusCreated {
366		return decodeBody(resp.Body)
367	}
368
369	b, _ := ioutil.ReadAll(resp.Body)
370	return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
371}
372
373func promptURL(repo repository.RepoCommon) (string, string, error) {
374	validRemotes, err := getValidGithubRemoteURLs(repo)
375	if err != nil {
376		return "", "", err
377	}
378
379	validator := func(name, value string) (string, error) {
380		_, _, err := splitURL(value)
381		if err != nil {
382			return err.Error(), nil
383		}
384		return "", nil
385	}
386
387	url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
388	if err != nil {
389		return "", "", err
390	}
391
392	return splitURL(url)
393}
394
395// splitURL extract the owner and project from a github repository URL. It will remove the
396// '.git' extension from the URL before parsing it.
397// Note that Github removes the '.git' extension from projects names at their creation
398func splitURL(url string) (owner string, project string, err error) {
399	cleanURL := strings.TrimSuffix(url, ".git")
400
401	re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
402
403	res := re.FindStringSubmatch(cleanURL)
404	if res == nil {
405		return "", "", ErrBadProjectURL
406	}
407
408	owner = res[1]
409	project = res[2]
410	return
411}
412
413func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
414	remotes, err := repo.GetRemotes()
415	if err != nil {
416		return nil, err
417	}
418
419	urls := make([]string, 0, len(remotes))
420	for _, url := range remotes {
421		// split url can work again with shortURL
422		owner, project, err := splitURL(url)
423		if err == nil {
424			shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
425			urls = append(urls, shortURL)
426		}
427	}
428
429	sort.Strings(urls)
430
431	return urls, nil
432}
433
434func validateUsername(username string) (bool, error) {
435	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
436
437	client := &http.Client{
438		Timeout: defaultTimeout,
439	}
440
441	resp, err := client.Get(url)
442	if err != nil {
443		return false, err
444	}
445
446	err = resp.Body.Close()
447	if err != nil {
448		return false, err
449	}
450
451	return resp.StatusCode == http.StatusOK, nil
452}
453
454func validateProject(owner, project string, token *auth.Token) (bool, error) {
455	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
456
457	req, err := http.NewRequest("GET", url, nil)
458	if err != nil {
459		return false, err
460	}
461
462	// need the token for private repositories
463	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
464
465	client := &http.Client{
466		Timeout: defaultTimeout,
467	}
468
469	resp, err := client.Do(req)
470	if err != nil {
471		return false, err
472	}
473
474	err = resp.Body.Close()
475	if err != nil {
476		return false, err
477	}
478
479	return resp.StatusCode == http.StatusOK, nil
480}
481
482func getLoginFromToken(token *auth.Token) (string, error) {
483	ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
484	defer cancel()
485
486	client := buildClient(token)
487
488	var q loginQuery
489
490	err := client.Query(ctx, &q, nil)
491	if err != nil {
492		return "", err
493	}
494	if q.Viewer.Login == "" {
495		return "", fmt.Errorf("github say username is empty")
496	}
497
498	return q.Viewer.Login, nil
499}