Skip to content

Commit 9873f0e

Browse files
committed
caddyauth: Allow user-configurable headers and status code
1 parent 3c003de commit 9873f0e

File tree

7 files changed

+376
-16
lines changed

7 files changed

+376
-16
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package integration
2+
3+
import (
4+
"encoding/base64"
5+
"net/http"
6+
"testing"
7+
8+
"github.com/caddyserver/caddy/v2/caddytest"
9+
)
10+
11+
func TestAuthentication(t *testing.T) {
12+
tester := caddytest.NewTester(t)
13+
tester.InitServer(`
14+
{
15+
"admin": {
16+
"listen": "localhost:2999"
17+
},
18+
"apps": {
19+
"pki": {
20+
"certificate_authorities": {
21+
"local": {
22+
"install_trust": false
23+
}
24+
}
25+
},
26+
"http": {
27+
"http_port": 9080,
28+
"https_port": 9443,
29+
"servers": {
30+
"srv0": {
31+
"listen": [
32+
":9080"
33+
],
34+
"routes": [
35+
{
36+
"match": [
37+
{
38+
"path": [
39+
"/basic"
40+
]
41+
}
42+
],
43+
"handle": [
44+
{
45+
"handler": "authentication",
46+
"providers": {
47+
"http_basic": {
48+
"hash_cache": {},
49+
"accounts": [
50+
{
51+
"username": "Aladdin",
52+
"password": "$2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa"
53+
}
54+
]
55+
}
56+
}
57+
}
58+
]
59+
},
60+
{
61+
"match": [
62+
{
63+
"path": [
64+
"/proxy"
65+
]
66+
}
67+
],
68+
"handle": [
69+
{
70+
"handler": "authentication",
71+
"status_code": 407,
72+
"providers": {
73+
"http_basic": {
74+
"hash_cache": {},
75+
"authorization_header": "Proxy-Authorization",
76+
"authenticate_header": "Proxy-Authenticate",
77+
"realm": "HTTP proxy",
78+
"accounts": [
79+
{
80+
"username": "Aladdin",
81+
"password": "$2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa"
82+
}
83+
]
84+
}
85+
}
86+
}
87+
]
88+
}
89+
]
90+
}
91+
}
92+
}
93+
}
94+
}
95+
`, "json")
96+
97+
assertHeader := func(tb testing.TB, resp *http.Response, header, want string) {
98+
if actual := resp.Header.Get(header); actual != want {
99+
tb.Errorf("expected %s header to be %s, but was %s", header, want, actual)
100+
}
101+
}
102+
103+
resp, _ := tester.AssertGetResponse("http://localhost:9080/basic", http.StatusUnauthorized, "")
104+
assertHeader(t, resp, "WWW-Authenticate", `Basic realm="restricted"`)
105+
106+
tester.AssertGetResponse("http://Aladdin:open%20sesame@localhost:9080/basic", http.StatusOK, "")
107+
108+
tester.AssertGetResponse("http://localhost:9080/proxy", http.StatusProxyAuthRequired, "")
109+
110+
resp, _ = tester.AssertGetResponse("http://Aladdin:open%20sesame@localhost:9080/proxy", http.StatusProxyAuthRequired, "")
111+
assertHeader(t, resp, "Proxy-Authenticate", `Basic realm="HTTP proxy"`)
112+
113+
req, err := http.NewRequest(http.MethodGet, "http://localhost:9080/proxy", nil)
114+
if err != nil {
115+
t.Fatalf("unable to create request %v", err)
116+
}
117+
req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")))
118+
tester.AssertResponseCode(req, http.StatusOK)
119+
}
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,33 +21,33 @@
2121
// Original source, copied because the package was marked internal:
2222
// https://github.com/golang/go/blob/5c489514bc5e61ad9b5b07bd7d8ec65d66a0512a/src/net/http/internal/ascii/print.go
2323

24-
package reverseproxy
24+
package ascii
2525

26-
// asciiEqualFold is strings.EqualFold, ASCII only. It reports whether s and t
26+
// EqualFold is strings.EqualFold, ASCII only. It reports whether s and t
2727
// are equal, ASCII-case-insensitively.
28-
func asciiEqualFold(s, t string) bool {
28+
func EqualFold(s, t string) bool {
2929
if len(s) != len(t) {
3030
return false
3131
}
3232
for i := 0; i < len(s); i++ {
33-
if asciiLower(s[i]) != asciiLower(t[i]) {
33+
if lower(s[i]) != lower(t[i]) {
3434
return false
3535
}
3636
}
3737
return true
3838
}
3939

40-
// asciiLower returns the ASCII lowercase version of b.
41-
func asciiLower(b byte) byte {
40+
// lower returns the ASCII lowercase version of b.
41+
func lower(b byte) byte {
4242
if 'A' <= b && b <= 'Z' {
4343
return b + ('a' - 'A')
4444
}
4545
return b
4646
}
4747

48-
// asciiIsPrint returns whether s is ASCII and printable according to
48+
// IsPrint returns whether s is ASCII and printable according to
4949
// https://tools.ietf.org/html/rfc20#section-4.2.
50-
func asciiIsPrint(s string) bool {
50+
func IsPrint(s string) bool {
5151
for i := 0; i < len(s); i++ {
5252
if s[i] < ' ' || s[i] > '~' {
5353
return false

modules/caddyhttp/reverseproxy/ascii_test.go renamed to internal/ascii/ascii_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
// Original source, copied because the package was marked internal:
2222
// https://github.com/golang/go/blob/5c489514bc5e61ad9b5b07bd7d8ec65d66a0512a/src/net/http/internal/ascii/print_test.go
2323

24-
package reverseproxy
24+
package ascii
2525

2626
import "testing"
2727

@@ -56,7 +56,7 @@ func TestEqualFold(t *testing.T) {
5656
}
5757
for _, tt := range tests {
5858
t.Run(tt.name, func(t *testing.T) {
59-
if got := asciiEqualFold(tt.a, tt.b); got != tt.want {
59+
if got := EqualFold(tt.a, tt.b); got != tt.want {
6060
t.Errorf("AsciiEqualFold(%q,%q): got %v want %v", tt.a, tt.b, got, tt.want)
6161
}
6262
})
@@ -106,7 +106,7 @@ func TestIsPrint(t *testing.T) {
106106
}
107107
for _, tt := range tests {
108108
t.Run(tt.name, func(t *testing.T) {
109-
if got := asciiIsPrint(tt.in); got != tt.want {
109+
if got := IsPrint(tt.in); got != tt.want {
110110
t.Errorf("IsASCIIPrint(%q): got %v want %v", tt.in, got, tt.want)
111111
}
112112
})

modules/caddyhttp/caddyauth/basicauth.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"golang.org/x/sync/singleflight"
2828

2929
"github.com/caddyserver/caddy/v2"
30+
"github.com/caddyserver/caddy/v2/internal/ascii"
3031
)
3132

3233
func init() {
@@ -41,6 +42,12 @@ type HTTPBasicAuth struct {
4142
// The list of accounts to authenticate.
4243
AccountList []Account `json:"accounts,omitempty"`
4344

45+
// The name of the HTTP header to check. Default: Authorization
46+
AuthorizationHeader string `json:"authorization_header,omitempty"`
47+
48+
// The name of the HTTP header to check. Default: WWW-Authenticate
49+
AuthenticateHeader string `json:"authenticate_header,omitempty"`
50+
4451
// The name of the realm. Default: restricted
4552
Realm string `json:"realm,omitempty"`
4653

@@ -141,7 +148,7 @@ func (hba *HTTPBasicAuth) Provision(ctx caddy.Context) error {
141148

142149
// Authenticate validates the user credentials in req and returns the user, if valid.
143150
func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request) (User, bool, error) {
144-
username, plaintextPasswordStr, ok := req.BasicAuth()
151+
username, plaintextPasswordStr, ok := hba.credentials(req)
145152
if !ok {
146153
return hba.promptForCredentials(w, nil)
147154
}
@@ -162,6 +169,40 @@ func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request)
162169
return User{ID: username}, true, nil
163170
}
164171

172+
func (hba HTTPBasicAuth) credentials(r *http.Request) (username, password string, ok bool) {
173+
header := hba.AuthorizationHeader
174+
if header == "" {
175+
header = "Authorization"
176+
}
177+
auth := r.Header.Get(header)
178+
if auth == "" {
179+
return "", "", false
180+
}
181+
return parseBasicAuth(auth)
182+
}
183+
184+
// parseBasicAuth parses an HTTP Basic Authentication string.
185+
// "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true).
186+
//
187+
// Copied from Go’s net/http.parseBasicAuth unexported function.
188+
func parseBasicAuth(auth string) (username, password string, ok bool) {
189+
const prefix = "Basic "
190+
// Case insensitive prefix match. See https://go.dev/issue/22736.
191+
if len(auth) < len(prefix) || !ascii.EqualFold(auth[:len(prefix)], prefix) {
192+
return "", "", false
193+
}
194+
c, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
195+
if err != nil {
196+
return "", "", false
197+
}
198+
cs := string(c)
199+
username, password, ok = strings.Cut(cs, ":")
200+
if !ok {
201+
return "", "", false
202+
}
203+
return username, password, true
204+
}
205+
165206
func (hba HTTPBasicAuth) correctPassword(account Account, plaintextPassword []byte) (bool, error) {
166207
compare := func() (bool, error) {
167208
return hba.Hash.Compare(account.password, plaintextPassword)
@@ -212,7 +253,11 @@ func (hba HTTPBasicAuth) promptForCredentials(w http.ResponseWriter, err error)
212253
if realm == "" {
213254
realm = "restricted"
214255
}
215-
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm))
256+
header := hba.AuthenticateHeader
257+
if header == "" {
258+
header = "WWW-Authenticate"
259+
}
260+
w.Header().Set(header, fmt.Sprintf(`Basic realm="%s"`, realm))
216261
return User{}, false, err
217262
}
218263

0 commit comments

Comments
 (0)