1package github
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "io/ioutil"
10 "math/rand"
11 "net/http"
12 "regexp"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/pkg/errors"
18
19 "github.com/MichaelMure/git-bug/bridge/core"
20 "github.com/MichaelMure/git-bug/bridge/core/auth"
21 "github.com/MichaelMure/git-bug/cache"
22 "github.com/MichaelMure/git-bug/input"
23 "github.com/MichaelMure/git-bug/repository"
24)
25
26var (
27 ErrBadProjectURL = errors.New("bad project url")
28)
29
30func (g *Github) ValidParams() map[string]interface{} {
31 return map[string]interface{}{
32 "URL": nil,
33 "Login": nil,
34 "CredPrefix": nil,
35 "TokenRaw": nil,
36 "Owner": nil,
37 "Project": nil,
38 }
39}
40
41func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
42 var err error
43 var owner string
44 var project string
45 var ok bool
46
47 // getting owner and project name
48 switch {
49 case params.Owner != "" && params.Project != "":
50 // first try to use params if both or project and owner are provided
51 owner = params.Owner
52 project = params.Project
53 case 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 default:
60 // terminal prompt
61 owner, project, err = promptURL(repo)
62 if err != nil {
63 return nil, err
64 }
65 }
66
67 // validate project owner and override with the correct case
68 ok, owner, err = validateUsername(owner)
69 if err != nil {
70 return nil, err
71 }
72 if !ok {
73 return nil, fmt.Errorf("invalid parameter owner: %v", owner)
74 }
75
76 var login string
77 var cred auth.Credential
78
79 switch {
80 case params.CredPrefix != "":
81 cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
82 if err != nil {
83 return nil, err
84 }
85 l, ok := cred.GetMetadata(auth.MetaKeyLogin)
86 if !ok {
87 return nil, fmt.Errorf("credential doesn't have a login")
88 }
89 login = l
90 case params.TokenRaw != "":
91 token := auth.NewToken(target, params.TokenRaw)
92 login, err = getLoginFromToken(token)
93 if err != nil {
94 return nil, err
95 }
96 token.SetMetadata(auth.MetaKeyLogin, login)
97 cred = token
98 default:
99 if params.Login == "" {
100 login, err = promptLogin()
101 } else {
102 // validate login and override with the correct case
103 ok, login, err = validateUsername(params.Login)
104 if !ok {
105 return nil, fmt.Errorf("invalid parameter login: %v", params.Login)
106 }
107 }
108 if err != nil {
109 return nil, err
110 }
111 cred, err = promptTokenOptions(repo, login, owner, project)
112 if err != nil {
113 return nil, err
114 }
115 }
116
117 token, ok := cred.(*auth.Token)
118 if !ok {
119 return nil, fmt.Errorf("the Github bridge only handle token credentials")
120 }
121
122 // verify access to the repository with token
123 ok, err = validateProject(owner, project, token)
124 if err != nil {
125 return nil, err
126 }
127 if !ok {
128 return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope")
129 }
130
131 conf := make(core.Configuration)
132 conf[core.ConfigKeyTarget] = target
133 conf[confKeyOwner] = owner
134 conf[confKeyProject] = project
135 conf[confKeyDefaultLogin] = login
136
137 err = g.ValidateConfig(conf)
138 if err != nil {
139 return nil, err
140 }
141
142 // don't forget to store the now known valid token
143 if !auth.IdExist(repo, cred.ID()) {
144 err = auth.Store(repo, cred)
145 if err != nil {
146 return nil, err
147 }
148 }
149
150 return conf, core.FinishConfig(repo, metaKeyGithubLogin, login)
151}
152
153func (*Github) ValidateConfig(conf core.Configuration) error {
154 if v, ok := conf[core.ConfigKeyTarget]; !ok {
155 return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
156 } else if v != target {
157 return fmt.Errorf("unexpected target name: %v", v)
158 }
159 if _, ok := conf[confKeyOwner]; !ok {
160 return fmt.Errorf("missing %s key", confKeyOwner)
161 }
162 if _, ok := conf[confKeyProject]; !ok {
163 return fmt.Errorf("missing %s key", confKeyProject)
164 }
165 if _, ok := conf[confKeyDefaultLogin]; !ok {
166 return fmt.Errorf("missing %s key", confKeyDefaultLogin)
167 }
168
169 return nil
170}
171
172func requestToken(note, login, password string, scope string) (*http.Response, error) {
173 return requestTokenWith2FA(note, login, password, "", scope)
174}
175
176func requestTokenWith2FA(note, login, password, otpCode string, scope string) (*http.Response, error) {
177 url := fmt.Sprintf("%s/authorizations", githubV3Url)
178 params := struct {
179 Scopes []string `json:"scopes"`
180 Note string `json:"note"`
181 Fingerprint string `json:"fingerprint"`
182 }{
183 Scopes: []string{scope},
184 Note: note,
185 Fingerprint: randomFingerprint(),
186 }
187
188 data, err := json.Marshal(params)
189 if err != nil {
190 return nil, err
191 }
192
193 req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
194 if err != nil {
195 return nil, err
196 }
197
198 req.SetBasicAuth(login, password)
199 req.Header.Set("Content-Type", "application/json")
200
201 if otpCode != "" {
202 req.Header.Set("X-GitHub-OTP", otpCode)
203 }
204
205 client := &http.Client{
206 Timeout: defaultTimeout,
207 }
208
209 return client.Do(req)
210}
211
212func decodeBody(body io.ReadCloser) (string, error) {
213 data, _ := ioutil.ReadAll(body)
214
215 aux := struct {
216 Token string `json:"token"`
217 }{}
218
219 err := json.Unmarshal(data, &aux)
220 if err != nil {
221 return "", err
222 }
223
224 if aux.Token == "" {
225 return "", fmt.Errorf("no token found in response: %s", string(data))
226 }
227
228 return aux.Token, nil
229}
230
231func randomFingerprint() string {
232 // Doesn't have to be crypto secure, it's just to avoid token collision
233 rand.Seed(time.Now().UnixNano())
234 var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
235 b := make([]rune, 32)
236 for i := range b {
237 b[i] = letterRunes[rand.Intn(len(letterRunes))]
238 }
239 return string(b)
240}
241
242func promptTokenOptions(repo repository.RepoConfig, login, owner, project string) (auth.Credential, error) {
243 creds, err := auth.List(repo,
244 auth.WithTarget(target),
245 auth.WithKind(auth.KindToken),
246 auth.WithMeta(auth.MetaKeyLogin, login),
247 )
248 if err != nil {
249 return nil, err
250 }
251
252 cred, index, err := input.PromptCredential(target, "token", creds, []string{
253 "enter my token",
254 "interactive token creation",
255 })
256 switch {
257 case err != nil:
258 return nil, err
259 case cred != nil:
260 return cred, nil
261 case index == 0:
262 return promptToken()
263 case index == 1:
264 value, err := loginAndRequestToken(login, owner, project)
265 if err != nil {
266 return nil, err
267 }
268 token := auth.NewToken(target, value)
269 token.SetMetadata(auth.MetaKeyLogin, login)
270 return token, nil
271 default:
272 panic("missed case")
273 }
274}
275
276func promptToken() (*auth.Token, error) {
277 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
278 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
279 fmt.Println()
280 fmt.Println("The access scope depend on the type of repository.")
281 fmt.Println("Public:")
282 fmt.Println(" - 'public_repo': to be able to read public repositories")
283 fmt.Println("Private:")
284 fmt.Println(" - 'repo' : to be able to read private repositories")
285 fmt.Println()
286
287 re := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
288
289 var login string
290
291 validator := func(name string, value string) (complaint string, err error) {
292 if !re.MatchString(value) {
293 return "token has incorrect format", nil
294 }
295 login, err = getLoginFromToken(auth.NewToken(target, value))
296 if err != nil {
297 return fmt.Sprintf("token is invalid: %v", err), nil
298 }
299 return "", nil
300 }
301
302 rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
303 if err != nil {
304 return nil, err
305 }
306
307 token := auth.NewToken(target, rawToken)
308 token.SetMetadata(auth.MetaKeyLogin, login)
309
310 return token, nil
311}
312
313func loginAndRequestToken(login, owner, project string) (string, error) {
314 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 global git config.")
315 fmt.Println()
316 fmt.Println("The access scope depend on the type of repository.")
317 fmt.Println("Public:")
318 fmt.Println(" - 'public_repo': to be able to read public repositories")
319 fmt.Println("Private:")
320 fmt.Println(" - 'repo' : to be able to read private repositories")
321 fmt.Println()
322
323 // prompt project visibility to know the token scope needed for the repository
324 index, err := input.PromptChoice("repository visibility", []string{"public", "private"})
325 if err != nil {
326 return "", err
327 }
328 scope := []string{"public_repo", "repo"}[index]
329
330 password, err := input.PromptPassword("Password", "password", input.Required)
331 if err != nil {
332 return "", err
333 }
334
335 // Attempt to authenticate and create a token
336 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
337 resp, err := requestToken(note, login, password, scope)
338 if err != nil {
339 return "", err
340 }
341
342 defer resp.Body.Close()
343
344 // Handle 2FA is needed
345 OTPHeader := resp.Header.Get("X-GitHub-OTP")
346 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
347 otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required)
348 if err != nil {
349 return "", err
350 }
351
352 resp, err = requestTokenWith2FA(note, login, password, otpCode, scope)
353 if err != nil {
354 return "", err
355 }
356
357 defer resp.Body.Close()
358 }
359
360 if resp.StatusCode == http.StatusCreated {
361 return decodeBody(resp.Body)
362 }
363
364 b, _ := ioutil.ReadAll(resp.Body)
365 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
366}
367
368func promptURL(repo repository.RepoCommon) (string, string, error) {
369 validRemotes, err := getValidGithubRemoteURLs(repo)
370 if err != nil {
371 return "", "", err
372 }
373
374 validator := func(name, value string) (string, error) {
375 _, _, err := splitURL(value)
376 if err != nil {
377 return err.Error(), nil
378 }
379 return "", nil
380 }
381
382 url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
383 if err != nil {
384 return "", "", err
385 }
386
387 return splitURL(url)
388}
389
390// splitURL extract the owner and project from a github repository URL. It will remove the
391// '.git' extension from the URL before parsing it.
392// Note that Github removes the '.git' extension from projects names at their creation
393func splitURL(url string) (owner string, project string, err error) {
394 cleanURL := strings.TrimSuffix(url, ".git")
395
396 re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
397
398 res := re.FindStringSubmatch(cleanURL)
399 if res == nil {
400 return "", "", ErrBadProjectURL
401 }
402
403 owner = res[1]
404 project = res[2]
405 return
406}
407
408func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
409 remotes, err := repo.GetRemotes()
410 if err != nil {
411 return nil, err
412 }
413
414 urls := make([]string, 0, len(remotes))
415 for _, url := range remotes {
416 // split url can work again with shortURL
417 owner, project, err := splitURL(url)
418 if err == nil {
419 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
420 urls = append(urls, shortURL)
421 }
422 }
423
424 sort.Strings(urls)
425
426 return urls, nil
427}
428
429func promptLogin() (string, error) {
430 var login string
431
432 validator := func(_ string, value string) (string, error) {
433 ok, fixed, err := validateUsername(value)
434 if err != nil {
435 return "", err
436 }
437 if !ok {
438 return "invalid login", nil
439 }
440 login = fixed
441 return "", nil
442 }
443
444 _, err := input.Prompt("Github login", "login", input.Required, validator)
445 if err != nil {
446 return "", err
447 }
448
449 return login, nil
450}
451
452func validateUsername(username string) (bool, string, error) {
453 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
454
455 client := &http.Client{
456 Timeout: defaultTimeout,
457 }
458
459 resp, err := client.Get(url)
460 if err != nil {
461 return false, "", err
462 }
463
464 if resp.StatusCode != http.StatusOK {
465 return false, "", nil
466 }
467
468 data, err := ioutil.ReadAll(resp.Body)
469 if err != nil {
470 return false, "", err
471 }
472
473 err = resp.Body.Close()
474 if err != nil {
475 return false, "", err
476 }
477
478 var decoded struct {
479 Login string `json:"login"`
480 }
481 err = json.Unmarshal(data, &decoded)
482 if err != nil {
483 return false, "", err
484 }
485
486 if decoded.Login == "" {
487 return false, "", fmt.Errorf("validateUsername: missing login in the response")
488 }
489
490 return true, decoded.Login, nil
491}
492
493func validateProject(owner, project string, token *auth.Token) (bool, error) {
494 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
495
496 req, err := http.NewRequest("GET", url, nil)
497 if err != nil {
498 return false, err
499 }
500
501 // need the token for private repositories
502 req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
503
504 client := &http.Client{
505 Timeout: defaultTimeout,
506 }
507
508 resp, err := client.Do(req)
509 if err != nil {
510 return false, err
511 }
512
513 err = resp.Body.Close()
514 if err != nil {
515 return false, err
516 }
517
518 return resp.StatusCode == http.StatusOK, nil
519}
520
521func getLoginFromToken(token *auth.Token) (string, error) {
522 ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
523 defer cancel()
524
525 client := buildClient(token)
526
527 var q loginQuery
528
529 err := client.Query(ctx, &q, nil)
530 if err != nil {
531 return "", err
532 }
533 if q.Viewer.Login == "" {
534 return "", fmt.Errorf("github say username is empty")
535 }
536
537 return q.Viewer.Login, nil
538}