Digest auth: reuse challenges across requests (#32842)
This commit is contained in:
parent
37d8aecbb2
commit
c28f2ff623
8 changed files with 247 additions and 17 deletions
63
util/transport/digest.go
Normal file
63
util/transport/digest.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/icholy/digest"
|
||||
)
|
||||
|
||||
// nonRfcSha256 is sent by some servers instead of the RFC 7616 compliant SHA-256
|
||||
const nonRfcSha256 = "SHA256"
|
||||
|
||||
// Digest creates an http transport performing digest auth. The challenge is
|
||||
// cached per host, so all requests after the first authenticate preemptively
|
||||
// instead of paying a 401 challenge round trip each time (RFC 7616 §3.3).
|
||||
func Digest(user, password string, base http.RoundTripper) http.RoundTripper {
|
||||
return &digest.Transport{
|
||||
Username: user,
|
||||
Password: password,
|
||||
Transport: base,
|
||||
FindChallenge: digestChallenge,
|
||||
Digest: digestCredentials,
|
||||
}
|
||||
}
|
||||
|
||||
// digestChallenge additionally accepts challenges announcing the non-RFC SHA256
|
||||
func digestChallenge(h http.Header) (*digest.Challenge, error) {
|
||||
chal, err := digest.FindChallenge(h)
|
||||
if err == nil || !errors.Is(err, digest.ErrNoChallenge) {
|
||||
return chal, err
|
||||
}
|
||||
|
||||
for _, header := range h.Values("WWW-Authenticate") {
|
||||
if !digest.IsDigest(header) {
|
||||
continue
|
||||
}
|
||||
if chal, err := digest.ParseChallenge(header); err == nil && strings.EqualFold(chal.Algorithm, nonRfcSha256) {
|
||||
return chal, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// digestCredentials hashes a non-RFC SHA256 challenge as SHA-256, echoing back
|
||||
// the spelling the server used
|
||||
func digestCredentials(_ *http.Request, chal *digest.Challenge, opt digest.Options) (*digest.Credentials, error) {
|
||||
if !strings.EqualFold(chal.Algorithm, nonRfcSha256) {
|
||||
return digest.Digest(chal, opt)
|
||||
}
|
||||
|
||||
rfc := *chal
|
||||
rfc.Algorithm = "SHA-256"
|
||||
|
||||
cred, err := digest.Digest(&rfc, opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred.Algorithm = chal.Algorithm
|
||||
|
||||
return cred, nil
|
||||
}
|
||||
173
util/transport/digest_test.go
Normal file
173
util/transport/digest_test.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package transport
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
digestUser = "admin"
|
||||
digestPass = "secret"
|
||||
digestRealm = "shellypro4pm-f008d1d8b8b8"
|
||||
)
|
||||
|
||||
var reDigestParam = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,\s]+))`)
|
||||
|
||||
func digestParams(auth string) map[string]string {
|
||||
res := make(map[string]string)
|
||||
for _, m := range reDigestParam.FindAllStringSubmatch(auth, -1) {
|
||||
if m[2] != "" {
|
||||
res[m[1]] = m[2]
|
||||
} else {
|
||||
res[m[1]] = m[3]
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func sha256hex(parts ...string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, ":")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// digestDevice verifies digest credentials the way a Shelly Gen2+ device does:
|
||||
// one nonce per challenge, reusable while nc is strictly increasing.
|
||||
type digestDevice struct {
|
||||
mu sync.Mutex
|
||||
|
||||
algorithm string // algorithm advertised in the challenge
|
||||
nonce string
|
||||
seq int
|
||||
lastNC int
|
||||
|
||||
challenges int // nonces minted
|
||||
unauth int // requests without Authorization
|
||||
ncSeen []int
|
||||
seenAlg string // algorithm echoed by the client
|
||||
}
|
||||
|
||||
func (d *digestDevice) challengeLocked(w http.ResponseWriter) {
|
||||
d.seq++
|
||||
d.nonce = fmt.Sprintf("nonce-%d", d.seq)
|
||||
d.lastNC = 0
|
||||
d.challenges++
|
||||
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Digest qop="auth", realm=%q, nonce=%q, algorithm=%s`,
|
||||
digestRealm, d.nonce, d.algorithm))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (d *digestDevice) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
d.unauth++
|
||||
d.challengeLocked(w)
|
||||
return
|
||||
}
|
||||
|
||||
p := digestParams(auth)
|
||||
d.seenAlg = p["algorithm"]
|
||||
nc, err := strconv.ParseInt(p["nc"], 16, 64)
|
||||
if p["nonce"] != d.nonce || err != nil || int(nc) <= d.lastNC {
|
||||
d.challengeLocked(w)
|
||||
return
|
||||
}
|
||||
|
||||
// the device always hashes with SHA-256, whatever spelling it advertised
|
||||
ha1 := sha256hex(digestUser, digestRealm, digestPass)
|
||||
ha2 := sha256hex(r.Method, r.URL.RequestURI())
|
||||
if want := sha256hex(ha1, d.nonce, p["nc"], p["cnonce"], "auth", ha2); want != p["response"] {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
d.lastNC = int(nc)
|
||||
d.ncSeen = append(d.ncSeen, int(nc))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func testDigestClient(t *testing.T, algorithm string) (*digestDevice, *http.Client, string) {
|
||||
t.Helper()
|
||||
|
||||
dev := &digestDevice{algorithm: algorithm}
|
||||
srv := httptest.NewServer(dev)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return dev, &http.Client{Transport: Digest(digestUser, digestPass, nil)}, srv.URL
|
||||
}
|
||||
|
||||
// TestDigestPreemptive asserts the challenge is reused. Devices bound the
|
||||
// number of nonces they issue, so re-challenging per request gets us throttled.
|
||||
func TestDigestPreemptive(t *testing.T) {
|
||||
dev, client, uri := testDigestClient(t, "SHA-256")
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
resp, err := client.Get(uri + "/rpc/Switch.GetStatus")
|
||||
require.NoError(t, err, "request %d", i)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "request %d", i)
|
||||
}
|
||||
|
||||
dev.mu.Lock()
|
||||
defer dev.mu.Unlock()
|
||||
assert.Equal(t, 1, dev.challenges, "only the first request may trigger a challenge")
|
||||
assert.Equal(t, 1, dev.unauth, "only the first request may be unauthenticated")
|
||||
assert.Equal(t, []int{1, 2, 3, 4}, dev.ncSeen, "nc must increase across reuse")
|
||||
}
|
||||
|
||||
// TestDigestNonRfcAlgorithm covers servers advertising SHA256 instead of the
|
||||
// RFC 7616 compliant SHA-256. The non-RFC spelling takes a separate challenge
|
||||
// path, so it has to reuse challenges just the same.
|
||||
func TestDigestNonRfcAlgorithm(t *testing.T) {
|
||||
for _, algorithm := range []string{"SHA-256", "SHA256"} {
|
||||
t.Run(algorithm, func(t *testing.T) {
|
||||
dev, client, uri := testDigestClient(t, algorithm)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, err := client.Get(uri)
|
||||
require.NoError(t, err, "request %d", i)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "request %d", i)
|
||||
}
|
||||
|
||||
dev.mu.Lock()
|
||||
defer dev.mu.Unlock()
|
||||
assert.Equal(t, algorithm, dev.seenAlg, "client must echo the announced algorithm")
|
||||
assert.Equal(t, 1, dev.challenges)
|
||||
assert.Equal(t, 1, dev.unauth)
|
||||
assert.Equal(t, []int{1, 2, 3}, dev.ncSeen)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestWrongPassword asserts a rejected response surfaces to the caller
|
||||
// instead of being retried until the device throttles us.
|
||||
func TestDigestWrongPassword(t *testing.T) {
|
||||
dev, _, uri := testDigestClient(t, "SHA-256")
|
||||
client := &http.Client{Transport: Digest(digestUser, "wrong", nil)}
|
||||
|
||||
resp, err := client.Get(uri)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
dev.mu.Lock()
|
||||
defer dev.mu.Unlock()
|
||||
assert.Equal(t, 1, dev.challenges, "a rejected response must not be retried")
|
||||
assert.Equal(t, 1, dev.unauth)
|
||||
assert.Empty(t, dev.ncSeen)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue