auth/login.go (149 lines of code) (raw):

// Copyright 2016 Google, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /* Package auth implements the logic required to authenticate the user and generate access tokens for use with GCR. */ package auth import ( "crypto/rand" "crypto/sha256" "encoding/base64" "fmt" "io" "io/ioutil" "net" "net/http" "net/http/httputil" "os" "strings" "github.com/GoogleCloudPlatform/docker-credential-gcr/v2/config" "github.com/toqueteos/webbrowser" "golang.org/x/oauth2" ) const redirectURIAuthCodeInTitleBar = "urn:ietf:wg:oauth:2.0:oob" // GCRLoginAgent implements the OAuth2 login dance, generating an Oauth2 access_token // for the user. If AllowBrowser is set to true, the agent will attempt to // obtain an authorization_code automatically by executing OpenBrowser and // reading the redirect performed after a successful login. Otherwise, it will // attempt to use In and Out to direct the user to the login portal and receive // the authorization_code in response. type GCRLoginAgent struct { // Read input from here; if nil, uses os.Stdin. In io.Reader // Write output to here; if nil, uses os.Stdout. Out io.Writer // Open the browser for the given url. If nil, uses webbrowser.Open. OpenBrowser func(url string) error } // populate missing fields as described in the struct definition comments func (a *GCRLoginAgent) init() { if a.In == nil { a.In = os.Stdin } if a.Out == nil { a.Out = os.Stdout } if a.OpenBrowser == nil { a.OpenBrowser = webbrowser.Open } } // PerformLogin performs the auth dance necessary to obtain an // authorization_code from the user and exchange it for an Oauth2 access_token. func (a *GCRLoginAgent) PerformLogin() (*oauth2.Token, error) { a.init() conf := &oauth2.Config{ ClientID: config.GCRCredHelperClientID, ClientSecret: config.GCRCredHelperClientNotSoSecret, Scopes: config.GCRScopes, Endpoint: config.GCROAuth2Endpoint, } verifier, challenge, method, err := codeChallengeParams() state, err := makeRandString(16) if err != nil { return nil, fmt.Errorf("Unable to build random string: %v", err) } authCodeOpts := []oauth2.AuthCodeOption{ oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", method), } // Browser based auth is the only mechanism supported now. // Attempt to receive the authorization code via redirect URL ln, port, err := getListener() if err != nil { return nil, fmt.Errorf("Unable to open local listener: %v", err) } defer ln.Close() // open a web browser and listen on the redirect URL port conf.RedirectURL = fmt.Sprintf("http://localhost:%d", port) url := conf.AuthCodeURL(state, authCodeOpts...) err = a.OpenBrowser(url) if err != nil { return nil, fmt.Errorf("Unable to open browser: %v", err) } code, err := handleCodeResponse(ln, state) if err != nil { return nil, fmt.Errorf("Response was invalid: %v", err) } return conf.Exchange( config.OAuthHTTPContext, code, oauth2.SetAuthURLParam("code_verifier", verifier)) } func (a *GCRLoginAgent) codeViaPrompt(conf *oauth2.Config, authCodeOpts []oauth2.AuthCodeOption) (string, error) { // Direct the user to our login portal conf.RedirectURL = redirectURIAuthCodeInTitleBar url := conf.AuthCodeURL("state", authCodeOpts...) fmt.Fprintln(a.Out, "Please visit the following URL and complete the authorization dialog:") fmt.Fprintf(a.Out, "%v\n", url) // Receive the authorization_code in response fmt.Fprintln(a.Out, "Authorization code:") var code string if _, err := fmt.Fscan(a.In, &code); err != nil { return "", err } return code, nil } func getListener() (net.Listener, int, error) { laddr := net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} // port: 0 == find free port ln, err := net.ListenTCP("tcp4", &laddr) if err != nil { return nil, 0, err } return ln, ln.Addr().(*net.TCPAddr).Port, nil } func handleCodeResponse(ln net.Listener, stateCheck string) (string, error) { conn, err := ln.Accept() if err != nil { return "", err } srvConn := httputil.NewServerConn(conn, nil) defer srvConn.Close() req, err := srvConn.Read() if err != nil { return "", err } code := req.URL.Query().Get("code") state := req.URL.Query().Get("state") resp := &http.Response{ StatusCode: 200, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Close: true, ContentLength: -1, // designates unknown length } defer srvConn.Write(req, resp) // If the code couldn't be obtained, inform the user via the browser and // return an error. // TODO i18n? if code == "" { err := fmt.Errorf("Code not present in response: %s", req.URL.String()) resp.Body = getResponseBody("ERROR: Authentication code not present in response.") return "", err } if state != stateCheck { err := fmt.Errorf("Invalid State") resp.StatusCode = 400 resp.Body = getResponseBody("ERROR: State parameter is invalid.") return "", err } resp.Body = getResponseBody("Success! You may now close your browser.") return code, nil } // turn a string into an io.ReadCloser as required by an http.Response func getResponseBody(body string) io.ReadCloser { reader := strings.NewReader(body) return ioutil.NopCloser(reader) } // generates the values used in "Proof Key for Code Exchange by OAuth Public Clients" // https://tools.ietf.org/html/rfc7636 // https://developers.google.com/identity/protocols/OAuth2InstalledApp#step1-code-verifier func codeChallengeParams() (verifier, challenge, method string, err error) { // A `code_verifier` is a high-entropy cryptographic random string using the unreserved characters // [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" // with a minimum length of 43 characters and a maximum length of 128 characters. verifier, err = makeRandString(32) if err != nil { return "", "", "", err } // https://tools.ietf.org/html/rfc7636#section-4.2 // If the client is capable of using "S256", it MUST use "S256": // code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) sha := sha256.Sum256([]byte(verifier)) challenge = base64.RawURLEncoding.EncodeToString(sha[:]) return verifier, challenge, "S256", nil } func makeRandString(length int) (string, error) { b := make([]byte, length) _, err := rand.Read(b) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil }