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