gerrit: Add support for Digest Authorization
The existing implementation doesn't support the HTTP Digest Authorization
that widely used in docker-based Gerrit configuration
Proposed code is based on http://play.golang.org/p/ABoHSHoTmu
Change-Id: Ia01d03cc849a4fcd538b05a60b83ac7e18809d5a
Reviewed-on: https://go-review.googlesource.com/29295
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/gerrit/auth.go b/gerrit/auth.go
index 527249e..5718c6e 100644
--- a/gerrit/auth.go
+++ b/gerrit/auth.go
@@ -5,6 +5,9 @@
package gerrit
import (
+ "bytes"
+ "crypto/md5"
+ "encoding/hex"
"fmt"
"io/ioutil"
"log"
@@ -171,3 +174,90 @@
type noAuth struct{}
func (noAuth) setAuth(c *Client, r *http.Request) {}
+
+type digestAuth struct {
+ Username, Password, Realm, NONCE, QOP, Opaque, Algorithm string
+}
+
+func getDigestAuth(username, password string, resp *http.Response) *digestAuth {
+ header := resp.Header.Get("www-authenticate")
+ parts := strings.SplitN(header, " ", 2)
+ parts = strings.Split(parts[1], ", ")
+ opts := make(map[string]string)
+
+ for _, part := range parts {
+ vals := strings.SplitN(part, "=", 2)
+ key := vals[0]
+ val := strings.Trim(vals[1], "\",")
+ opts[key] = val
+ }
+
+ auth := digestAuth{
+ username, password,
+ opts["realm"], opts["nonce"], opts["qop"], opts["opaque"], opts["algorithm"],
+ }
+ return &auth
+}
+
+func setDigestAuth(r *http.Request, username, password string, resp *http.Response, nc int) {
+ auth := getDigestAuth(username, password, resp)
+ authStr := getDigestAuthString(auth, r.URL, r.Method, nc)
+ r.Header.Add("Authorization", authStr)
+}
+
+func getDigestAuthString(auth *digestAuth, url *url.URL, method string, nc int) string {
+ var buf bytes.Buffer
+ h := md5.New()
+ fmt.Fprintf(&buf, "%s:%s:%s", auth.Username, auth.Realm, auth.Password)
+ buf.WriteTo(h)
+ ha1 := hex.EncodeToString(h.Sum(nil))
+
+ h = md5.New()
+ fmt.Fprintf(&buf, "%s:%s", method, url.Path)
+ buf.WriteTo(h)
+ ha2 := hex.EncodeToString(h.Sum(nil))
+
+ ncStr := fmt.Sprintf("%08x", nc)
+ hnc := "MTM3MDgw"
+
+ h = md5.New()
+ fmt.Fprintf(&buf, "%s:%s:%s:%s:%s:%s", ha1, auth.NONCE, ncStr, hnc, auth.QOP, ha2)
+ buf.WriteTo(h)
+ respdig := hex.EncodeToString(h.Sum(nil))
+
+ buf.Write([]byte("Digest "))
+ fmt.Fprintf(&buf,
+ `username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`,
+ auth.Username, auth.Realm, auth.NONCE, url.Path, respdig,
+ )
+
+ if auth.Opaque != "" {
+ fmt.Fprintf(&buf, `, opaque="%s"`, auth.Opaque)
+ }
+ if auth.QOP != "" {
+ fmt.Fprintf(&buf, `, qop="%s", nc=%s, cnonce="%s"`, auth.QOP, ncStr, hnc)
+ }
+ if auth.Algorithm != "" {
+ fmt.Fprintf(&buf, `, algorithm="%s"`, auth.Algorithm)
+ }
+
+ return buf.String()
+}
+
+func (a digestAuth) setAuth(c *Client, r *http.Request) {
+ resp, err := http.Get(r.URL.String())
+ if err != nil {
+ return
+ }
+ setDigestAuth(r, a.Username, a.Password, resp, 1)
+}
+
+// DigestAuth returns an Auth implementation which sends
+// the provided username and password using HTTP Digest Authentication
+// (RFC 2617)
+func DigestAuth(username, password string) Auth {
+ return digestAuth{
+ Username: username,
+ Password: password,
+ }
+}
diff --git a/gerrit/auth_test.go b/gerrit/auth_test.go
new file mode 100644
index 0000000..fcff2d5
--- /dev/null
+++ b/gerrit/auth_test.go
@@ -0,0 +1,113 @@
+// Copyright 2016 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package gerrit
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func md5str(text string) string {
+ h := md5.Sum([]byte(text))
+ return hex.EncodeToString(h[:])
+}
+
+func TestBasicAuth(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ expected := "User Password true"
+ u, p, ok := r.BasicAuth()
+ if expected != fmt.Sprintf("%s %s %t", u, p, ok) {
+ t.Errorf("Expected %s, got %s %s %t", expected, u, p, ok)
+ w.WriteHeader(http.StatusUnauthorized)
+ } else {
+ w.Header().Set("Content-Type", "application/json; charset=UTF-8")
+ // The JSON response begins with an XSRF-defeating header ")]}\n"
+ fmt.Fprintln(w, ")]}")
+ json.NewEncoder(w).Encode(AccountInfo{})
+ }
+ }))
+ defer ts.Close()
+
+ _, err := NewClient(
+ ts.URL,
+ BasicAuth("User", "Password"),
+ ).GetAccountInfo(context.Background(), "self")
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+func TestDigestAuth(t *testing.T) {
+ const (
+ user = "User"
+ pass = "Password"
+ nonce = "dcd98b7102dd2f0e8b11d0f600bfb0c093"
+ opaque = "5ccc069c403ebaf9f0171e9517f40e41"
+ realm = "Gerrit Code Review"
+ qop = "auth"
+ )
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ header := r.Header.Get("Authorization")
+ if header == "" {
+ w.Header().Set("WWW-Authenticate", fmt.Sprintf(
+ `Digest realm="%s", qop="%s", nonce="%s", opaque="%s"`,
+ realm, qop, nonce, opaque,
+ ))
+ w.WriteHeader(http.StatusUnauthorized)
+ } else {
+ parts := strings.SplitN(header, " ", 2)
+ parts = strings.Split(parts[1], ", ")
+ opts := make(map[string]string)
+
+ for _, part := range parts {
+ vals := strings.SplitN(part, "=", 2)
+ key := vals[0]
+ val := strings.Trim(vals[1], "\",")
+ opts[key] = val
+ }
+
+ // https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation
+ // The "response" value is calculated in three steps, as follows.
+ // Where values are combined, they are delimited by colons.
+ // 1. The MD5 hash of the combined username, authentication realm and password is calculated.
+ // The result is referred to as HA1.
+ // 2. The MD5 hash of the combined method and digest URI is calculated, e.g. of "GET" and "/index.html".
+ // The result is referred to as HA2.
+ // 3. The MD5 hash of the combined HA1 result, server nonce (nonce), request counter (nc),
+ // client nonce (cnonce), quality of protection code (qop) and HA2 result is calculated.
+ // The result is the "response" value provided by the client.
+ ha1 := md5str(fmt.Sprintf("%s:%s:%s", user, realm, pass))
+ ha2 := md5str("GET:/a/accounts/self")
+ expected := md5str(fmt.Sprintf("%s:%s:%s:%s:%s:%s", ha1, nonce, opts["nc"], opts["cnonce"], qop, ha2))
+
+ if expected != opts["response"] {
+ t.Errorf("Expected %s, got %s", expected, opts["response"])
+ w.WriteHeader(http.StatusUnauthorized)
+ } else {
+ w.Header().Set("Content-Type", "application/json; charset=UTF-8")
+ // The JSON response begins with an XSRF-defeating header ")]}\n"
+ fmt.Fprintln(w, ")]}")
+ json.NewEncoder(w).Encode(AccountInfo{})
+ }
+ }
+ }))
+ defer ts.Close()
+
+ _, err := NewClient(
+ ts.URL,
+ DigestAuth(user, pass),
+ ).GetAccountInfo(context.Background(), "self")
+ if err != nil {
+ t.Error(err)
+ }
+}