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	"golang.org/x/crypto/ssh/terminal"
 20
 21	"github.com/MichaelMure/git-bug/bridge/core"
 22	"github.com/MichaelMure/git-bug/repository"
 23)
 24
 25const (
 26	githubV3Url = "https://api.github.com"
 27	keyOwner    = "owner"
 28	keyProject  = "project"
 29	keyToken    = "token"
 30
 31	defaultTimeout = 5 * time.Second
 32)
 33
 34var (
 35	rxGithubSplit = regexp.MustCompile(`github\.com\/([^\/]*)\/([^\/]*)`)
 36)
 37
 38func (*Github) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) {
 39	conf := make(core.Configuration)
 40	var err error
 41	var token string
 42	var owner string
 43	var project string
 44
 45	// getting owner and project name:
 46	// first use directly params if they are both provided, else try to parse
 47	// them from params URL, and finaly try getting them from terminal prompt
 48	if params.Owner != "" && params.Project != "" {
 49		owner = params.Owner
 50		project = params.Project
 51
 52	} else if params.URL != "" {
 53		owner, project, err = splitURL(params.URL)
 54		if err != nil {
 55			return nil, err
 56		}
 57
 58	} else {
 59		owner, project, err = promptURL()
 60		if err != nil {
 61			return nil, err
 62		}
 63	}
 64
 65	// validate project owner
 66	ok, err := validateUsername(owner)
 67	if err != nil {
 68		return nil, err
 69	}
 70	if !ok {
 71		return nil, fmt.Errorf("invalid parameter owner: %v", owner)
 72	}
 73
 74	// try to get token from params if provided, else use terminal prompt
 75	// to login and generate a token
 76	if params.Token != "" {
 77		token = params.Token
 78
 79	} else {
 80		fmt.Println()
 81		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.")
 82		fmt.Println()
 83		fmt.Println("Depending on your configuration the token will have one of the following scopes:")
 84		fmt.Println("  - 'user:email': to be able to read public-only users email")
 85		fmt.Println("  - 'repo'      : to be able to read private repositories")
 86		// fmt.Println("The token will have the \"repo\" permission, giving it read/write access to your repositories and issues. There is no narrower scope available, sorry :-|")
 87		fmt.Println()
 88
 89		isPublic, err := promptProjectVisibility()
 90		if err != nil {
 91			return nil, err
 92		}
 93
 94		username, err := promptUsername()
 95		if err != nil {
 96			return nil, err
 97		}
 98
 99		password, err := promptPassword()
100		if err != nil {
101			return nil, err
102		}
103
104		var scope string
105		if isPublic {
106			// user:email is requested to be able to read public emails
107			//     - a private email will stay private, even with this token
108			scope = "user:email"
109		} else {
110			// 'repo' is request to be able to read private repositories
111			// /!\ token will have read/write rights on every private repository you have access to
112			scope = "repo"
113		}
114
115		// Attempt to authenticate and create a token
116
117		note := fmt.Sprintf("git-bug - %s/%s", owner, project)
118
119		resp, err := requestToken(note, username, password, scope)
120		if err != nil {
121			return nil, err
122		}
123
124		defer resp.Body.Close()
125
126		// Handle 2FA is needed
127		OTPHeader := resp.Header.Get("X-GitHub-OTP")
128		if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
129			otpCode, err := prompt2FA()
130			if err != nil {
131				return nil, err
132			}
133
134			resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
135			if err != nil {
136				return nil, err
137			}
138
139			defer resp.Body.Close()
140		}
141
142		if resp.StatusCode == http.StatusCreated {
143			token, err = decodeBody(resp.Body)
144			if err != nil {
145				return nil, err
146			}
147
148		} else {
149			b, _ := ioutil.ReadAll(resp.Body)
150			return nil, fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
151		}
152	}
153
154	// verifying access to project with token
155	ok, err = validateProject(owner, project, token)
156	if err != nil {
157		return nil, err
158	}
159	if !ok {
160		return nil, fmt.Errorf("project doesn't exist or authentication token has a wrong scope")
161	}
162
163	conf[keyToken] = token
164	conf[keyOwner] = owner
165	conf[keyProject] = project
166
167	return conf, nil
168}
169
170func (*Github) ValidateConfig(conf core.Configuration) error {
171	if _, ok := conf[keyToken]; !ok {
172		return fmt.Errorf("missing %s key", keyToken)
173	}
174
175	if _, ok := conf[keyOwner]; !ok {
176		return fmt.Errorf("missing %s key", keyOwner)
177	}
178
179	if _, ok := conf[keyProject]; !ok {
180		return fmt.Errorf("missing %s key", keyProject)
181	}
182
183	return nil
184}
185
186func requestToken(note, username, password string, scope string) (*http.Response, error) {
187	return requestTokenWith2FA(note, username, password, "", scope)
188}
189
190func requestTokenWith2FA(note, username, password, otpCode string, scope string) (*http.Response, error) {
191	url := fmt.Sprintf("%s/authorizations", githubV3Url)
192	params := struct {
193		Scopes      []string `json:"scopes"`
194		Note        string   `json:"note"`
195		Fingerprint string   `json:"fingerprint"`
196	}{
197		Scopes:      []string{scope},
198		Note:        note,
199		Fingerprint: randomFingerprint(),
200	}
201
202	data, err := json.Marshal(params)
203	if err != nil {
204		return nil, err
205	}
206
207	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
208	if err != nil {
209		return nil, err
210	}
211
212	req.SetBasicAuth(username, password)
213	req.Header.Set("Content-Type", "application/json")
214
215	if otpCode != "" {
216		req.Header.Set("X-GitHub-OTP", otpCode)
217	}
218
219	client := &http.Client{
220		Timeout: defaultTimeout,
221	}
222
223	return client.Do(req)
224}
225
226func decodeBody(body io.ReadCloser) (string, error) {
227	data, _ := ioutil.ReadAll(body)
228
229	aux := struct {
230		Token string `json:"token"`
231	}{}
232
233	err := json.Unmarshal(data, &aux)
234	if err != nil {
235		return "", err
236	}
237
238	if aux.Token == "" {
239		return "", fmt.Errorf("no token found in response: %s", string(data))
240	}
241
242	return aux.Token, nil
243}
244
245func randomFingerprint() string {
246	// Doesn't have to be crypto secure, it's just to avoid token collision
247	rand.Seed(time.Now().UnixNano())
248	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
249	b := make([]rune, 32)
250	for i := range b {
251		b[i] = letterRunes[rand.Intn(len(letterRunes))]
252	}
253	return string(b)
254}
255
256func promptUsername() (string, error) {
257	for {
258		fmt.Print("username: ")
259
260		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
261		if err != nil {
262			return "", err
263		}
264
265		line = strings.TrimRight(line, "\n")
266
267		ok, err := validateUsername(line)
268		if err != nil {
269			return "", err
270		}
271		if ok {
272			return line, nil
273		}
274
275		fmt.Println("invalid username")
276	}
277}
278
279func promptURL() (string, string, error) {
280	for {
281		fmt.Print("Github project URL: ")
282
283		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
284		if err != nil {
285			return "", "", err
286		}
287
288		line = strings.TrimRight(line, "\n")
289		if line == "" {
290			fmt.Println("URL is empty")
291			continue
292		}
293
294		projectOwner, projectName, err := splitURL(line)
295		if err != nil {
296			fmt.Println(err)
297			continue
298		}
299
300		return projectOwner, projectName, nil
301	}
302}
303
304func splitURL(url string) (string, string, error) {
305	res := rxGithubSplit.FindStringSubmatch(url)
306	if res == nil {
307		return "", "", fmt.Errorf("bad github project url")
308	}
309
310	return res[1], res[2], nil
311}
312
313func validateUsername(username string) (bool, error) {
314	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
315
316	resp, err := http.Get(url)
317	if err != nil {
318		return false, err
319	}
320
321	err = resp.Body.Close()
322	if err != nil {
323		return false, err
324	}
325
326	return resp.StatusCode == http.StatusOK, nil
327}
328
329func validateProject(owner, project, token string) (bool, error) {
330	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
331
332	req, err := http.NewRequest("GET", url, nil)
333	if err != nil {
334		return false, err
335	}
336
337	req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
338
339	client := &http.Client{
340		Timeout: defaultTimeout,
341	}
342
343	resp, err := client.Do(req)
344	if err != nil {
345		return false, err
346	}
347
348	err = resp.Body.Close()
349	if err != nil {
350		return false, err
351	}
352
353	return resp.StatusCode == http.StatusOK, nil
354}
355
356func promptPassword() (string, error) {
357	for {
358		fmt.Print("password: ")
359
360		bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
361		// new line for coherent formatting, ReadPassword clip the normal new line
362		// entered by the user
363		fmt.Println()
364
365		if err != nil {
366			return "", err
367		}
368
369		if len(bytePassword) > 0 {
370			return string(bytePassword), nil
371		}
372
373		fmt.Println("password is empty")
374	}
375}
376
377func prompt2FA() (string, error) {
378	for {
379		fmt.Print("two-factor authentication code: ")
380
381		byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
382		fmt.Println()
383		if err != nil {
384			return "", err
385		}
386
387		if len(byte2fa) != 6 {
388			fmt.Println("invalid 2FA code size")
389			continue
390		}
391
392		str2fa := string(byte2fa)
393		_, err = strconv.Atoi(str2fa)
394		if err != nil {
395			fmt.Println("2fa code must be digits only")
396			continue
397		}
398
399		return str2fa, nil
400	}
401}
402
403func promptProjectVisibility() (bool, error) {
404	fmt.Println("[0]: public")
405	fmt.Println("[1]: private")
406
407	for {
408		fmt.Print("repository visibility type: ")
409
410		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
411		fmt.Println()
412		if err != nil {
413			return false, err
414		}
415
416		line = strings.TrimRight(line, "\n")
417
418		index, err := strconv.Atoi(line)
419		if err != nil || (index != 0 && index != 1) {
420			fmt.Println("invalid input")
421			continue
422		}
423
424		return index == 0, nil
425	}
426}