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
130	err = g.ValidateConfig(conf)
131	if err != nil {
132		return nil, err
133	}
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, core.FinishConfig(repo, metaKeyGithubLogin, login)
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[confKeyOwner]; !ok {
154		return fmt.Errorf("missing %s key", confKeyOwner)
155	}
156
157	if _, ok := conf[confKeyProject]; !ok {
158		return fmt.Errorf("missing %s key", confKeyProject)
159	}
160
161	return nil
162}
163
164func usernameValidator(_ string, value string) (string, error) {
165	ok, err := validateUsername(value)
166	if err != nil {
167		return "", err
168	}
169	if !ok {
170		return "invalid login", nil
171	}
172	return "", nil
173}
174
175func requestToken(note, login, password string, scope string) (*http.Response, error) {
176	return requestTokenWith2FA(note, login, password, "", scope)
177}
178
179func requestTokenWith2FA(note, login, password, otpCode string, scope string) (*http.Response, error) {
180	url := fmt.Sprintf("%s/authorizations", githubV3Url)
181	params := struct {
182		Scopes      []string `json:"scopes"`
183		Note        string   `json:"note"`
184		Fingerprint string   `json:"fingerprint"`
185	}{
186		Scopes:      []string{scope},
187		Note:        note,
188		Fingerprint: randomFingerprint(),
189	}
190
191	data, err := json.Marshal(params)
192	if err != nil {
193		return nil, err
194	}
195
196	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
197	if err != nil {
198		return nil, err
199	}
200
201	req.SetBasicAuth(login, password)
202	req.Header.Set("Content-Type", "application/json")
203
204	if otpCode != "" {
205		req.Header.Set("X-GitHub-OTP", otpCode)
206	}
207
208	client := &http.Client{
209		Timeout: defaultTimeout,
210	}
211
212	return client.Do(req)
213}
214
215func decodeBody(body io.ReadCloser) (string, error) {
216	data, _ := ioutil.ReadAll(body)
217
218	aux := struct {
219		Token string `json:"token"`
220	}{}
221
222	err := json.Unmarshal(data, &aux)
223	if err != nil {
224		return "", err
225	}
226
227	if aux.Token == "" {
228		return "", fmt.Errorf("no token found in response: %s", string(data))
229	}
230
231	return aux.Token, nil
232}
233
234func randomFingerprint() string {
235	// Doesn't have to be crypto secure, it's just to avoid token collision
236	rand.Seed(time.Now().UnixNano())
237	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
238	b := make([]rune, 32)
239	for i := range b {
240		b[i] = letterRunes[rand.Intn(len(letterRunes))]
241	}
242	return string(b)
243}
244
245func promptTokenOptions(repo repository.RepoConfig, login, owner, project string) (auth.Credential, error) {
246	creds, err := auth.List(repo,
247		auth.WithTarget(target),
248		auth.WithKind(auth.KindToken),
249		auth.WithMeta(auth.MetaKeyLogin, login),
250	)
251	if err != nil {
252		return nil, err
253	}
254
255	cred, err := input.PromptCredentialWithInteractive(target, "token", creds)
256	switch err {
257	case nil:
258		return cred, nil
259	case input.ErrDirectPrompt:
260		return promptToken()
261	case input.ErrInteractiveCreation:
262		value, err := loginAndRequestToken(login, owner, project)
263		if err != nil {
264			return nil, err
265		}
266		token := auth.NewToken(target, value)
267		token.SetMetadata(auth.MetaKeyLogin, login)
268		return token, nil
269	default:
270		return nil, err
271	}
272}
273
274func promptToken() (*auth.Token, error) {
275	fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
276	fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
277	fmt.Println()
278	fmt.Println("The access scope depend on the type of repository.")
279	fmt.Println("Public:")
280	fmt.Println("  - 'public_repo': to be able to read public repositories")
281	fmt.Println("Private:")
282	fmt.Println("  - 'repo'       : to be able to read private repositories")
283	fmt.Println()
284
285	re, err := regexp.Compile(`^[a-zA-Z0-9]{40}$`)
286	if err != nil {
287		panic("regexp compile:" + err.Error())
288	}
289
290	var login string
291
292	validator := func(name string, value string) (complaint string, err error) {
293		if !re.MatchString(value) {
294			return "token has incorrect format", nil
295		}
296		login, err = getLoginFromToken(auth.NewToken(target, value))
297		if err != nil {
298			return fmt.Sprintf("token is invalid: %v", err), nil
299		}
300		return "", nil
301	}
302
303	rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
304	if err != nil {
305		return nil, err
306	}
307
308	token := auth.NewToken(target, rawToken)
309	token.SetMetadata(auth.MetaKeyLogin, login)
310
311	return token, nil
312}
313
314func loginAndRequestToken(login, owner, project string) (string, error) {
315	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.")
316	fmt.Println()
317	fmt.Println("The access scope depend on the type of repository.")
318	fmt.Println("Public:")
319	fmt.Println("  - 'public_repo': to be able to read public repositories")
320	fmt.Println("Private:")
321	fmt.Println("  - 'repo'       : to be able to read private repositories")
322	fmt.Println()
323
324	// prompt project visibility to know the token scope needed for the repository
325	i, err := input.PromptChoice("repository visibility", []string{"public", "private"})
326	if err != nil {
327		return "", err
328	}
329	isPublic := i == 0
330
331	password, err := input.PromptPassword("Password", "password", input.Required)
332	if err != nil {
333		return "", err
334	}
335
336	var scope string
337	if isPublic {
338		// public_repo is requested to be able to read public repositories
339		scope = "public_repo"
340	} else {
341		// 'repo' is request to be able to read private repositories
342		// /!\ token will have read/write rights on every private repository you have access to
343		scope = "repo"
344	}
345
346	// Attempt to authenticate and create a token
347
348	note := fmt.Sprintf("git-bug - %s/%s", owner, project)
349
350	resp, err := requestToken(note, login, password, scope)
351	if err != nil {
352		return "", err
353	}
354
355	defer resp.Body.Close()
356
357	// Handle 2FA is needed
358	OTPHeader := resp.Header.Get("X-GitHub-OTP")
359	if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
360		otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required)
361		if err != nil {
362			return "", err
363		}
364
365		resp, err = requestTokenWith2FA(note, login, password, otpCode, scope)
366		if err != nil {
367			return "", err
368		}
369
370		defer resp.Body.Close()
371	}
372
373	if resp.StatusCode == http.StatusCreated {
374		return decodeBody(resp.Body)
375	}
376
377	b, _ := ioutil.ReadAll(resp.Body)
378	return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
379}
380
381func promptURL(repo repository.RepoCommon) (string, string, error) {
382	validRemotes, err := getValidGithubRemoteURLs(repo)
383	if err != nil {
384		return "", "", err
385	}
386
387	validator := func(name, value string) (string, error) {
388		_, _, err := splitURL(value)
389		if err != nil {
390			return err.Error(), nil
391		}
392		return "", nil
393	}
394
395	url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
396	if err != nil {
397		return "", "", err
398	}
399
400	return splitURL(url)
401}
402
403// splitURL extract the owner and project from a github repository URL. It will remove the
404// '.git' extension from the URL before parsing it.
405// Note that Github removes the '.git' extension from projects names at their creation
406func splitURL(url string) (owner string, project string, err error) {
407	cleanURL := strings.TrimSuffix(url, ".git")
408
409	re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
410
411	res := re.FindStringSubmatch(cleanURL)
412	if res == nil {
413		return "", "", ErrBadProjectURL
414	}
415
416	owner = res[1]
417	project = res[2]
418	return
419}
420
421func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
422	remotes, err := repo.GetRemotes()
423	if err != nil {
424		return nil, err
425	}
426
427	urls := make([]string, 0, len(remotes))
428	for _, url := range remotes {
429		// split url can work again with shortURL
430		owner, project, err := splitURL(url)
431		if err == nil {
432			shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
433			urls = append(urls, shortURL)
434		}
435	}
436
437	sort.Strings(urls)
438
439	return urls, nil
440}
441
442func validateUsername(username string) (bool, error) {
443	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
444
445	client := &http.Client{
446		Timeout: defaultTimeout,
447	}
448
449	resp, err := client.Get(url)
450	if err != nil {
451		return false, err
452	}
453
454	err = resp.Body.Close()
455	if err != nil {
456		return false, err
457	}
458
459	return resp.StatusCode == http.StatusOK, nil
460}
461
462func validateProject(owner, project string, token *auth.Token) (bool, error) {
463	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
464
465	req, err := http.NewRequest("GET", url, nil)
466	if err != nil {
467		return false, err
468	}
469
470	// need the token for private repositories
471	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
472
473	client := &http.Client{
474		Timeout: defaultTimeout,
475	}
476
477	resp, err := client.Do(req)
478	if err != nil {
479		return false, err
480	}
481
482	err = resp.Body.Close()
483	if err != nil {
484		return false, err
485	}
486
487	return resp.StatusCode == http.StatusOK, nil
488}
489
490func getLoginFromToken(token *auth.Token) (string, error) {
491	ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
492	defer cancel()
493
494	client := buildClient(token)
495
496	var q loginQuery
497
498	err := client.Query(ctx, &q, nil)
499	if err != nil {
500		return "", err
501	}
502	if q.Viewer.Login == "" {
503		return "", fmt.Errorf("github say username is empty")
504	}
505
506	return q.Viewer.Login, nil
507}