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, err := input.PromptCredentialWithInteractive(target, "token", creds)
256 switch err {
257 case nil:
258 return cred, nil
259 case input.ErrDirectPrompt:
260 return promptToken()
261 case input.ErrInteractiveCreation:
262 value, err := loginAndRequestToken(login, owner, project)
263 if err != nil {
264 return nil, err
265 }
266 token := auth.NewToken(target, value)
267 token.SetMetadata(auth.MetaKeyLogin, login)
268 return token, nil
269 default:
270 return nil, err
271 }
272}
273
274func promptToken() (*auth.Token, error) {
275 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
276 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
277 fmt.Println()
278 fmt.Println("The access scope depend on the type of repository.")
279 fmt.Println("Public:")
280 fmt.Println(" - 'public_repo': to be able to read public repositories")
281 fmt.Println("Private:")
282 fmt.Println(" - 'repo' : to be able to read private repositories")
283 fmt.Println()
284
285 re, err := regexp.Compile(`^[a-zA-Z0-9]{40}$`)
286 if err != nil {
287 panic("regexp compile:" + err.Error())
288 }
289
290 var login string
291
292 validator := func(name string, value string) (complaint string, err error) {
293 if !re.MatchString(value) {
294 return "token has incorrect format", nil
295 }
296 login, err = getLoginFromToken(auth.NewToken(target, value))
297 if err != nil {
298 return fmt.Sprintf("token is invalid: %v", err), nil
299 }
300 return "", nil
301 }
302
303 rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
304 if err != nil {
305 return nil, err
306 }
307
308 token := auth.NewToken(target, rawToken)
309 token.SetMetadata(auth.MetaKeyLogin, login)
310
311 return token, nil
312}
313
314func loginAndRequestToken(login, owner, project string) (string, error) {
315 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.")
316 fmt.Println()
317 fmt.Println("The access scope depend on the type of repository.")
318 fmt.Println("Public:")
319 fmt.Println(" - 'public_repo': to be able to read public repositories")
320 fmt.Println("Private:")
321 fmt.Println(" - 'repo' : to be able to read private repositories")
322 fmt.Println()
323
324 // prompt project visibility to know the token scope needed for the repository
325 i, err := input.PromptChoice("repository visibility", []string{"public", "private"})
326 if err != nil {
327 return "", err
328 }
329 isPublic := i == 0
330
331 password, err := input.PromptPassword("Password", "password", input.Required)
332 if err != nil {
333 return "", err
334 }
335
336 var scope string
337 if isPublic {
338 // public_repo is requested to be able to read public repositories
339 scope = "public_repo"
340 } else {
341 // 'repo' is request to be able to read private repositories
342 // /!\ token will have read/write rights on every private repository you have access to
343 scope = "repo"
344 }
345
346 // Attempt to authenticate and create a token
347
348 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
349
350 resp, err := requestToken(note, login, password, scope)
351 if err != nil {
352 return "", err
353 }
354
355 defer resp.Body.Close()
356
357 // Handle 2FA is needed
358 OTPHeader := resp.Header.Get("X-GitHub-OTP")
359 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
360 otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required)
361 if err != nil {
362 return "", err
363 }
364
365 resp, err = requestTokenWith2FA(note, login, password, otpCode, scope)
366 if err != nil {
367 return "", err
368 }
369
370 defer resp.Body.Close()
371 }
372
373 if resp.StatusCode == http.StatusCreated {
374 return decodeBody(resp.Body)
375 }
376
377 b, _ := ioutil.ReadAll(resp.Body)
378 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
379}
380
381func promptURL(repo repository.RepoCommon) (string, string, error) {
382 validRemotes, err := getValidGithubRemoteURLs(repo)
383 if err != nil {
384 return "", "", err
385 }
386
387 validator := func(name, value string) (string, error) {
388 _, _, err := splitURL(value)
389 if err != nil {
390 return err.Error(), nil
391 }
392 return "", nil
393 }
394
395 url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
396 if err != nil {
397 return "", "", err
398 }
399
400 return splitURL(url)
401}
402
403// splitURL extract the owner and project from a github repository URL. It will remove the
404// '.git' extension from the URL before parsing it.
405// Note that Github removes the '.git' extension from projects names at their creation
406func splitURL(url string) (owner string, project string, err error) {
407 cleanURL := strings.TrimSuffix(url, ".git")
408
409 re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
410
411 res := re.FindStringSubmatch(cleanURL)
412 if res == nil {
413 return "", "", ErrBadProjectURL
414 }
415
416 owner = res[1]
417 project = res[2]
418 return
419}
420
421func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
422 remotes, err := repo.GetRemotes()
423 if err != nil {
424 return nil, err
425 }
426
427 urls := make([]string, 0, len(remotes))
428 for _, url := range remotes {
429 // split url can work again with shortURL
430 owner, project, err := splitURL(url)
431 if err == nil {
432 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
433 urls = append(urls, shortURL)
434 }
435 }
436
437 sort.Strings(urls)
438
439 return urls, nil
440}
441
442func validateUsername(username string) (bool, error) {
443 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
444
445 client := &http.Client{
446 Timeout: defaultTimeout,
447 }
448
449 resp, err := client.Get(url)
450 if err != nil {
451 return false, err
452 }
453
454 err = resp.Body.Close()
455 if err != nil {
456 return false, err
457 }
458
459 return resp.StatusCode == http.StatusOK, nil
460}
461
462func validateProject(owner, project string, token *auth.Token) (bool, error) {
463 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
464
465 req, err := http.NewRequest("GET", url, nil)
466 if err != nil {
467 return false, err
468 }
469
470 // need the token for private repositories
471 req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
472
473 client := &http.Client{
474 Timeout: defaultTimeout,
475 }
476
477 resp, err := client.Do(req)
478 if err != nil {
479 return false, err
480 }
481
482 err = resp.Body.Close()
483 if err != nil {
484 return false, err
485 }
486
487 return resp.StatusCode == http.StatusOK, nil
488}
489
490func getLoginFromToken(token *auth.Token) (string, error) {
491 ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
492 defer cancel()
493
494 client := buildClient(token)
495
496 var q loginQuery
497
498 err := client.Query(ctx, &q, nil)
499 if err != nil {
500 return "", err
501 }
502 if q.Viewer.Login == "" {
503 return "", fmt.Errorf("github say username is empty")
504 }
505
506 return q.Viewer.Login, nil
507}