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