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