From dfa3a6d84908f81b624f6ff3cd50cbd0a00328dc Mon Sep 17 00:00:00 2001 From: Alexander Scharinger Date: Mon, 9 Nov 2020 22:15:46 +0100 Subject: Replace Github authorization endpoint by device authorization grant Fix issue #484 --- bridge/github/config.go | 194 +++++++++++++++++++++++------------------------- 1 file changed, 94 insertions(+), 100 deletions(-) (limited to 'bridge/github/config.go') diff --git a/bridge/github/config.go b/bridge/github/config.go index 130b0ad1..c0e176d3 100644 --- a/bridge/github/config.go +++ b/bridge/github/config.go @@ -1,14 +1,13 @@ package github import ( - "bytes" "context" "encoding/json" "fmt" - "io" "io/ioutil" "math/rand" "net/http" + "net/url" "regexp" "sort" "strings" @@ -25,6 +24,7 @@ import ( var ( ErrBadProjectURL = errors.New("bad project url") + GithubClientID = "ce3600aa56c2e69f18a5" ) func (g *Github) ValidParams() map[string]interface{} { @@ -169,63 +169,112 @@ func (*Github) ValidateConfig(conf core.Configuration) error { return nil } -func requestToken(note, login, password string, scope string) (*http.Response, error) { - return requestTokenWith2FA(note, login, password, "", scope) -} - -func requestTokenWith2FA(note, login, password, otpCode string, scope string) (*http.Response, error) { - url := fmt.Sprintf("%s/authorizations", githubV3Url) - params := struct { - Scopes []string `json:"scopes"` - Note string `json:"note"` - Fingerprint string `json:"fingerprint"` - }{ - Scopes: []string{scope}, - Note: note, - Fingerprint: randomFingerprint(), +func requestToken() (string, error) { + // prompt project visibility to know the token scope needed for the repository + index, err := input.PromptChoice("repository visibility", []string{"public", "private"}) + if err != nil { + return "", err } - - data, err := json.Marshal(params) + scope := []string{"public_repo", "repo"}[index] + // + resp, err := requestUserVerificationCode(scope) if err != nil { - return nil, err + return "", err } - - req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + defer resp.Body.Close() + data, err := ioutil.ReadAll(resp.Body) if err != nil { - return nil, err + return "", err } - - req.SetBasicAuth(login, password) - req.Header.Set("Content-Type", "application/json") - - if otpCode != "" { - req.Header.Set("X-GitHub-OTP", otpCode) + values, err := url.ParseQuery(string(data)) + if err != nil { + return "", err } + promptUserToGoToBrowser(values.Get("user_code")) + return pollGithubUntilUserAuthorizedGitbug(&values) +} +func requestUserVerificationCode(scope string) (*http.Response, error) { + params := url.Values{} + params.Set("client_id", GithubClientID) + params.Set("scope", scope) client := &http.Client{ Timeout: defaultTimeout, } - - return client.Do(req) + resp, err := client.PostForm("https://github.com/login/device/code", params) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + bb, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(bb)) + } + return resp, nil } -func decodeBody(body io.ReadCloser) (string, error) { - data, _ := ioutil.ReadAll(body) - - aux := struct { - Token string `json:"token"` - }{} +func promptUserToGoToBrowser(code string) { + fmt.Println("Please visit the following URL in a browser and enter the user authentication code.") + fmt.Println() + fmt.Println(" URL: https://github.com/login/device") + fmt.Println(" user authentiation code: ", code) + fmt.Println() +} - err := json.Unmarshal(data, &aux) - if err != nil { - return "", err +func pollGithubUntilUserAuthorizedGitbug(values1 *url.Values) (string, error) { + params := url.Values{} + params.Set("client_id", GithubClientID) + params.Set("device_code", values1.Get("device_code")) + params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") // fixed by RFC 8628 + client := &http.Client{ + Timeout: defaultTimeout, } - - if aux.Token == "" { - return "", fmt.Errorf("no token found in response: %s", string(data)) + // there exists a minimum interval required by the github API + var initialInterval time.Duration = 6 // seconds + var interval time.Duration = initialInterval + token := "" + for { + resp, err := client.PostForm("https://github.com/login/oauth/access_token", params) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + bb, _ := ioutil.ReadAll(resp.Body) + return "", fmt.Errorf("error creating token %v, %v", resp.StatusCode, string(bb)) + } + data2, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + values2, err := url.ParseQuery(string(data2)) + if err != nil { + return "", err + } + apiError := values2.Get("error") + if apiError != "" { + if apiError == "slow_down" { + interval *= 2 + } else { + interval = initialInterval + } + if apiError == "authorization_pending" || apiError == "slow_down" { + // no-op + } else { + // apiError equals on of: "expired_token", "unsupported_grant_type", + // "incorrect_client_credentials", "incorrect_device_code", or "access_denied" + return "", fmt.Errorf("error creating token %v, %v", apiError, values2.Get("error_description")) + } + time.Sleep(interval * time.Second) + continue + } + token = values2.Get("access_token") + if token == "" { + panic("invalid Github API response") + } + break } - - return aux.Token, nil + return token, nil } func randomFingerprint() string { @@ -261,7 +310,7 @@ func promptTokenOptions(repo repository.RepoKeyring, login, owner, project strin case index == 0: return promptToken() case index == 1: - value, err := loginAndRequestToken(login, owner, project) + value, err := requestToken() if err != nil { return nil, err } @@ -310,61 +359,6 @@ func promptToken() (*auth.Token, error) { return token, nil } -func loginAndRequestToken(login, owner, project string) (string, error) { - 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.") - fmt.Println() - fmt.Println("The access scope depend on the type of repository.") - fmt.Println("Public:") - fmt.Println(" - 'public_repo': to be able to read public repositories") - fmt.Println("Private:") - fmt.Println(" - 'repo' : to be able to read private repositories") - fmt.Println() - - // prompt project visibility to know the token scope needed for the repository - index, err := input.PromptChoice("repository visibility", []string{"public", "private"}) - if err != nil { - return "", err - } - scope := []string{"public_repo", "repo"}[index] - - password, err := input.PromptPassword("Password", "password", input.Required) - if err != nil { - return "", err - } - - // Attempt to authenticate and create a token - note := fmt.Sprintf("git-bug - %s/%s", owner, project) - resp, err := requestToken(note, login, password, scope) - if err != nil { - return "", err - } - - defer resp.Body.Close() - - // Handle 2FA is needed - OTPHeader := resp.Header.Get("X-GitHub-OTP") - if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" { - otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required) - if err != nil { - return "", err - } - - resp, err = requestTokenWith2FA(note, login, password, otpCode, scope) - if err != nil { - return "", err - } - - defer resp.Body.Close() - } - - if resp.StatusCode == http.StatusCreated { - return decodeBody(resp.Body) - } - - b, _ := ioutil.ReadAll(resp.Body) - return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b)) -} - func promptURL(repo repository.RepoCommon) (string, string, error) { validRemotes, err := getValidGithubRemoteURLs(repo) if err != nil { -- cgit From 1b889a91983598b99e2518543fe4b011b28fe074 Mon Sep 17 00:00:00 2001 From: Alexander Scharinger Date: Tue, 10 Nov 2020 23:50:38 +0100 Subject: Revision of Github bridge device authorization grant --- bridge/github/config.go | 131 ++++++++++++++++++++++++++---------------------- 1 file changed, 72 insertions(+), 59 deletions(-) (limited to 'bridge/github/config.go') diff --git a/bridge/github/config.go b/bridge/github/config.go index c0e176d3..2f9b8b0a 100644 --- a/bridge/github/config.go +++ b/bridge/github/config.go @@ -10,6 +10,7 @@ import ( "net/url" "regexp" "sort" + "strconv" "strings" "time" @@ -24,7 +25,7 @@ import ( var ( ErrBadProjectURL = errors.New("bad project url") - GithubClientID = "ce3600aa56c2e69f18a5" + githubClientID = "ce3600aa56c2e69f18a5" ) func (g *Github) ValidParams() map[string]interface{} { @@ -170,111 +171,123 @@ func (*Github) ValidateConfig(conf core.Configuration) error { } func requestToken() (string, error) { - // prompt project visibility to know the token scope needed for the repository - index, err := input.PromptChoice("repository visibility", []string{"public", "private"}) + scope, err := promptUserForProjectVisibility() if err != nil { - return "", err + return "", errors.WithStack(err) } - scope := []string{"public_repo", "repo"}[index] - // - resp, err := requestUserVerificationCode(scope) + ghResp, err := requestUserVerificationCode(scope) if err != nil { return "", err } - defer resp.Body.Close() - data, err := ioutil.ReadAll(resp.Body) + promptUserToGoToBrowser(ghResp["verification_uri"], ghResp["user_code"]) + interval, err := strconv.ParseInt(ghResp["interval"], 10, 64) // base 10, bitSize 64 if err != nil { - return "", err + return "", errors.Wrap(err, "Error parsing integer received from Github API") } - values, err := url.ParseQuery(string(data)) + return pollGithubForAuthorization(ghResp["device_code"], interval) +} + +func promptUserForProjectVisibility() (string, error) { + fmt.Println("git-bug will now generate an access token in your Github profile. The token is stored in the global git config.") + fmt.Println() + fmt.Println("The access scope depend on the type of repository.") + fmt.Println("Public:") + fmt.Println(" - 'public_repo': to be able to read public repositories") + fmt.Println("Private:") + fmt.Println(" - 'repo' : to be able to read private repositories") + fmt.Println() + index, err := input.PromptChoice("repository visibility", []string{"public", "private"}) if err != nil { return "", err } - promptUserToGoToBrowser(values.Get("user_code")) - return pollGithubUntilUserAuthorizedGitbug(&values) + return []string{"public_repo", "repo"}[index], nil } -func requestUserVerificationCode(scope string) (*http.Response, error) { +func requestUserVerificationCode(scope string) (map[string]string, error) { params := url.Values{} - params.Set("client_id", GithubClientID) + params.Set("client_id", githubClientID) params.Set("scope", scope) client := &http.Client{ Timeout: defaultTimeout, } resp, err := client.PostForm("https://github.com/login/device/code", params) if err != nil { - return nil, err + return nil, errors.Wrap(err, "error requesting user verification code") } + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - defer resp.Body.Close() - bb, _ := ioutil.ReadAll(resp.Body) - return nil, fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(bb)) + return nil, fmt.Errorf("unexpected response status code from Github API:", resp.StatusCode) } - return resp, nil + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "error requesting user verification code") + } + values, err := url.ParseQuery(string(data)) + if err != nil { + return nil, errors.Wrap(err, "error decoding Github API response") + } + result := map[string]string{"device_code": "", "user_code": "", "verification_uri": "", "interval": ""} + for key, _ := range result { + result[key] = values.Get(key) + } + return result, nil } -func promptUserToGoToBrowser(code string) { - fmt.Println("Please visit the following URL in a browser and enter the user authentication code.") +func promptUserToGoToBrowser(url, userCode string) { + fmt.Println("Please visit the following Github URL in a browser and enter your user authentication code.") fmt.Println() - fmt.Println(" URL: https://github.com/login/device") - fmt.Println(" user authentiation code: ", code) + fmt.Println(" URL:", url) + fmt.Println(" user authentiation code:", userCode) fmt.Println() } -func pollGithubUntilUserAuthorizedGitbug(values1 *url.Values) (string, error) { +func pollGithubForAuthorization(deviceCode string, intervalSec int64) (string, error) { params := url.Values{} - params.Set("client_id", GithubClientID) - params.Set("device_code", values1.Get("device_code")) + params.Set("client_id", githubClientID) + params.Set("device_code", deviceCode) params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") // fixed by RFC 8628 client := &http.Client{ Timeout: defaultTimeout, } - // there exists a minimum interval required by the github API - var initialInterval time.Duration = 6 // seconds - var interval time.Duration = initialInterval - token := "" + interval := time.Duration(intervalSec * 1100) // milliseconds, add 10% margin for { resp, err := client.PostForm("https://github.com/login/oauth/access_token", params) if err != nil { - return "", err + return "", errors.Wrap(err, "error polling the Github API") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - bb, _ := ioutil.ReadAll(resp.Body) - return "", fmt.Errorf("error creating token %v, %v", resp.StatusCode, string(bb)) + return "", fmt.Errorf("unexpected response status code from Github API:", resp.StatusCode) } - data2, err := ioutil.ReadAll(resp.Body) + data, err := ioutil.ReadAll(resp.Body) if err != nil { - return "", err + return "", errors.Wrap(err, "error polling the Github API") } - values2, err := url.ParseQuery(string(data2)) + values, err := url.ParseQuery(string(data)) if err != nil { - return "", err + return "", errors.Wrap(err, "error decoding Github API response") } - apiError := values2.Get("error") - if apiError != "" { - if apiError == "slow_down" { - interval *= 2 - } else { - interval = initialInterval - } - if apiError == "authorization_pending" || apiError == "slow_down" { - // no-op - } else { - // apiError equals on of: "expired_token", "unsupported_grant_type", - // "incorrect_client_credentials", "incorrect_device_code", or "access_denied" - return "", fmt.Errorf("error creating token %v, %v", apiError, values2.Get("error_description")) - } - time.Sleep(interval * time.Second) - continue + + if token := values.Get("access_token"); token != "" { + return token, nil } - token = values2.Get("access_token") - if token == "" { - panic("invalid Github API response") + + switch apiError := values.Get("error"); apiError { + case "slow_down": + interval += 5500 // add 5 seconds (RFC 8628), plus some margin + time.Sleep(interval * time.Millisecond) + continue + case "authorization_pending": + time.Sleep(interval * time.Millisecond) + continue + case "": + return "", errors.New("unexpected response from Github API") + default: + // apiError should equal one of: "expired_token", "unsupported_grant_type", + // "incorrect_client_credentials", "incorrect_device_code", or "access_denied" + return "", fmt.Errorf("error creating token: %v, %v", apiError, values.Get("error_description")) } - break } - return token, nil } func randomFingerprint() string { -- cgit From 09a845855f68f29db46e360380275471918f19c4 Mon Sep 17 00:00:00 2001 From: rng-dynamics <73444470+rng-dynamics@users.noreply.github.com> Date: Tue, 17 Nov 2020 16:01:45 +0100 Subject: Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michael Muré --- bridge/github/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'bridge/github/config.go') diff --git a/bridge/github/config.go b/bridge/github/config.go index 2f9b8b0a..1f9cd384 100644 --- a/bridge/github/config.go +++ b/bridge/github/config.go @@ -216,7 +216,7 @@ func requestUserVerificationCode(scope string) (map[string]string, error) { } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected response status code from Github API:", resp.StatusCode) + return nil, fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode) } data, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -257,7 +257,7 @@ func pollGithubForAuthorization(deviceCode string, intervalSec int64) (string, e } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("unexpected response status code from Github API:", resp.StatusCode) + return "", fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode) } data, err := ioutil.ReadAll(resp.Body) if err != nil { -- cgit From eded1f10c4ffd7bc893d8009883b404352861120 Mon Sep 17 00:00:00 2001 From: Alexander Scharinger Date: Tue, 17 Nov 2020 19:59:29 +0100 Subject: Change return type from map to struct --- bridge/github/config.go | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) (limited to 'bridge/github/config.go') diff --git a/bridge/github/config.go b/bridge/github/config.go index 1f9cd384..87d266da 100644 --- a/bridge/github/config.go +++ b/bridge/github/config.go @@ -170,21 +170,24 @@ func (*Github) ValidateConfig(conf core.Configuration) error { return nil } +type githRespT struct { + uri string + userCode string + deviceCode string + interval int64 +} + func requestToken() (string, error) { scope, err := promptUserForProjectVisibility() if err != nil { return "", errors.WithStack(err) } - ghResp, err := requestUserVerificationCode(scope) + resp, err := requestUserVerificationCode(scope) if err != nil { return "", err } - promptUserToGoToBrowser(ghResp["verification_uri"], ghResp["user_code"]) - interval, err := strconv.ParseInt(ghResp["interval"], 10, 64) // base 10, bitSize 64 - if err != nil { - return "", errors.Wrap(err, "Error parsing integer received from Github API") - } - return pollGithubForAuthorization(ghResp["device_code"], interval) + promptUserToGoToBrowser(resp.uri, resp.userCode) + return pollGithubForAuthorization(resp.deviceCode, resp.interval) } func promptUserForProjectVisibility() (string, error) { @@ -203,7 +206,7 @@ func promptUserForProjectVisibility() (string, error) { return []string{"public_repo", "repo"}[index], nil } -func requestUserVerificationCode(scope string) (map[string]string, error) { +func requestUserVerificationCode(scope string) (*githRespT, error) { params := url.Values{} params.Set("client_id", githubClientID) params.Set("scope", scope) @@ -222,15 +225,17 @@ func requestUserVerificationCode(scope string) (map[string]string, error) { if err != nil { return nil, errors.Wrap(err, "error requesting user verification code") } - values, err := url.ParseQuery(string(data)) + vals, err := url.ParseQuery(string(data)) if err != nil { return nil, errors.Wrap(err, "error decoding Github API response") } - result := map[string]string{"device_code": "", "user_code": "", "verification_uri": "", "interval": ""} - for key, _ := range result { - result[key] = values.Get(key) + interval, err := strconv.ParseInt(vals.Get("interval"), 10, 64) // base 10, bitSize 64 + if err != nil { + return nil, errors.Wrap(err, "Error parsing integer received from Github API") } - return result, nil + result := githRespT{uri: vals.Get("verification_uri"), userCode: vals.Get("user_code"), + deviceCode: vals.Get("device_code"), interval: interval} + return &result, nil } func promptUserToGoToBrowser(url, userCode string) { -- cgit