Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions ex/exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Exchange struct {
cappedBody io.Reader
RoutedPath string
ServerSpec spec.Spec
bodyBytes []byte
}

type HandlerFn func(ex *Exchange) response.Response
Expand Down Expand Up @@ -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())
}

Expand Down
14 changes: 14 additions & 0 deletions ex/exchange_test_utils.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand Down
170 changes: 164 additions & 6 deletions routes/auth/digest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())

Expand All @@ -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())

Expand All @@ -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())
}
18 changes: 15 additions & 3 deletions routes/auth/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -130,7 +141,7 @@ func handleAuthDigest(ex *ex.Exchange) response.Response {
givenNonce,
givenDetails["nc"],
givenDetails["cnonce"],
expectedQop,
givenDetails["qop"],
ex,
)
if err != nil {
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down