diff --git a/ex/exchange.go b/ex/exchange.go index c533e98..d80ec36 100644 --- a/ex/exchange.go +++ b/ex/exchange.go @@ -24,6 +24,7 @@ type Exchange struct { cappedBody io.Reader RoutedPath string ServerSpec spec.Spec + bodyBytes []byte } type HandlerFn func(ex *Exchange) response.Response @@ -202,19 +203,23 @@ func (ex Exchange) FindIncomingIPAddress() string { return ipStr } -func (ex Exchange) BodyBytes() []byte { - if ex.cappedBody == nil { - return nil - } - if bodyBytes, err := io.ReadAll(ex.cappedBody); err != nil { - fmt.Println("Error reading request payload", err) - return nil - } else { - return bodyBytes +func (ex *Exchange) BodyBytes() []byte { + if ex.bodyBytes == nil { + if ex.Request.Body == nil { + return nil + } + if bodyBytes, err := io.ReadAll(ex.cappedBody); err != nil { + fmt.Println("Error reading request payload", err) + return nil + } else { + ex.bodyBytes = bodyBytes + ex.Request.Body = io.NopCloser(strings.NewReader(string(ex.bodyBytes))) + } } + return ex.bodyBytes } -func (ex Exchange) BodyString() string { +func (ex *Exchange) BodyString() string { return string(ex.BodyBytes()) } diff --git a/ex/exchange_test_utils.go b/ex/exchange_test_utils.go index d9d02e1..6904bef 100644 --- a/ex/exchange_test_utils.go +++ b/ex/exchange_test_utils.go @@ -1,8 +1,11 @@ package ex import ( + "bytes" + "io" "net/http" "net/url" + "strconv" "github.com/sharat87/httpbun/response" "github.com/sharat87/httpbun/server/spec" @@ -13,8 +16,19 @@ func InvokeHandlerForTest(path string, req http.Request, routePat string, fn Han if req.URL != nil { panic("req.URL must be nil") } + // Prepend a `/` to the path to ensure `req.URL.Path` is consistent inside the handler. Otherwise, the hash + // computation in digest auth will fail, since it depends on the URL path. req.URL, _ = url.Parse("http://localhost/" + path) + if req.Body != nil { + bodyBytes, _ := io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + if req.Header == nil { + req.Header = http.Header{} + } + req.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes))) + } + ex := New( nil, &req, diff --git a/routes/auth/digest_test.go b/routes/auth/digest_test.go index 86103aa..8015385 100644 --- a/routes/auth/digest_test.go +++ b/routes/auth/digest_test.go @@ -5,6 +5,12 @@ import ( "net/url" "testing" + "fmt" + "io" + "regexp" + "strings" + + "github.com/sharat87/httpbun/server/spec" "github.com/stretchr/testify/suite" "github.com/sharat87/httpbun/ex" @@ -46,12 +52,14 @@ func (s *DigestSuite) TestDigestAuthWithValidUsernameAndPasswordButMissingCreden } func (s *DigestSuite) TestComputeDigestAuthResponse() { - fakeEx := &ex.Exchange{ - Request: &http.Request{ + fakeEx := ex.New( + nil, + &http.Request{ Method: "GET", URL: &url.URL{Path: "/digest-auth/auth/user/pass"}, }, - } + spec.Spec{PathPrefix: ""}, + ) s.Equal("", fakeEx.BodyString()) @@ -70,12 +78,14 @@ func (s *DigestSuite) TestComputeDigestAuthResponse() { } func (s *DigestSuite) TestComputeDigestAuthIntResponse() { - fakeEx := &ex.Exchange{ - Request: &http.Request{ + fakeEx := ex.New( + nil, + &http.Request{ Method: "GET", URL: &url.URL{Path: "/digest-auth/auth-int/user/pass"}, }, - } + spec.Spec{PathPrefix: ""}, + ) s.Equal("", fakeEx.BodyString()) @@ -92,3 +102,151 @@ func (s *DigestSuite) TestComputeDigestAuthIntResponse() { s.NoError(err) s.Equal("feb28fc95b61742fa4afd0ad8b630026", response) } + +func (s *DigestSuite) TestDigestAuthWithMultipleQopValues() { + // 1. First, make a request without credentials to get the nonce from the WWW-Authenticate header. + resp1 := ex.InvokeHandlerForTest( + "digest-auth/auth,auth-int/user/pass", + http.Request{}, + DigestAuthRoute, + handleAuthDigest, + ) + + s.Equal(401, resp1.Status) + wwwAuthHeader := resp1.Header.Get("WWW-Authenticate") + s.NotEmpty(wwwAuthHeader) + + // 2. Parse the WWW-Authenticate header to get the nonce. + nonceRegex := regexp.MustCompile(`nonce="([^"]+)"`) + matches := nonceRegex.FindStringSubmatch(wwwAuthHeader) + s.Len(matches, 2) + nonce := matches[1] + + // 3. Construct the Authorization header. + username := "user" + password := "pass" + cnonce := "0a4f113b" + nc := "00000001" + qop := "auth" + uri := "/digest-auth/auth,auth-int/user/pass" + + fakeEx := ex.New( + nil, + &http.Request{ + Method: "GET", + URL: &url.URL{Path: uri}, + }, + spec.Spec{PathPrefix: ""}, + ) + + response, err := computeDigestAuthResponse( + username, + password, + nonce, + nc, + cnonce, + qop, + fakeEx, + ) + s.NoError(err) + + authHeader := fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s", uri="%s", qop=%s, nc=%s, cnonce="%s", response="%s"`, + username, REALM, nonce, uri, qop, nc, cnonce, response, + ) + + // 4. Make the second request with the Authorization header. + req := http.Request{ + Header: http.Header{"Authorization": []string{authHeader}}, + Method: "GET", + } + resp2 := ex.InvokeHandlerForTest( + "digest-auth/auth,auth-int/user/pass", + req, + DigestAuthRoute, + handleAuthDigest, + ) + + s.Equal(200, resp2.Status) + body, ok := resp2.Body.(map[string]any) + s.True(ok) + s.Equal(true, body["authenticated"]) + s.Equal(username, body["user"]) +} + +func (s *DigestSuite) TestDigestAuthWithAuthIntQop() { + // 1. First, make a request without credentials to get the nonce from the WWW-Authenticate header. + resp1 := ex.InvokeHandlerForTest( + "digest-auth/auth-int/user/pass", + http.Request{}, + DigestAuthRoute, + handleAuthDigest, + ) + + s.Equal(401, resp1.Status) + wwwAuthHeader := resp1.Header.Get("WWW-Authenticate") + s.NotEmpty(wwwAuthHeader) + + // 2. Parse the WWW-Authenticate header to get the nonce. + nonceRegex := regexp.MustCompile(`nonce="([^"]+)"`) + matches := nonceRegex.FindStringSubmatch(wwwAuthHeader) + s.Len(matches, 2) + nonce := matches[1] + + // 3. Construct the Authorization header. + username := "user" + password := "pass" + cnonce := "0a4f113b" + nc := "00000001" + qop := "auth-int" + uri := "/digest-auth/auth-int/user/pass" + body := "test body" + + fakeEx := ex.New( + nil, + &http.Request{ + Method: "POST", + URL: &url.URL{Path: uri}, + Body: io.NopCloser(strings.NewReader(body)), + }, + spec.Spec{PathPrefix: ""}, + ) + + response, err := computeDigestAuthResponse( + username, + password, + nonce, + nc, + cnonce, + qop, + fakeEx, + ) + s.NoError(err) + + authHeader := fmt.Sprintf( + `Digest username="%s", realm="%s", nonce="%s", uri="%s", qop=%s, nc=%s, cnonce="%s", response="%s"`, + username, REALM, nonce, uri, qop, nc, cnonce, response, + ) + + // 4. Make the second request with the Authorization header. + req := http.Request{ + Header: http.Header{"Authorization": []string{authHeader}}, + Method: "POST", + Body: io.NopCloser(strings.NewReader(body)), + } + resp2 := ex.InvokeHandlerForTest( + "digest-auth/auth-int/user/pass", + req, + DigestAuthRoute, + handleAuthDigest, + ) + + s.Equal(200, resp2.Status) + respBody, ok := resp2.Body.(map[string]any) + s.True(ok) + s.Equal(true, respBody["authenticated"]) + s.Equal(username, respBody["user"]) + + // Verify that the body is still readable + s.Equal(body, fakeEx.BodyString()) +} diff --git a/routes/auth/handler.go b/routes/auth/handler.go index bf61914..8defcab 100644 --- a/routes/auth/handler.go +++ b/routes/auth/handler.go @@ -99,8 +99,19 @@ func handleAuthDigest(ex *ex.Exchange) response.Response { givenDetails := parseDigestAuthHeader(authHeader) // QOP check. - if expectedQop != "" && givenDetails["qop"] != expectedQop { - return unauthorizedDigest(expectedQop, requireCookie, fmt.Sprintf("Error: %q\n", "Unsupported QOP")) + if expectedQop != "" { + supportedQops := strings.Split(expectedQop, ",") + givenQop := givenDetails["qop"] + var isSupported bool + for _, qop := range supportedQops { + if qop == givenQop { + isSupported = true + break + } + } + if !isSupported { + return unauthorizedDigest(expectedQop, requireCookie, fmt.Sprintf("Error: %q\n", "Unsupported QOP")) + } } // Nonce check. @@ -130,7 +141,7 @@ func handleAuthDigest(ex *ex.Exchange) response.Response { givenNonce, givenDetails["nc"], givenDetails["cnonce"], - expectedQop, + givenDetails["qop"], ex, ) if err != nil { @@ -145,6 +156,7 @@ func handleAuthDigest(ex *ex.Exchange) response.Response { } return response.Response{ + Status: http.StatusOK, Body: map[string]any{ "authenticated": true, "user": expectedUsername, diff --git a/routes/routes.go b/routes/routes.go index 950a243..afa2cc0 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -85,7 +85,8 @@ func handleHealth(_ *ex.Exchange) response.Response { func handlePayload(ex *ex.Exchange) response.Response { return response.New(http.StatusOK, http.Header{ c.ContentType: ex.Request.Header[c.ContentType], - }, ex.BodyBytes()) + }, ex.BodyBytes(), + ) } func handleStatus(ex *ex.Exchange) response.Response {