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	"strings"
 15	"syscall"
 16	"time"
 17
 18	"github.com/MichaelMure/git-bug/bridge/core"
 19	"github.com/MichaelMure/git-bug/repository"
 20	"golang.org/x/crypto/ssh/terminal"
 21)
 22
 23const githubV3Url = "https://api.github.com"
 24const keyUser = "user"
 25const keyProject = "project"
 26const keyToken = "token"
 27
 28func (*Github) Configure(repo repository.RepoCommon) (core.Configuration, error) {
 29	conf := make(core.Configuration)
 30
 31	fmt.Println()
 32	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.")
 33	fmt.Println()
 34	fmt.Println("The token will have the following scopes:")
 35	fmt.Println("  - user:email: to be able to read public-only users email")
 36	// 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 :-|")
 37	fmt.Println()
 38
 39	projectUser, projectName, err := promptURL()
 40	if err != nil {
 41		return nil, err
 42	}
 43
 44	conf[keyUser] = projectUser
 45	conf[keyProject] = projectName
 46
 47	username, err := promptUsername()
 48	if err != nil {
 49		return nil, err
 50	}
 51
 52	password, err := promptPassword()
 53	if err != nil {
 54		return nil, err
 55	}
 56
 57	// Attempt to authenticate and create a token
 58
 59	note := fmt.Sprintf("git-bug - %s/%s", projectUser, projectName)
 60
 61	resp, err := requestToken(note, username, password)
 62	if err != nil {
 63		return nil, err
 64	}
 65
 66	defer resp.Body.Close()
 67
 68	// Handle 2FA is needed
 69	OTPHeader := resp.Header.Get("X-GitHub-OTP")
 70	if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
 71		otpCode, err := prompt2FA()
 72		if err != nil {
 73			return nil, err
 74		}
 75
 76		resp, err = requestTokenWith2FA(note, username, password, otpCode)
 77		if err != nil {
 78			return nil, err
 79		}
 80
 81		defer resp.Body.Close()
 82	}
 83
 84	if resp.StatusCode == http.StatusCreated {
 85		token, err := decodeBody(resp.Body)
 86		if err != nil {
 87			return nil, err
 88		}
 89		conf[keyToken] = token
 90		return conf, nil
 91	}
 92
 93	b, _ := ioutil.ReadAll(resp.Body)
 94	fmt.Printf("Error %v: %v\n", resp.StatusCode, string(b))
 95
 96	return nil, nil
 97}
 98
 99func (*Github) ValidateConfig(conf core.Configuration) error {
100	if _, ok := conf[keyToken]; !ok {
101		return fmt.Errorf("missing %s key", keyToken)
102	}
103
104	if _, ok := conf[keyUser]; !ok {
105		return fmt.Errorf("missing %s key", keyUser)
106	}
107
108	if _, ok := conf[keyProject]; !ok {
109		return fmt.Errorf("missing %s key", keyProject)
110	}
111
112	return nil
113}
114
115func requestToken(note, username, password string) (*http.Response, error) {
116	return requestTokenWith2FA(note, username, password, "")
117}
118
119func requestTokenWith2FA(note, username, password, otpCode string) (*http.Response, error) {
120	url := fmt.Sprintf("%s/authorizations", githubV3Url)
121	params := struct {
122		Scopes      []string `json:"scopes"`
123		Note        string   `json:"note"`
124		Fingerprint string   `json:"fingerprint"`
125	}{
126		// user:email is requested to be able to read public emails
127		//     - a private email will stay private, even with this token
128		Scopes:      []string{"user:email"},
129		Note:        note,
130		Fingerprint: randomFingerprint(),
131	}
132
133	data, err := json.Marshal(params)
134	if err != nil {
135		return nil, err
136	}
137
138	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
139	if err != nil {
140		return nil, err
141	}
142
143	req.SetBasicAuth(username, password)
144	req.Header.Set("Content-Type", "application/json")
145
146	if otpCode != "" {
147		req.Header.Set("X-GitHub-OTP", otpCode)
148	}
149
150	client := http.Client{}
151
152	return client.Do(req)
153}
154
155func decodeBody(body io.ReadCloser) (string, error) {
156	data, _ := ioutil.ReadAll(body)
157
158	aux := struct {
159		Token string `json:"token"`
160	}{}
161
162	err := json.Unmarshal(data, &aux)
163	if err != nil {
164		return "", err
165	}
166
167	if aux.Token == "" {
168		return "", fmt.Errorf("no token found in response: %s", string(data))
169	}
170
171	return aux.Token, nil
172}
173
174func randomFingerprint() string {
175	// Doesn't have to be crypto secure, it's just to avoid token collision
176	rand.Seed(time.Now().UnixNano())
177	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
178	b := make([]rune, 32)
179	for i := range b {
180		b[i] = letterRunes[rand.Intn(len(letterRunes))]
181	}
182	return string(b)
183}
184
185func promptUsername() (string, error) {
186	for {
187		fmt.Print("username: ")
188
189		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
190		if err != nil {
191			return "", err
192		}
193
194		line = strings.TrimRight(line, "\n")
195
196		ok, err := validateUsername(line)
197		if err != nil {
198			return "", err
199		}
200		if ok {
201			return line, nil
202		}
203
204		fmt.Println("invalid username")
205	}
206}
207
208func promptURL() (string, string, error) {
209	for {
210		fmt.Print("Github project URL: ")
211
212		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
213		if err != nil {
214			return "", "", err
215		}
216
217		line = strings.TrimRight(line, "\n")
218
219		if line == "" {
220			fmt.Println("URL is empty")
221			continue
222		}
223
224		projectUser, projectName, err := splitURL(line)
225
226		if err != nil {
227			fmt.Println(err)
228			continue
229		}
230
231		return projectUser, projectName, nil
232	}
233}
234
235func splitURL(url string) (string, string, error) {
236	re, err := regexp.Compile(`github\.com\/([^\/]*)\/([^\/]*)`)
237	if err != nil {
238		panic(err)
239	}
240
241	res := re.FindStringSubmatch(url)
242
243	if res == nil {
244		return "", "", fmt.Errorf("bad github project url")
245	}
246
247	return res[1], res[2], nil
248}
249
250func validateUsername(username string) (bool, error) {
251	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
252
253	resp, err := http.Get(url)
254	if err != nil {
255		return false, err
256	}
257
258	err = resp.Body.Close()
259	if err != nil {
260		return false, err
261	}
262
263	return resp.StatusCode == http.StatusOK, nil
264}
265
266func promptPassword() (string, error) {
267	for {
268		fmt.Print("password: ")
269
270		bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
271		// new line for coherent formatting, ReadPassword clip the normal new line
272		// entered by the user
273		fmt.Println()
274
275		if err != nil {
276			return "", err
277		}
278
279		if len(bytePassword) > 0 {
280			return string(bytePassword), nil
281		}
282
283		fmt.Println("password is empty")
284	}
285}
286
287func prompt2FA() (string, error) {
288	for {
289		fmt.Print("two-factor authentication code: ")
290
291		byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
292		if err != nil {
293			return "", err
294		}
295
296		if len(byte2fa) > 0 {
297			return string(byte2fa), nil
298		}
299
300		fmt.Println("code is empty")
301	}
302}