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}