Skip to content

Commit 841a714

Browse files
committed
Add path exclusion support to BasicAuth authentication
Signed-off-by: Kacper Rzetelski <[email protected]>
1 parent ad41e17 commit 841a714

18 files changed

+1222
-229
lines changed

docs/web-configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ http_server_config:
125125
# required. Passwords are hashed with bcrypt.
126126
basic_auth_users:
127127
[ <string>: <secret> ... ]
128+
129+
# A list of HTTP paths to be excepted from authentication.
130+
auth_excluded_paths:
131+
[ - <string> ]
128132
```
129133

130134
[A sample configuration file](web-config.yml) is provided.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2023 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package authentication
15+
16+
import (
17+
"log/slog"
18+
"net/http"
19+
)
20+
21+
// HTTPChallenge contains information which can used by an HTTP server to challenge a client request using a challenge-response authentication framework.
22+
// https://datatracker.ietf.org/doc/html/rfc7235#section-2.1
23+
type HTTPChallenge struct {
24+
Scheme string
25+
}
26+
27+
type Authenticator interface {
28+
Authenticate(*http.Request) (bool, string, *HTTPChallenge, error)
29+
}
30+
31+
type AuthenticatorFunc func(r *http.Request) (bool, string, *HTTPChallenge, error)
32+
33+
func (f AuthenticatorFunc) Authenticate(r *http.Request) (bool, string, *HTTPChallenge, error) {
34+
return f(r)
35+
}
36+
37+
func WithAuthentication(handler http.Handler, authenticator Authenticator, logger *slog.Logger) http.Handler {
38+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+
ok, denyReason, httpChallenge, err := authenticator.Authenticate(r)
40+
if err != nil {
41+
logger.Error("Unable to authenticate", "URI", r.RequestURI, "err", err.Error())
42+
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
43+
return
44+
}
45+
46+
if ok {
47+
handler.ServeHTTP(w, r)
48+
return
49+
}
50+
51+
if httpChallenge != nil {
52+
w.Header().Set("WWW-Authenticate", httpChallenge.Scheme)
53+
}
54+
55+
logger.Warn("Unauthenticated request", "URI", r.RequestURI, "denyReason", denyReason)
56+
http.Error(w, denyReason, http.StatusUnauthorized)
57+
})
58+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright 2023 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package authentication
15+
16+
import (
17+
"errors"
18+
"io"
19+
"net/http"
20+
"net/http/httptest"
21+
"reflect"
22+
"testing"
23+
24+
"github.com/prometheus/exporter-toolkit/web/authentication/testhelpers"
25+
)
26+
27+
func TestWithAuthentication(t *testing.T) {
28+
t.Parallel()
29+
30+
logger := testhelpers.NewNoOpLogger()
31+
32+
tt := []struct {
33+
Name string
34+
Authenticator Authenticator
35+
ExpectedStatusCode int
36+
ExpectedBody string
37+
ExpectedWWWAuthenticateHeader string
38+
}{
39+
{
40+
Name: "Accepting authenticator",
41+
Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, *HTTPChallenge, error) {
42+
return true, "", nil, nil
43+
}),
44+
ExpectedStatusCode: http.StatusOK,
45+
ExpectedBody: "",
46+
ExpectedWWWAuthenticateHeader: "",
47+
},
48+
{
49+
Name: "Denying authenticator without http challenge",
50+
Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, *HTTPChallenge, error) {
51+
return false, "deny reason", nil, nil
52+
}),
53+
ExpectedStatusCode: http.StatusUnauthorized,
54+
ExpectedBody: "deny reason\n",
55+
ExpectedWWWAuthenticateHeader: "",
56+
},
57+
{
58+
Name: "Denying authenticator with http challenge",
59+
Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, *HTTPChallenge, error) {
60+
httpChallenge := &HTTPChallenge{
61+
Scheme: "test",
62+
}
63+
return false, "deny reason", httpChallenge, nil
64+
}),
65+
ExpectedStatusCode: http.StatusUnauthorized,
66+
ExpectedBody: "deny reason\n",
67+
ExpectedWWWAuthenticateHeader: "test",
68+
},
69+
{
70+
Name: "Erroring authenticator",
71+
Authenticator: AuthenticatorFunc(func(_ *http.Request) (bool, string, *HTTPChallenge, error) {
72+
return false, "", nil, errors.New("error authenticating")
73+
}),
74+
ExpectedStatusCode: http.StatusInternalServerError,
75+
ExpectedBody: "Internal Server Error\n",
76+
ExpectedWWWAuthenticateHeader: "",
77+
},
78+
}
79+
80+
for _, tc := range tt {
81+
t.Run(tc.Name, func(t *testing.T) {
82+
t.Parallel()
83+
84+
req := testhelpers.MakeDefaultRequest(t)
85+
86+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+
w.WriteHeader(http.StatusOK)
88+
})
89+
90+
rr := httptest.NewRecorder()
91+
authHandler := WithAuthentication(handler, tc.Authenticator, logger)
92+
authHandler.ServeHTTP(rr, req)
93+
gotResult := rr.Result()
94+
95+
gotBodyBytes, err := io.ReadAll(gotResult.Body)
96+
if err != nil {
97+
t.Fatalf("unexpected error reading response body: %v", err)
98+
}
99+
gotBody := string(gotBodyBytes)
100+
101+
gotWWWAuthenticateHeader := gotResult.Header.Get("WWW-Authenticate")
102+
103+
if tc.ExpectedStatusCode != gotResult.StatusCode {
104+
t.Errorf("Expected status code %q, got %q", tc.ExpectedStatusCode, gotResult.StatusCode)
105+
}
106+
107+
if tc.ExpectedBody != gotBody {
108+
t.Errorf("Expected body %q, got %q", tc.ExpectedBody, gotBody)
109+
}
110+
111+
if !reflect.DeepEqual(tc.ExpectedWWWAuthenticateHeader, gotWWWAuthenticateHeader) {
112+
t.Errorf("Expected 'WWW-Authenticate' header %v, got %v", tc.ExpectedWWWAuthenticateHeader, gotWWWAuthenticateHeader)
113+
}
114+
})
115+
}
116+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright 2023 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package basicauth
15+
16+
import (
17+
"encoding/hex"
18+
"net/http"
19+
"strings"
20+
"sync"
21+
22+
"github.com/prometheus/common/config"
23+
"github.com/prometheus/exporter-toolkit/web/authentication"
24+
"golang.org/x/crypto/bcrypt"
25+
)
26+
27+
const denyReasonUnauthorized = "Unauthorized"
28+
29+
// BasicAuthAuthenticator authenticates requests using basic auth.
30+
type BasicAuthAuthenticator struct {
31+
users map[string]config.Secret
32+
33+
cache *cache
34+
// bcryptMtx is there to ensure that bcrypt.CompareHashAndPassword is run
35+
// only once in parallel as this is CPU intensive.
36+
bcryptMtx sync.Mutex
37+
}
38+
39+
func (b *BasicAuthAuthenticator) Authenticate(r *http.Request) (bool, string, *authentication.HTTPChallenge, error) {
40+
httpChallenge := &authentication.HTTPChallenge{
41+
Scheme: "Basic",
42+
}
43+
44+
user, pass, auth := r.BasicAuth()
45+
46+
if !auth {
47+
return false, denyReasonUnauthorized, httpChallenge, nil
48+
}
49+
50+
hashedPassword, validUser := b.users[user]
51+
52+
if !validUser {
53+
// The user is not found. Use a fixed password hash to
54+
// prevent user enumeration by timing requests.
55+
// This is a bcrypt-hashed version of "fakepassword".
56+
hashedPassword = "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSi"
57+
}
58+
59+
cacheKey := strings.Join(
60+
[]string{
61+
hex.EncodeToString([]byte(user)),
62+
hex.EncodeToString([]byte(hashedPassword)),
63+
hex.EncodeToString([]byte(pass)),
64+
}, ":")
65+
authOk, ok := b.cache.get(cacheKey)
66+
67+
if !ok {
68+
// This user, hashedPassword, password is not cached.
69+
b.bcryptMtx.Lock()
70+
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(pass))
71+
b.bcryptMtx.Unlock()
72+
73+
authOk = validUser && err == nil
74+
b.cache.set(cacheKey, authOk)
75+
}
76+
77+
if authOk && validUser {
78+
return true, "", nil, nil
79+
}
80+
81+
return false, denyReasonUnauthorized, httpChallenge, nil
82+
}
83+
84+
func NewBasicAuthAuthenticator(users map[string]config.Secret) authentication.Authenticator {
85+
return &BasicAuthAuthenticator{
86+
cache: newCache(),
87+
users: users,
88+
}
89+
}
90+
91+
var _ authentication.Authenticator = &BasicAuthAuthenticator{}

0 commit comments

Comments
 (0)