diff --git a/README.md b/README.md index 894467c..93ffea6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Features -------- * Supports HTTP Basic and HTTP Digest authentication. + * Supports proxy authentication * Supports htpasswd and htdigest formatted files. * Automatic reloading of password files. * Pluggable interface for user/password storage. diff --git a/auth.go b/auth.go index d769ff9..71eaa5f 100644 --- a/auth.go +++ b/auth.go @@ -102,3 +102,35 @@ func JustCheck(auth AuthenticatorInterface, wrapped http.HandlerFunc) http.Handl wrapped(w, &ar.Request) }) } + +func AuthenticateHeaderName(proxy bool) string { + if proxy { + return "Proxy-Authenticate" + } + return "WWW-Authenticate" +} + +func AuthorizationHeaderName(proxy bool) string { + if proxy { + return "Proxy-Authorization" + } + return "Authorization" +} + +func AuthenticationInfoHeaderName(proxy bool) string { + if proxy { + return "Proxy-Authentication-Info" + } + return "Authentication-Info" +} + +func UnauthorizedStatusCode(proxy bool) int { + if proxy { + return http.StatusProxyAuthRequired + } + return http.StatusUnauthorized +} + +func UnauthorizedStatusText(proxy bool) string { + return http.StatusText(UnauthorizedStatusCode(proxy)) +} diff --git a/basic.go b/basic.go index e165c28..52e4e6f 100644 --- a/basic.go +++ b/basic.go @@ -29,6 +29,7 @@ var ( ) type BasicAuth struct { + IsProxy bool Realm string Secrets SecretProvider } @@ -44,7 +45,7 @@ var _ = (AuthenticatorInterface)((*BasicAuth)(nil)) Supports MD5 and SHA1 password entries */ func (a *BasicAuth) CheckAuth(r *http.Request) string { - s := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + s := strings.SplitN(r.Header.Get(AuthorizationHeaderName(a.IsProxy)), " ", 2) if len(s) != 2 || s[0] != "Basic" { return "" } @@ -102,9 +103,8 @@ func compareMD5HashAndPassword(hashedPassword, password []byte) error { (or requires reauthentication). */ func (a *BasicAuth) RequireAuth(w http.ResponseWriter, r *http.Request) { - w.Header().Set("WWW-Authenticate", `Basic realm="`+a.Realm+`"`) - w.WriteHeader(401) - w.Write([]byte("401 Unauthorized\n")) + w.Header().Set(AuthenticateHeaderName(a.IsProxy), `Basic realm="`+a.Realm+`"`) + http.Error(w, UnauthorizedStatusText(a.IsProxy), UnauthorizedStatusCode(a.IsProxy)) } /* @@ -130,7 +130,7 @@ func (a *BasicAuth) NewContext(ctx context.Context, r *http.Request) context.Con info := &Info{Username: a.CheckAuth(r), ResponseHeaders: make(http.Header)} info.Authenticated = (info.Username != "") if !info.Authenticated { - info.ResponseHeaders.Set("WWW-Authenticate", `Basic realm="`+a.Realm+`"`) + info.ResponseHeaders.Set(AuthenticateHeaderName(a.IsProxy), `Basic realm="`+a.Realm+`"`) } return context.WithValue(ctx, infoKey, info) } @@ -138,3 +138,7 @@ func (a *BasicAuth) NewContext(ctx context.Context, r *http.Request) context.Con func NewBasicAuthenticator(realm string, secrets SecretProvider) *BasicAuth { return &BasicAuth{Realm: realm, Secrets: secrets} } + +func NewBasicAuthenticatorForProxy(realm string, secrets SecretProvider) *BasicAuth { + return &BasicAuth{IsProxy: true, Realm: realm, Secrets: secrets} +} diff --git a/basic_test.go b/basic_test.go index 99f44fc..47de9c9 100644 --- a/basic_test.go +++ b/basic_test.go @@ -8,33 +8,36 @@ import ( func TestAuthBasic(t *testing.T) { secrets := HtpasswdFileProvider("test.htpasswd") - a := &BasicAuth{Realm: "example.com", Secrets: secrets} - r := &http.Request{} - r.Method = "GET" - if a.CheckAuth(r) != "" { - t.Fatal("CheckAuth passed on empty headers") - } - r.Header = http.Header(make(map[string][]string)) - r.Header.Set("Authorization", "Digest blabla ololo") - if a.CheckAuth(r) != "" { - t.Fatal("CheckAuth passed on bad headers") - } - r.Header.Set("Authorization", "Basic !@#") - if a.CheckAuth(r) != "" { - t.Fatal("CheckAuth passed on bad base64 data") - } - data := [][]string{ - {"test", "hello"}, - {"test2", "hello2"}, - {"test3", "hello3"}, - {"test16", "topsecret"}, - } - for _, tc := range data { - auth := base64.StdEncoding.EncodeToString([]byte(tc[0] + ":" + tc[1])) - r.Header.Set("Authorization", "Basic "+auth) - if a.CheckAuth(r) != tc[0] { - t.Fatalf("CheckAuth failed for user '%s'", tc[0]) + for _, isProxy := range []bool{false, true} { + a := &BasicAuth{IsProxy: isProxy, Realm: "example.com", Secrets: secrets} + r := &http.Request{} + r.Method = "GET" + if a.CheckAuth(r) != "" { + t.Fatal("CheckAuth passed on empty headers") + } + r.Header = http.Header(make(map[string][]string)) + r.Header.Set(AuthorizationHeaderName(a.IsProxy), "Digest blabla ololo") + if a.CheckAuth(r) != "" { + t.Fatal("CheckAuth passed on bad headers") + } + r.Header.Set(AuthorizationHeaderName(a.IsProxy), "Basic !@#") + if a.CheckAuth(r) != "" { + t.Fatal("CheckAuth passed on bad base64 data") + } + + data := [][]string{ + {"test", "hello"}, + {"test2", "hello2"}, + {"test3", "hello3"}, + {"test16", "topsecret"}, + } + for _, tc := range data { + auth := base64.StdEncoding.EncodeToString([]byte(tc[0] + ":" + tc[1])) + r.Header.Set(AuthorizationHeaderName(a.IsProxy), "Basic "+auth) + if a.CheckAuth(r) != tc[0] { + t.Fatalf("CheckAuth failed for user '%s'", tc[0]) + } } } } diff --git a/bitset.go b/bitset.go new file mode 100644 index 0000000..06a5d9d --- /dev/null +++ b/bitset.go @@ -0,0 +1,72 @@ +// Package bitset implments a memory efficient bit array of booleans +// Adapted from https://github.com/lazybeaver/bitset + +package auth + +import "fmt" + +type BitSet struct { + bits []uint8 + size uint64 +} + +const ( + bitMaskZero = uint8(0) + bitMaskOnes = uint8((1 << 8) - 1) +) + +var ( + bitMasks = [...]uint8{0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80} +) + +func (b *BitSet) getPositionAndMask(index uint64) (uint64, uint8) { + if index < 0 || index >= b.size { + panic(fmt.Errorf("BitSet index (%d) out of bounds (size: %d)", index, b.size)) + } + position := index >> 3 + mask := bitMasks[index%8] + return position, mask +} + +func (b *BitSet) Init(size uint64) { + b.bits = make([]uint8, (size+7)/8) + b.size = size +} + +func (b *BitSet) Size() uint64 { + return b.size +} + +func (b *BitSet) Get(index uint64) bool { + position, mask := b.getPositionAndMask(index) + return (b.bits[position] & mask) != 0 +} + +func (b *BitSet) Set(index uint64) { + position, mask := b.getPositionAndMask(index) + b.bits[position] |= mask +} + +func (b *BitSet) Clear(index uint64) { + position, mask := b.getPositionAndMask(index) + b.bits[position] &^= mask +} + +func (b *BitSet) String() string { + value := make([]byte, b.size) + var i uint64 + for i = 0; i < b.size; i++ { + if b.Get(i) { + value[i] = '1' + } else { + value[i] = '0' + } + } + return string(value) +} + +func NewBitSet(size uint64) *BitSet { + b := &BitSet{} + b.Init(size) + return b +} diff --git a/bitset_test.go b/bitset_test.go new file mode 100644 index 0000000..6444903 --- /dev/null +++ b/bitset_test.go @@ -0,0 +1,79 @@ +package auth + +import ( + "testing" +) + +func TestNew(t *testing.T) { + var size uint64 = 101 + bs := NewBitSet(size) + if bs.Size() != size { + t.Errorf("Unexpected initialization failure") + } + var i uint64 + for i = 0; i < size; i++ { + if bs.Get(i) { + t.Errorf("Newly initialized bitset cannot have true values") + } + } +} + +func TestGet(t *testing.T) { + bs := NewBitSet(2) + bs.Set(0) + bs.Clear(1) + if bs.Get(0) != true { + t.Errorf("Actual: false | Expected: true") + } + if bs.Get(1) != false { + t.Errorf("Actual: true | Expected: false") + } +} + +func TestSet(t *testing.T) { + bs := NewBitSet(10) + bs.Set(2) + bs.Set(3) + bs.Set(5) + bs.Set(7) + actual := bs.String() + expected := "0011010100" + if actual != expected { + t.Errorf("Actual: %s | Expected: %s", actual, expected) + } +} + +func TestClear(t *testing.T) { + bs := NewBitSet(10) + var i uint64 + for i = 0; i < 10; i++ { + bs.Set(i) + } + bs.Clear(0) + bs.Clear(3) + bs.Clear(6) + bs.Clear(9) + actual := bs.String() + expected := "0110110110" + if actual != expected { + t.Errorf("Actual: %s | Expected: %s", actual, expected) + } +} + +func BenchmarkGet(b *testing.B) { + bn := uint64(b.N) + bs := NewBitSet(bn) + var i uint64 + for i = 0; i < bn; i++ { + _ = bs.Get(i) + } +} + +func BenchmarkSet(b *testing.B) { + bn := uint64(b.N) + bs := NewBitSet(bn) + var i uint64 + for i = 0; i < bn; i++ { + bs.Set(i) + } +} diff --git a/digest.go b/digest.go index 4d4af29..3681dc2 100644 --- a/digest.go +++ b/digest.go @@ -14,17 +14,27 @@ import ( "golang.org/x/net/context" ) +const DefaultNcCacheSize = 65536 + type digest_client struct { - nc uint64 + /* + ncs_seen is a bitset used to record the nc values we've seen for a given nonce. + This allows us to identify and deny replay attacks without relying on nc values + always increasing. That's important since in practice a client's use of multiple + server connections, a hierarchy of proxies, and AJAX can cause nc values to arrive + out of order (See https://github.com/abbot/go-http-auth/issues/21) + */ + ncs_seen *BitSet last_seen int64 } type DigestAuth struct { + IsProxy bool Realm string Opaque string Secrets SecretProvider PlainTextSecrets bool - IgnoreNonceCount bool + NcCacheSize uint64 // The max number of nc values we remember before issuing a new nonce /* Approximate size of Client's Cache. When actual number of @@ -80,17 +90,22 @@ func (a *DigestAuth) Purge(count int) { http.Handler for DigestAuth which initiates the authentication process (or requires reauthentication). */ -func (a *DigestAuth) RequireAuth(w http.ResponseWriter, r *http.Request) { +func (a *DigestAuth) RequireAuth(w http.ResponseWriter, r *http.Request, stale bool) { + a.mutex.Lock() + defer a.mutex.Unlock() + if len(a.clients) > a.ClientCacheSize+a.ClientCacheTolerance { a.Purge(a.ClientCacheTolerance * 2) } nonce := RandomKey() - a.clients[nonce] = &digest_client{nc: 0, last_seen: time.Now().UnixNano()} - w.Header().Set("WWW-Authenticate", - fmt.Sprintf(`Digest realm="%s", nonce="%s", opaque="%s", algorithm="MD5", qop="auth"`, - a.Realm, nonce, a.Opaque)) - w.WriteHeader(401) - w.Write([]byte("401 Unauthorized\n")) + a.clients[nonce] = &digest_client{ncs_seen: NewBitSet(a.NcCacheSize), + last_seen: time.Now().UnixNano()} + value := fmt.Sprintf(`Digest realm="%s", nonce="%s", opaque="%s", algorithm="MD5", qop="auth"`, a.Realm, nonce, a.Opaque) + if stale { + value += ", stale=true" + } + w.Header().Set(AuthenticateHeaderName(a.IsProxy), value) + http.Error(w, UnauthorizedStatusText(a.IsProxy), UnauthorizedStatusCode(a.IsProxy)) } /* @@ -98,49 +113,68 @@ func (a *DigestAuth) RequireAuth(w http.ResponseWriter, r *http.Request) { auth parameters or nil if the header is not a valid parsable Digest auth header. */ -func DigestAuthParams(r *http.Request) map[string]string { - s := strings.SplitN(r.Header.Get("Authorization"), " ", 2) +func (a *DigestAuth) DigestAuthParams(r *http.Request) map[string]string { + s := strings.SplitN(r.Header.Get(AuthorizationHeaderName(a.IsProxy)), " ", 2) if len(s) != 2 || s[0] != "Digest" { return nil } - result := map[string]string{} - for _, kv := range strings.Split(s[1], ",") { - parts := strings.SplitN(kv, "=", 2) - if len(parts) != 2 { - continue - } - result[strings.Trim(parts[0], "\" ")] = strings.Trim(parts[1], "\" ") - } - return result + return ParsePairs(s[1]) } /* - Check if request contains valid authentication data. Returns a pair - of username, authinfo where username is the name of the authenticated - user or an empty string and authinfo is the contents for the optional - Authentication-Info response header. + Check if request contains valid authentication data. Returns a triplet + of username, authinfo, stale where username is the name of the authenticated + user or an empty string, authinfo is the contents for the optional Authentication-Info + response header, and stale indicates whether the server-returned Authenticate header + should specify stale=true (see https://www.ietf.org/rfc/rfc2617.txt Section 3.3) */ -func (da *DigestAuth) CheckAuth(r *http.Request) (username string, authinfo *string) { +func (da *DigestAuth) CheckAuth(r *http.Request) (username string, authinfo *string, stale bool) { da.mutex.Lock() defer da.mutex.Unlock() username = "" authinfo = nil - auth := DigestAuthParams(r) + stale = false + auth := da.DigestAuthParams(r) if auth == nil || da.Opaque != auth["opaque"] || auth["algorithm"] != "MD5" || auth["qop"] != "auth" { return } - // Check if the requested URI matches auth header - switch u, err := url.Parse(auth["uri"]); { - case err != nil: - return - case r.URL == nil: - return - case len(u.Path) > len(r.URL.Path): - return - case !strings.HasPrefix(r.URL.Path, u.Path): - return + /* Check whether the requested URI matches auth header + NOTE: when we're a proxy and method is CONNECT, the request and auth uri + specify a hostname not a path, e.g. + + CONNECT 1-edge-chat.facebook.com:443 HTTP/1.1 + User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:43.0) Gecko/20100101 Firefox/43.0 + Proxy-Connection: keep-alive + Connection: keep-alive + Host: 1-edge-chat.facebook.com:443 + Proxy-Authorization: Digest username="test", realm="", + nonce="iQSz9RcA1Qsa6ono", + uri="1-edge-chat.facebook.com:443", + algorithm=MD5, + response="a077a4676d60ff8bf48577ad7c7360d6", + opaque="EN3BwDsuWB5F6IWR", qop=auth, nc=0000000c, + cnonce="548d04d1bbd63926" + */ + + if r.Method == "CONNECT" { + if r.RequestURI != auth["uri"] { + return + } + } else { + + // Check if the requested URI matches auth header + switch u, err := url.Parse(auth["uri"]); { + case err != nil: + return + case r.URL == nil: + return + case len(u.Path) > len(r.URL.Path): + return + case !strings.HasPrefix(r.URL.Path, u.Path): + return + } } HA1 := da.Secrets(auth["username"], da.Realm) @@ -162,21 +196,30 @@ func (da *DigestAuth) CheckAuth(r *http.Request) (username string, authinfo *str return } - if client, ok := da.clients[auth["nonce"]]; !ok { + client, ok := da.clients[auth["nonce"]] + if !ok { + stale = true return - } else { - if client.nc != 0 && client.nc >= nc && !da.IgnoreNonceCount { - return - } - client.nc = nc - client.last_seen = time.Now().UnixNano() } + // Check the nonce-count + if nc >= client.ncs_seen.Size() { + // nc exceeds the size of our bitset. We can just treat this the + // same as a stale nonce + stale = true + return + } else if client.ncs_seen.Get(nc) { + // We've already seen this nc! Possible replay attack! + return + } + client.ncs_seen.Set(nc) + client.last_seen = time.Now().UnixNano() + resp_HA2 := H(":" + auth["uri"]) rspauth := H(strings.Join([]string{HA1, auth["nonce"], auth["nc"], auth["cnonce"], auth["qop"], resp_HA2}, ":")) info := fmt.Sprintf(`qop="auth", rspauth="%s", cnonce="%s", nc="%s"`, rspauth, auth["cnonce"], auth["nc"]) - return auth["username"], &info + return auth["username"], &info, stale } /* @@ -196,12 +239,12 @@ const DefaultClientCacheTolerance = 100 */ func (a *DigestAuth) Wrap(wrapped AuthenticatedHandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if username, authinfo := a.CheckAuth(r); username == "" { - a.RequireAuth(w, r) + if username, authinfo, stale := a.CheckAuth(r); username == "" { + a.RequireAuth(w, r, stale) } else { ar := &AuthenticatedRequest{Request: *r, Username: username} if authinfo != nil { - w.Header().Set("Authentication-Info", *authinfo) + w.Header().Set(AuthenticationInfoHeaderName(a.IsProxy), *authinfo) } wrapped(w, ar) } @@ -222,21 +265,25 @@ func (a *DigestAuth) JustCheck(wrapped http.HandlerFunc) http.HandlerFunc { // NewContext returns a context carrying authentication information for the request. func (a *DigestAuth) NewContext(ctx context.Context, r *http.Request) context.Context { - username, authinfo := a.CheckAuth(r) + username, authinfo, stale := a.CheckAuth(r) info := &Info{Username: username, ResponseHeaders: make(http.Header)} if username != "" { info.Authenticated = true - info.ResponseHeaders.Set("Authentication-Info", *authinfo) + info.ResponseHeaders.Set(AuthenticationInfoHeaderName(a.IsProxy), *authinfo) } else { - // return back digest WWW-Authenticate header + // return back digest XYZ-Authenticate header if len(a.clients) > a.ClientCacheSize+a.ClientCacheTolerance { a.Purge(a.ClientCacheTolerance * 2) } nonce := RandomKey() - a.clients[nonce] = &digest_client{nc: 0, last_seen: time.Now().UnixNano()} - info.ResponseHeaders.Set("WWW-Authenticate", - fmt.Sprintf(`Digest realm="%s", nonce="%s", opaque="%s", algorithm="MD5", qop="auth"`, - a.Realm, nonce, a.Opaque)) + a.clients[nonce] = &digest_client{ncs_seen: NewBitSet(a.NcCacheSize), + last_seen: time.Now().UnixNano()} + value := fmt.Sprintf(`Digest realm="%s", nonce="%s", opaque="%s", algorithm="MD5", qop="auth"`, + a.Realm, nonce, a.Opaque) + if stale { + value += ", stale=true" + } + info.ResponseHeaders.Set(AuthenticateHeaderName(a.IsProxy), value) } return context.WithValue(ctx, infoKey, info) } @@ -247,8 +294,15 @@ func NewDigestAuthenticator(realm string, secrets SecretProvider) *DigestAuth { Realm: realm, Secrets: secrets, PlainTextSecrets: false, + NcCacheSize: DefaultNcCacheSize, ClientCacheSize: DefaultClientCacheSize, ClientCacheTolerance: DefaultClientCacheTolerance, clients: map[string]*digest_client{}} return da } + +func NewDigestAuthenticatorForProxy(realm string, secrets SecretProvider) *DigestAuth { + da := NewDigestAuthenticator(realm, secrets) + da.IsProxy = true + return da +} diff --git a/digest_test.go b/digest_test.go index 012488d..ef9395e 100644 --- a/digest_test.go +++ b/digest_test.go @@ -1,66 +1,120 @@ package auth import ( + "bufio" "net/http" "net/url" + "strings" "testing" "time" ) func TestAuthDigest(t *testing.T) { - secrets := HtdigestFileProvider("test.htdigest") - da := &DigestAuth{Opaque: "U7H+ier3Ae8Skd/g", - Realm: "example.com", - Secrets: secrets, - clients: map[string]*digest_client{}} - r := &http.Request{} - r.Method = "GET" - if u, _ := da.CheckAuth(r); u != "" { - t.Fatal("non-empty auth for empty request header") - } - r.Header = http.Header(make(map[string][]string)) - r.Header.Set("Authorization", "Digest blabla") - if u, _ := da.CheckAuth(r); u != "" { - t.Fatal("non-empty auth for bad request header") - } - r.Header.Set("Authorization", `Digest username="test", realm="example.com", nonce="Vb9BP/h81n3GpTTB", uri="/t2", cnonce="NjE4MTM2", nc=00000001, qop="auth", response="ffc357c4eba74773c8687e0bc724c9a3", opaque="U7H+ier3Ae8Skd/g", algorithm="MD5"`) - if u, _ := da.CheckAuth(r); u != "" { - t.Fatal("non-empty auth for unknown client") - } - r.URL, _ = url.Parse("/t2") - da.clients["Vb9BP/h81n3GpTTB"] = &digest_client{nc: 0, last_seen: time.Now().UnixNano()} - if u, _ := da.CheckAuth(r); u != "test" { - t.Fatal("empty auth for legitimate client") - } + for _, isProxy := range []bool{false, true} { + secrets := HtdigestFileProvider("test.htdigest") + da := &DigestAuth{IsProxy: isProxy, + Opaque: "U7H+ier3Ae8Skd/g", + Realm: "example.com", + Secrets: secrets, + NcCacheSize: 20, + clients: map[string]*digest_client{}} + r := &http.Request{} + r.Method = "GET" + if u, _, _ := da.CheckAuth(r); u != "" { + t.Fatal("non-empty auth for empty request header") + } + r.Header = http.Header(make(map[string][]string)) + r.Header.Set(AuthorizationHeaderName(da.IsProxy), "Digest blabla") + if u, _, _ := da.CheckAuth(r); u != "" { + t.Fatal("non-empty auth for bad request header") + } - // our nc is now 0, client nc is 1 - if u, _ := da.CheckAuth(r); u != "" { - t.Fatal("non-empty auth for outdated nc") - } + r.URL, _ = url.Parse("/t2") + r.Header.Set(AuthorizationHeaderName(da.IsProxy), `Digest username="test", realm="example.com", nonce="Vb9BP/h81n3GpTTB", uri="/t2", cnonce="NjE4MTM2", nc=00000001, qop="auth", response="ffc357c4eba74773c8687e0bc724c9a3", opaque="U7H+ier3Ae8Skd/g", algorithm="MD5"`) + u, _, stale := da.CheckAuth(r) + if u != "" { + t.Fatal("non-empty auth for unknown client") + } + if !stale { + t.Fatal("stale should be true") + } - // try again with nc checking off - da.IgnoreNonceCount = true - if u, _ := da.CheckAuth(r); u != "test" { - t.Fatal("empty auth for outdated nc even though nc checking is off") - } - da.IgnoreNonceCount = false + da.clients["Vb9BP/h81n3GpTTB"] = &digest_client{ncs_seen: NewBitSet(da.NcCacheSize), + last_seen: time.Now().UnixNano()} + u, _, stale = da.CheckAuth(r) + if u != "test" { + t.Fatal("empty auth for legitimate client") + } + if stale { + t.Fatal("stale should be false") + } - r.URL, _ = url.Parse("/") - da.clients["Vb9BP/h81n3GpTTB"] = &digest_client{nc: 0, last_seen: time.Now().UnixNano()} - if u, _ := da.CheckAuth(r); u != "" { - t.Fatal("non-empty auth for bad request path") - } + r.URL, _ = url.Parse("/") + da.clients["Vb9BP/h81n3GpTTB"] = &digest_client{ncs_seen: NewBitSet(da.NcCacheSize), last_seen: time.Now().UnixNano()} + if u, _, _ := da.CheckAuth(r); u != "" { + t.Fatal("non-empty auth for bad request path") + } + + r.URL, _ = url.Parse("/t3") + da.clients["Vb9BP/h81n3GpTTB"] = &digest_client{ncs_seen: NewBitSet(da.NcCacheSize), last_seen: time.Now().UnixNano()} + if u, _, _ := da.CheckAuth(r); u != "" { + t.Fatal("non-empty auth for bad request path") + } + + da.clients["+RbVXSbIoa1SaJk1"] = &digest_client{ncs_seen: NewBitSet(da.NcCacheSize), last_seen: time.Now().UnixNano()} + r.Header.Set(AuthorizationHeaderName(da.IsProxy), `Digest username="test", realm="example.com", nonce="+RbVXSbIoa1SaJk1", uri="/", cnonce="NjE4NDkw", nc=00000001, qop="auth", response="c08918024d7faaabd5424654c4e3ad1c", opaque="U7H+ier3Ae8Skd/g", algorithm="MD5"`) + if u, _, _ := da.CheckAuth(r); u != "test" { + t.Fatal("empty auth for valid request in subpath") + } + + // nc checking, we've already seen 00000001 so this should fail + if u, _, _ := da.CheckAuth(r); u != "" { + t.Fatal("non-empty auth for already-seen nc") + } + + // an updated request with nc 00000005 should succeed + r.Header.Set(AuthorizationHeaderName(da.IsProxy), `Digest username="test", realm="example.com", nonce="+RbVXSbIoa1SaJk1", uri="/", cnonce="NjE4NDkw", nc=00000005, qop="auth", response="c553c9a48ec99de9474e662934f73de2", opaque="U7H+ier3Ae8Skd/g", algorithm="MD5"`) + if u, _, _ := da.CheckAuth(r); u != "test" { + t.Fatal("empty auth for valid nc 00000005") + } + + // but repeating it should fail... + r.Header.Set(AuthorizationHeaderName(da.IsProxy), `Digest username="test", realm="example.com", nonce="+RbVXSbIoa1SaJk1", uri="/", cnonce="NjE4NDkw", nc=00000005, qop="auth", response="c553c9a48ec99de9474e662934f73de2", opaque="U7H+ier3Ae8Skd/g", algorithm="MD5"`) + if u, _, _ := da.CheckAuth(r); u != "" { + t.Fatal("non-empty auth for repeated nc 00000005") + } + + // an updated request with nc 00000002 should succeed even though it's out of order, since it hasn't been seen yet + r.Header.Set(AuthorizationHeaderName(da.IsProxy), `Digest username="test", realm="example.com", nonce="+RbVXSbIoa1SaJk1", uri="/", cnonce="NjE4NDkw", nc=00000002, qop="auth", response="1c2a64978d9e8a61f823240304b95afd", opaque="U7H+ier3Ae8Skd/g", algorithm="MD5"`) + if u, _, _ := da.CheckAuth(r); u != "test" { + t.Fatal("empty auth for valid nc 00000002") + } + + if da.clients["+RbVXSbIoa1SaJk1"].ncs_seen.String() != "01100100000000000000" { + t.Fatal("ncs_seen bitmap didn't match expected") + } - r.URL, _ = url.Parse("/t3") - da.clients["Vb9BP/h81n3GpTTB"] = &digest_client{nc: 0, last_seen: time.Now().UnixNano()} - if u, _ := da.CheckAuth(r); u != "" { - t.Fatal("non-empty auth for bad request path") } +} - da.clients["+RbVXSbIoa1SaJk1"] = &digest_client{nc: 0, last_seen: time.Now().UnixNano()} - r.Header.Set("Authorization", `Digest username="test", realm="example.com", nonce="+RbVXSbIoa1SaJk1", uri="/", cnonce="NjE4NDkw", nc=00000001, qop="auth", response="c08918024d7faaabd5424654c4e3ad1c", opaque="U7H+ier3Ae8Skd/g", algorithm="MD5"`) - if u, _ := da.CheckAuth(r); u != "test" { - t.Fatal("empty auth for valid request in subpath") +func TestDigestAuthParams(t *testing.T) { + body := `GET http://fonts.googleapis.com/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro HTTP/1.1 +Host: fonts.googleapis.com +User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:43.0) Gecko/20100101 Firefox/43.0 +Accept: text/css,/;q=0.1 +Accept-Language: en-US,en;q=0.5 +Accept-Encoding: gzip, deflate +Referer: http://elm-lang.org/assets/style.css +Authorization: Digest username="test", realm="", nonce="FRPnGdb8lvM1UHhi", uri="/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro", algorithm=MD5, response="fdcdd78e5b306ffed343d0ec3967f2e5", opaque="lEgVjogmIar2fg/t", qop=auth, nc=00000001, cnonce="e76b05db27a3b323" +Connection: keep-alive + +` + da := &DigestAuth{} + req, _ := http.ReadRequest(bufio.NewReader(strings.NewReader(body))) + params := da.DigestAuthParams(req) + if params["uri"] != "/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro" { + t.Fatal("failed to parse uri with embedded commas") } + } diff --git a/misc.go b/misc.go index 277a685..9b1e3a3 100644 --- a/misc.go +++ b/misc.go @@ -1,9 +1,13 @@ package auth -import "encoding/base64" -import "crypto/md5" -import "crypto/rand" -import "fmt" +import ( + "bytes" + "crypto/md5" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" +) /* Return a random 16-byte base64 alphabet string @@ -28,3 +32,69 @@ func H(data string) string { digest.Write([]byte(data)) return fmt.Sprintf("%x", digest.Sum(nil)) } + +/* + ParseList parses a comma-separated list of values as described by RFC 2068. + which was itself ported from urllib2.parse_http_list, from the Python standard library. + Lifted from https://code.google.com/p/gorilla/source/browse/http/parser/parser.go +*/ +func ParseList(value string) []string { + var list []string + var escape, quote bool + b := new(bytes.Buffer) + for _, r := range value { + if escape { + b.WriteRune(r) + escape = false + continue + } + if quote { + if r == '\\' { + escape = true + continue + } else if r == '"' { + quote = false + } + b.WriteRune(r) + continue + } + if r == ',' { + list = append(list, strings.TrimSpace(b.String())) + b.Reset() + continue + } + if r == '"' { + quote = true + } + b.WriteRune(r) + } + // Append last part. + if s := b.String(); s != "" { + list = append(list, strings.TrimSpace(s)) + } + return list +} + +/* + ParsePairs extracts key/value pairs from a comma-separated list of values as + described by RFC 2068. + The resulting values are unquoted. If a value doesn't contain a "=", the + key is the value itself and the value is an empty string. + Lifted from https://code.google.com/p/gorilla/source/browse/http/parser/parser.go +*/ +func ParsePairs(value string) map[string]string { + m := make(map[string]string) + for _, pair := range ParseList(strings.TrimSpace(value)) { + if i := strings.Index(pair, "="); i < 0 { + m[pair] = "" + } else { + v := pair[i+1:] + if v[0] == '"' && v[len(v)-1] == '"' { + // Unquote it. + v = v[1 : len(v)-1] + } + m[pair[:i]] = v + } + } + return m +} diff --git a/misc_test.go b/misc_test.go index 089524c..d702b5a 100644 --- a/misc_test.go +++ b/misc_test.go @@ -1,6 +1,10 @@ package auth -import "testing" +import ( + "fmt" + "reflect" + "testing" +) func TestH(t *testing.T) { const hello = "Hello, world!" @@ -10,3 +14,28 @@ func TestH(t *testing.T) { t.Fatal("Incorrect digest for test string:", h, "instead of", hello_md5) } } + +func TestParsePairs(t *testing.T) { + const header = `username="test", realm="", nonce="FRPnGdb8lvM1UHhi", uri="/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro", algorithm=MD5, response="fdcdd78e5b306ffed343d0ec3967f2e5", opaque="lEgVjogmIar2fg/t", qop=auth, nc=00000001, cnonce="e76b05db27a3b323"` + + expected := map[string]string{ + "username": "test", + "realm": "", + "nonce": "FRPnGdb8lvM1UHhi", + "uri": "/css?family=Source+Sans+Pro:400,700,400italic,700italic|Source+Code+Pro", + "algorithm": "MD5", + "response": "fdcdd78e5b306ffed343d0ec3967f2e5", + "opaque": "lEgVjogmIar2fg/t", + "qop": "auth", + "nc": "00000001", + "cnonce": "e76b05db27a3b323", + } + + res := ParsePairs(header) + + if !reflect.DeepEqual(res, expected) { + fmt.Printf("%#v\n", res) + t.Fatal("Failed to correctly parse pairs") + } + +}