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(-) 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