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