diff --git a/README.md b/README.md index dbf32c5..b9ae82f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ func main() { See the [oauth2 docs][] for complete instructions on using that library. +For API methods that require HTTP Basic Authentication, use the +[`BasicAuthTransport`](https://godoc.org/github.com/google/go-github/github#BasicAuthTransport). + ### Pagination ### All requests for resource collections (repos, pull requests, issues, etc) diff --git a/examples/basicauth/main.go b/examples/basicauth/main.go new file mode 100644 index 0000000..124520f --- /dev/null +++ b/examples/basicauth/main.go @@ -0,0 +1,53 @@ +// Copyright 2015 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// The basicauth command demonstrates using the github.BasicAuthTransport, +// including handling two-factor authentication. This won't currently work for +// accounts that use SMS to receive one-time passwords. +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "syscall" + + "github.com/google/go-github/github" + "golang.org/x/crypto/ssh/terminal" +) + +func main() { + r := bufio.NewReader(os.Stdin) + fmt.Print("GitHub Username: ") + username, _ := r.ReadString('\n') + + fmt.Print("GitHub Password: ") + bytePassword, _ := terminal.ReadPassword(int(syscall.Stdin)) + password := string(bytePassword) + + tp := github.BasicAuthTransport{ + Username: strings.TrimSpace(username), + Password: strings.TrimSpace(password), + } + + client := github.NewClient(tp.Client()) + user, _, err := client.Users.Get("") + + // Is this a two-factor auth error? If so, prompt for OTP and try again. + if _, ok := err.(*github.TwoFactorAuthError); err != nil && ok { + fmt.Print("\nGitHub OTP: ") + otp, _ := r.ReadString('\n') + tp.OTP = strings.TrimSpace(otp) + user, _, err = client.Users.Get("") + } + + if err != nil { + fmt.Printf("\nerror: %v\n", err) + return + } + + fmt.Printf("\n%v\n", github.Stringify(user)) +} diff --git a/github/github.go b/github/github.go index 7e9471b..e007455 100644 --- a/github/github.go +++ b/github/github.go @@ -32,6 +32,7 @@ const ( headerRateLimit = "X-RateLimit-Limit" headerRateRemaining = "X-RateLimit-Remaining" headerRateReset = "X-RateLimit-Reset" + headerOTP = "X-GitHub-OTP" mediaTypeV3 = "application/vnd.github.v3+json" defaultMediaType = "application/octet-stream" @@ -357,8 +358,15 @@ func (r *ErrorResponse) Error() string { r.Response.StatusCode, r.Message, r.Errors) } -// sanitizeURL redacts the client_id and client_secret tokens from the URL which -// may be exposed to the user, specifically in the ErrorResponse error message. +// TwoFactorAuthError occurs when using HTTP Basic Authentication for a user +// that has two-factor authentication enabled. The request can be reattempted +// by providing a one-time password in the request. +type TwoFactorAuthError ErrorResponse + +func (r *TwoFactorAuthError) Error() string { return (*ErrorResponse)(r).Error() } + +// sanitizeURL redacts the client_secret parameter from the URL which may be +// exposed to the user, specifically in the ErrorResponse error message. func sanitizeURL(uri *url.URL) *url.URL { if uri == nil { return nil @@ -411,6 +419,9 @@ func CheckResponse(r *http.Response) error { if err == nil && data != nil { json.Unmarshal(data, errorResponse) } + if r.StatusCode == http.StatusUnauthorized && strings.HasPrefix(r.Header.Get(headerOTP), "required") { + return (*TwoFactorAuthError)(errorResponse) + } return errorResponse } @@ -562,6 +573,43 @@ func (t *UnauthenticatedRateLimitedTransport) transport() http.RoundTripper { return http.DefaultTransport } +// BasicAuthTransport is an http.RoundTripper that authenticates all requests +// using HTTP Basic Authentication with the provided username and password. It +// additionally supports users who have two-factor authentication enabled on +// their GitHub account. +type BasicAuthTransport struct { + Username string // GitHub username + Password string // GitHub password + OTP string // one-time password for users with two-factor auth enabled + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// RoundTrip implements the RoundTripper interface. +func (t *BasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = cloneRequest(req) // per RoundTrip contract + req.SetBasicAuth(t.Username, t.Password) + if t.OTP != "" { + req.Header.Add(headerOTP, t.OTP) + } + return t.transport().RoundTrip(req) +} + +// Client returns an *http.Client that makes requests that are authenticated +// using HTTP Basic Authentication. +func (t *BasicAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *BasicAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} + // cloneRequest returns a clone of the provided *http.Request. The clone is a // shallow copy of the struct and its Header map. func cloneRequest(r *http.Request) *http.Request { @@ -569,9 +617,9 @@ func cloneRequest(r *http.Request) *http.Request { r2 := new(http.Request) *r2 = *r // deep copy of the Header - r2.Header = make(http.Header) + r2.Header = make(http.Header, len(r.Header)) for k, s := range r.Header { - r2.Header[k] = s + r2.Header[k] = append([]string(nil), s...) } return r2 } diff --git a/github/github_test.go b/github/github_test.go index eac121e..b02076d 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -695,3 +695,52 @@ func TestUnauthenticatedRateLimitedTransport_transport(t *testing.T) { t.Errorf("Expected custom transport to be used.") } } + +func TestBasicAuthTransport(t *testing.T) { + setup() + defer teardown() + + username, password, otp := "u", "p", "123456" + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok { + t.Errorf("request does not contain basic auth credentials") + } + if u != username { + t.Errorf("request contained basic auth username %q, want %q", u, username) + } + if p != password { + t.Errorf("request contained basic auth password %q, want %q", p, password) + } + if got, want := r.Header.Get(headerOTP), otp; got != want { + t.Errorf("request contained OTP %q, want %q", got, want) + } + }) + + tp := &BasicAuthTransport{ + Username: username, + Password: password, + OTP: otp, + } + basicAuthClient := NewClient(tp.Client()) + basicAuthClient.BaseURL = client.BaseURL + req, _ := basicAuthClient.NewRequest("GET", "/", nil) + basicAuthClient.Do(req, nil) +} + +func TestBasicAuthTransport_transport(t *testing.T) { + // default transport + tp := &BasicAuthTransport{} + if tp.transport() != http.DefaultTransport { + t.Errorf("Expected http.DefaultTransport to be used.") + } + + // custom transport + tp = &BasicAuthTransport{ + Transport: &http.Transport{}, + } + if tp.transport() == http.DefaultTransport { + t.Errorf("Expected custom transport to be used.") + } +}