Skip to content

Commit 6ea5d3c

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

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.

web/handler.go

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,9 @@
1616
package web
1717

1818
import (
19-
"encoding/hex"
2019
"fmt"
2120
"log/slog"
2221
"net/http"
23-
"strings"
24-
"sync"
2522

2623
"golang.org/x/crypto/bcrypt"
2724
)
@@ -79,10 +76,6 @@ type webHandler struct {
7976
tlsConfigPath string
8077
handler http.Handler
8178
logger *slog.Logger
82-
cache *cache
83-
// bcryptMtx is there to ensure that bcrypt.CompareHashAndPassword is run
84-
// only once in parallel as this is CPU intensive.
85-
bcryptMtx sync.Mutex
8679
}
8780

8881
func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -98,46 +91,5 @@ func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
9891
w.Header().Set(k, v)
9992
}
10093

101-
if len(c.Users) == 0 {
102-
u.handler.ServeHTTP(w, r)
103-
return
104-
}
105-
106-
user, pass, auth := r.BasicAuth()
107-
if auth {
108-
hashedPassword, validUser := c.Users[user]
109-
110-
if !validUser {
111-
// The user is not found. Use a fixed password hash to
112-
// prevent user enumeration by timing requests.
113-
// This is a bcrypt-hashed version of "fakepassword".
114-
hashedPassword = "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSi"
115-
}
116-
117-
cacheKey := strings.Join(
118-
[]string{
119-
hex.EncodeToString([]byte(user)),
120-
hex.EncodeToString([]byte(hashedPassword)),
121-
hex.EncodeToString([]byte(pass)),
122-
}, ":")
123-
authOk, ok := u.cache.get(cacheKey)
124-
125-
if !ok {
126-
// This user, hashedPassword, password is not cached.
127-
u.bcryptMtx.Lock()
128-
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(pass))
129-
u.bcryptMtx.Unlock()
130-
131-
authOk = validUser && err == nil
132-
u.cache.set(cacheKey, authOk)
133-
}
134-
135-
if authOk && validUser {
136-
u.handler.ServeHTTP(w, r)
137-
return
138-
}
139-
}
140-
141-
w.Header().Set("WWW-Authenticate", "Basic")
142-
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
94+
u.handler.ServeHTTP(w, r)
14395
}

web/handler_test.go

Lines changed: 0 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -17,182 +17,10 @@ import (
1717
"context"
1818
"net"
1919
"net/http"
20-
"sync"
2120
"testing"
2221
"time"
2322
)
2423

25-
// TestBasicAuthCache validates that the cache is working by calling a password
26-
// protected endpoint multiple times.
27-
func TestBasicAuthCache(t *testing.T) {
28-
server := &http.Server{
29-
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30-
w.Write([]byte("Hello World!"))
31-
}),
32-
}
33-
34-
done := make(chan struct{})
35-
t.Cleanup(func() {
36-
if err := server.Shutdown(context.Background()); err != nil {
37-
t.Fatal(err)
38-
}
39-
<-done
40-
})
41-
42-
go func() {
43-
flags := FlagConfig{
44-
WebListenAddresses: &([]string{port}),
45-
WebSystemdSocket: OfBool(false),
46-
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
47-
}
48-
ListenAndServe(server, &flags, testlogger)
49-
close(done)
50-
}()
51-
52-
waitForPort(t, port)
53-
54-
login := func(username, password string, code int) {
55-
client := &http.Client{}
56-
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
57-
if err != nil {
58-
t.Fatal(err)
59-
}
60-
req.SetBasicAuth(username, password)
61-
r, err := client.Do(req)
62-
if err != nil {
63-
t.Fatal(err)
64-
}
65-
if r.StatusCode != code {
66-
t.Fatalf("bad return code, expected %d, got %d", code, r.StatusCode)
67-
}
68-
}
69-
70-
// Initial logins, checking that it just works.
71-
login("alice", "alice123", 200)
72-
login("alice", "alice1234", 401)
73-
74-
var (
75-
start = make(chan struct{})
76-
wg sync.WaitGroup
77-
)
78-
wg.Add(300)
79-
for i := 0; i < 150; i++ {
80-
go func() {
81-
<-start
82-
login("alice", "alice123", 200)
83-
wg.Done()
84-
}()
85-
go func() {
86-
<-start
87-
login("alice", "alice1234", 401)
88-
wg.Done()
89-
}()
90-
}
91-
close(start)
92-
wg.Wait()
93-
}
94-
95-
// TestBasicAuthWithFakePassword validates that we can't login the "fakepassword" used in
96-
// to prevent user enumeration.
97-
func TestBasicAuthWithFakepassword(t *testing.T) {
98-
server := &http.Server{
99-
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100-
w.Write([]byte("Hello World!"))
101-
}),
102-
}
103-
104-
done := make(chan struct{})
105-
t.Cleanup(func() {
106-
if err := server.Shutdown(context.Background()); err != nil {
107-
t.Fatal(err)
108-
}
109-
<-done
110-
})
111-
112-
go func() {
113-
flags := FlagConfig{
114-
WebListenAddresses: &([]string{port}),
115-
WebSystemdSocket: OfBool(false),
116-
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
117-
}
118-
ListenAndServe(server, &flags, testlogger)
119-
close(done)
120-
}()
121-
122-
waitForPort(t, port)
123-
124-
login := func() {
125-
client := &http.Client{}
126-
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
127-
if err != nil {
128-
t.Fatal(err)
129-
}
130-
req.SetBasicAuth("fakeuser", "fakepassword")
131-
r, err := client.Do(req)
132-
if err != nil {
133-
t.Fatal(err)
134-
}
135-
if r.StatusCode != 401 {
136-
t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode)
137-
}
138-
}
139-
140-
// Login with a cold cache.
141-
login()
142-
// Login with the response cached.
143-
login()
144-
}
145-
146-
// TestByPassBasicAuthVuln tests for CVE-2022-46146.
147-
func TestByPassBasicAuthVuln(t *testing.T) {
148-
server := &http.Server{
149-
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
150-
w.Write([]byte("Hello World!"))
151-
}),
152-
}
153-
154-
done := make(chan struct{})
155-
t.Cleanup(func() {
156-
if err := server.Shutdown(context.Background()); err != nil {
157-
t.Fatal(err)
158-
}
159-
<-done
160-
})
161-
162-
go func() {
163-
flags := FlagConfig{
164-
WebListenAddresses: &([]string{port}),
165-
WebSystemdSocket: OfBool(false),
166-
WebConfigFile: OfString("testdata/web_config_users_noTLS.good.yml"),
167-
}
168-
ListenAndServe(server, &flags, testlogger)
169-
close(done)
170-
}()
171-
172-
waitForPort(t, port)
173-
174-
login := func(username, password string) {
175-
client := &http.Client{}
176-
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
177-
if err != nil {
178-
t.Fatal(err)
179-
}
180-
req.SetBasicAuth(username, password)
181-
r, err := client.Do(req)
182-
if err != nil {
183-
t.Fatal(err)
184-
}
185-
if r.StatusCode != 401 {
186-
t.Fatalf("bad return code, expected %d, got %d", 401, r.StatusCode)
187-
}
188-
}
189-
190-
// Poison the cache.
191-
login("alice$2y$12$1DpfPeqF9HzHJt.EWswy1exHluGfbhnn3yXhR7Xes6m3WJqFg0Wby", "fakepassword")
192-
// Login with a wrong password.
193-
login("alice", "$2y$10$QOauhQNbBCuQDKes6eFzPeMqBSjb7Mr5DUmpZ/VcEd00UAV/LDeSifakepassword")
194-
}
195-
19624
// TestHTTPHeaders validates that HTTP headers are added correctly.
19725
func TestHTTPHeaders(t *testing.T) {
19826
server := &http.Server{
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+
}

0 commit comments

Comments
 (0)