From 1816b4b447f136680af7d6b04a339d8339cd676e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Thu, 14 Dec 2023 13:12:57 +0100 Subject: [PATCH] feat: implement support for http digest auth (resolve #352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ferdinand Mütsch --- config/http_config.go | 37 +++++-- config/http_config_test.go | 100 ++++++++++++++++-- ...nf.basic-auth-and-digest.too-much.bad.yaml | 6 ++ go.mod | 1 + go.sum | 11 ++ 5 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 config/testdata/http.conf.basic-auth-and-digest.too-much.bad.yaml diff --git a/config/http_config.go b/config/http_config.go index 4a926e8d..dcd6455c 100644 --- a/config/http_config.go +++ b/config/http_config.go @@ -21,6 +21,7 @@ import ( "crypto/x509" "encoding/json" "fmt" + "github.com/icholy/digest" "net" "net/http" "net/url" @@ -143,6 +144,11 @@ func (a *BasicAuth) SetDirectory(dir string) { a.UsernameFile = JoinDir(dir, a.UsernameFile) } +type DigestAuth struct { + Username string `yaml:"username" json:"username"` + Password Secret `yaml:"password,omitempty" json:"password,omitempty"` +} + // Authorization contains HTTP authorization credentials. type Authorization struct { Type string `yaml:"type,omitempty" json:"type,omitempty"` @@ -288,7 +294,8 @@ func LoadHTTPConfigFile(filename string) (*HTTPClientConfig, []byte, error) { // HTTPClientConfig configures an HTTP client. type HTTPClientConfig struct { // The HTTP basic authentication credentials for the targets. - BasicAuth *BasicAuth `yaml:"basic_auth,omitempty" json:"basic_auth,omitempty"` + BasicAuth *BasicAuth `yaml:"basic_auth,omitempty" json:"basic_auth,omitempty"` + DigestAuth *DigestAuth `yaml:"digest_auth,omitempty" json:"digest_auth,omitempty"` // The HTTP authorization credentials for the targets. Authorization *Authorization `yaml:"authorization,omitempty" json:"authorization,omitempty"` // The OAuth2 client credentials used to fetch a token for the targets. @@ -333,8 +340,8 @@ func (c *HTTPClientConfig) Validate() error { if len(c.BearerToken) > 0 && len(c.BearerTokenFile) > 0 { return fmt.Errorf("at most one of bearer_token & bearer_token_file must be configured") } - if (c.BasicAuth != nil || c.OAuth2 != nil) && (len(c.BearerToken) > 0 || len(c.BearerTokenFile) > 0) { - return fmt.Errorf("at most one of basic_auth, oauth2, bearer_token & bearer_token_file must be configured") + if (c.BasicAuth != nil || c.OAuth2 != nil || c.DigestAuth != nil) && (len(c.BearerToken) > 0 || len(c.BearerTokenFile) > 0) { + return fmt.Errorf("at most one of basic_auth, digest_auth, oauth2, bearer_token & bearer_token_file must be configured") } if c.BasicAuth != nil && (string(c.BasicAuth.Username) != "" && c.BasicAuth.UsernameFile != "") { return fmt.Errorf("at most one of basic_auth username & username_file must be configured") @@ -356,8 +363,8 @@ func (c *HTTPClientConfig) Validate() error { if strings.ToLower(c.Authorization.Type) == "basic" { return fmt.Errorf(`authorization type cannot be set to "basic", use "basic_auth" instead`) } - if c.BasicAuth != nil || c.OAuth2 != nil { - return fmt.Errorf("at most one of basic_auth, oauth2 & authorization must be configured") + if c.BasicAuth != nil || c.OAuth2 != nil || c.DigestAuth != nil { + return fmt.Errorf("at most one of basic_auth, digest_auth, oauth2 & authorization must be configured") } } else { if len(c.BearerToken) > 0 { @@ -373,7 +380,10 @@ func (c *HTTPClientConfig) Validate() error { } if c.OAuth2 != nil { if c.BasicAuth != nil { - return fmt.Errorf("at most one of basic_auth, oauth2 & authorization must be configured") + return fmt.Errorf("at most one of basic_auth, digest_auth, oauth2 & authorization must be configured") + } + if c.DigestAuth != nil { + return fmt.Errorf("at most one of basic_auth, digest_auth, oauth2 & authorization must be configured") } if len(c.OAuth2.ClientID) == 0 { return fmt.Errorf("oauth2 client_id must be configured") @@ -388,6 +398,9 @@ func (c *HTTPClientConfig) Validate() error { return fmt.Errorf("at most one of oauth2 client_secret & client_secret_file must be configured") } } + if c.DigestAuth != nil && (c.BasicAuth != nil || c.Authorization != nil) { + return fmt.Errorf("at most one of basic_auth, digest_auth, oauth2 & authorization must be configured") + } if err := c.ProxyConfig.Validate(); err != nil { return err } @@ -563,6 +576,10 @@ func NewRoundTripperFromConfig(cfg HTTPClientConfig, name string, optFuncs ...HT rt = NewBasicAuthRoundTripper(cfg.BasicAuth.Username, cfg.BasicAuth.Password, cfg.BasicAuth.UsernameFile, cfg.BasicAuth.PasswordFile, rt) } + if cfg.DigestAuth != nil { + rt = NewDigestAuthRoundTripper(cfg.DigestAuth.Username, cfg.DigestAuth.Password, rt) + } + if cfg.OAuth2 != nil { rt = NewOAuth2RoundTripper(cfg.OAuth2, rt, &opts) } @@ -696,6 +713,14 @@ func (rt *basicAuthRoundTripper) CloseIdleConnections() { } } +func NewDigestAuthRoundTripper(username string, password Secret, rt http.RoundTripper) http.RoundTripper { + return &digest.Transport{ + Username: username, + Password: string(password), + Transport: rt, + } +} + type oauth2RoundTripper struct { config *OAuth2 rt http.RoundTripper diff --git a/config/http_config_test.go b/config/http_config_test.go index 72d1e798..6a93c306 100644 --- a/config/http_config_test.go +++ b/config/http_config_test.go @@ -15,8 +15,10 @@ package config import ( "context" + "crypto/md5" "crypto/tls" "crypto/x509" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -77,7 +79,7 @@ var invalidHTTPClientConfigs = []struct { }, { httpClientConfigFile: "testdata/http.conf.empty.bad.yml", - errMsg: "at most one of basic_auth, oauth2, bearer_token & bearer_token_file must be configured", + errMsg: "at most one of basic_auth, digest_auth, oauth2, bearer_token & bearer_token_file must be configured", }, { httpClientConfigFile: "testdata/http.conf.basic-auth.too-much.bad.yaml", @@ -97,11 +99,15 @@ var invalidHTTPClientConfigs = []struct { }, { httpClientConfigFile: "testdata/http.conf.basic-auth-and-auth-creds.too-much.bad.yaml", - errMsg: "at most one of basic_auth, oauth2 & authorization must be configured", + errMsg: "at most one of basic_auth, digest_auth, oauth2 & authorization must be configured", }, { httpClientConfigFile: "testdata/http.conf.basic-auth-and-oauth2.too-much.bad.yaml", - errMsg: "at most one of basic_auth, oauth2 & authorization must be configured", + errMsg: "at most one of basic_auth, digest_auth, oauth2 & authorization must be configured", + }, + { + httpClientConfigFile: "testdata/http.conf.basic-auth-and-digest.too-much.bad.yaml", + errMsg: "at most one of basic_auth, digest_auth, oauth2 & authorization must be configured", }, { httpClientConfigFile: "testdata/http.conf.auth-creds-no-basic.bad.yaml", @@ -312,6 +318,31 @@ func TestNewClientFromConfig(t *testing.T) { fmt.Fprint(w, ExpectedMessage) } }, + }, { + clientConfig: HTTPClientConfig{ + BasicAuth: &BasicAuth{ + Username: ExpectedUsername, + Password: ExpectedPassword, + }, + TLSConfig: TLSConfig{ + CAFile: TLSCAChainPath, + CertFile: ClientCertificatePath, + KeyFile: ClientKeyNoPassPath, + ServerName: "", + InsecureSkipVerify: false}, + }, + handler: func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok { + fmt.Fprintf(w, "The Authorization header wasn't set") + } else if ExpectedUsername != username { + fmt.Fprintf(w, "The expected username (%s) differs from the obtained username (%s).", ExpectedUsername, username) + } else if ExpectedPassword != password { + fmt.Fprintf(w, "The expected password (%s) differs from the obtained password (%s).", ExpectedPassword, password) + } else { + fmt.Fprint(w, ExpectedMessage) + } + }, }, { clientConfig: HTTPClientConfig{ Authorization: &Authorization{ @@ -335,7 +366,7 @@ func TestNewClientFromConfig(t *testing.T) { }, }, { clientConfig: HTTPClientConfig{ - BasicAuth: &BasicAuth{ + DigestAuth: &DigestAuth{ Username: ExpectedUsername, Password: ExpectedPassword, }, @@ -347,14 +378,61 @@ func TestNewClientFromConfig(t *testing.T) { InsecureSkipVerify: false}, }, handler: func(w http.ResponseWriter, r *http.Request) { - username, password, ok := r.BasicAuth() - if !ok { - fmt.Fprintf(w, "The Authorization header wasn't set") - } else if ExpectedUsername != username { - fmt.Fprintf(w, "The expected username (%s) differs from the obtained username (%s).", ExpectedUsername, username) - } else if ExpectedPassword != password { - fmt.Fprintf(w, "The expected password (%s) differs from the obtained password (%s).", ExpectedPassword, password) + // Example server response header: + // WWW-Authenticate: Digest realm="prometheus", nonce="43568ca162f46c3bcc57ecae193b3159", qop="auth", opaque="3bc9f19d8195721e24469ff255750f8c", algorithm=MD5, stale=FALSE + // + // Example client request header: + // Authorization: Digest username="foo", realm="prometheus", nonce="43568ca162f46c3bcc57ecae193b3159", uri="/", cnonce="NDA2M2JmYzQ2YTQ4OTQ0OTQ1NzE0NmI3ZmYyY2YyNzU=", nc=00000001, qop=auth, response="fe543d7eeb2d2f0aba8d100a1f076909", opaque="3bc9f19d8195721e24469ff255750f8c", algorithm=MD5 + + const ( + nonce = "43568ca162f46c3bcc57ecae193b3159" + realm = "prometheus" + ) + + if authHeader := r.Header.Get("Authorization"); authHeader == "" { + // first request + w.Header().Set("www-authenticate", "Digest realm=\""+realm+"\", nonce=\""+nonce+"\", qop=\"auth\", opaque=\"3bc9f19d8195721e24469ff255750f8c\", algorithm=MD5, stale=FALSE") + w.WriteHeader(401) } else { + // second, authenticated request + if !strings.HasPrefix(authHeader, "Digest") { + fmt.Fprint(w, "Request does not contain a valid digest auth header") + return + } + + digestComponents := make(map[string]string) + for _, p := range strings.Split(authHeader, ", ")[1:] { + kvParts := strings.Split(p, "=") + digestComponents[kvParts[0]] = strings.TrimSpace(strings.Trim(kvParts[1], "\"")) + } + + if v := digestComponents["realm"]; v != realm { + fmt.Fprintf(w, "Digest auth with wrong realm (%s)", v) + return + } + if v := digestComponents["nonce"]; v != nonce { + fmt.Fprintf(w, "Digest auth with wrong nonce (%s)", v) + return + } + + hashMD5 := func(s string) string { + hasher := md5.New() + hasher.Write([]byte(s)) + return hex.EncodeToString(hasher.Sum(nil)) + } + + hash1Str := fmt.Sprintf("%s:%s:%s", ExpectedUsername, realm, ExpectedPassword) + hash1 := hashMD5(hash1Str) + hash2Str := fmt.Sprintf("GET:%s", digestComponents["uri"]) + hash2 := hashMD5(hash2Str) + responseStr := fmt.Sprintf("%s:%s:%s:%s:%s:%s", hash1, nonce, digestComponents["nc"], digestComponents["cnonce"], digestComponents["qop"], hash2) + response := hashMD5(responseStr) + + if response != digestComponents["response"] { + fmt.Fprintf(w, "Digest auth failed, response hashes didn't match") + return + } + fmt.Fprint(w, ExpectedMessage) } }, diff --git a/config/testdata/http.conf.basic-auth-and-digest.too-much.bad.yaml b/config/testdata/http.conf.basic-auth-and-digest.too-much.bad.yaml new file mode 100644 index 00000000..31bcf319 --- /dev/null +++ b/config/testdata/http.conf.basic-auth-and-digest.too-much.bad.yaml @@ -0,0 +1,6 @@ +basic_auth: + username: user + password: foo +digest_auth: + username: user + password: foo diff --git a/go.mod b/go.mod index 0de14e68..097e18bd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.20 require ( github.com/alecthomas/kingpin/v2 v2.4.0 github.com/go-kit/log v0.2.1 + github.com/icholy/digest v0.1.22 github.com/julienschmidt/httprouter v1.3.0 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f diff --git a/go.sum b/go.sum index a8ab6963..bf2aca66 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,11 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/icholy/digest v0.1.22 h1:dRIwCjtAcXch57ei+F0HSb5hmprL873+q7PoVojdMzM= +github.com/icholy/digest v0.1.22/go.mod h1:uLAeDdWKIWNFMH0wqbwchbTQOmJWhzSnL7zmqSPqEEc= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= @@ -31,6 +34,8 @@ github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvls github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= @@ -41,17 +46,20 @@ github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwa github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -60,6 +68,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= @@ -74,3 +83,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=