diff --git a/caddytest/integration/authenticate_test.go b/caddytest/integration/authenticate_test.go new file mode 100644 index 00000000000..a473293ae69 --- /dev/null +++ b/caddytest/integration/authenticate_test.go @@ -0,0 +1,119 @@ +package integration + +import ( + "encoding/base64" + "net/http" + "testing" + + "github.com/caddyserver/caddy/v2/caddytest" +) + +func TestAuthentication(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "pki": { + "certificate_authorities": { + "local": { + "install_trust": false + } + } + }, + "http": { + "http_port": 9080, + "https_port": 9443, + "servers": { + "srv0": { + "listen": [ + ":9080" + ], + "routes": [ + { + "match": [ + { + "path": [ + "/basic" + ] + } + ], + "handle": [ + { + "handler": "authentication", + "providers": { + "http_basic": { + "hash_cache": {}, + "accounts": [ + { + "username": "Aladdin", + "password": "$2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa" + } + ] + } + } + } + ] + }, + { + "match": [ + { + "path": [ + "/proxy" + ] + } + ], + "handle": [ + { + "handler": "authentication", + "status_code": 407, + "providers": { + "http_basic": { + "hash_cache": {}, + "authorization_header": "Proxy-Authorization", + "authenticate_header": "Proxy-Authenticate", + "realm": "HTTP proxy", + "accounts": [ + { + "username": "Aladdin", + "password": "$2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa" + } + ] + } + } + } + ] + } + ] + } + } + } + } + } + `, "json") + + assertHeader := func(tb testing.TB, resp *http.Response, header, want string) { + if actual := resp.Header.Get(header); actual != want { + tb.Errorf("expected %s header to be %s, but was %s", header, want, actual) + } + } + + resp, _ := tester.AssertGetResponse("http://localhost:9080/basic", http.StatusUnauthorized, "") + assertHeader(t, resp, "WWW-Authenticate", `Basic realm="restricted"`) + + tester.AssertGetResponse("http://Aladdin:open%20sesame@localhost:9080/basic", http.StatusOK, "") + + tester.AssertGetResponse("http://localhost:9080/proxy", http.StatusProxyAuthRequired, "") + + resp, _ = tester.AssertGetResponse("http://Aladdin:open%20sesame@localhost:9080/proxy", http.StatusProxyAuthRequired, "") + assertHeader(t, resp, "Proxy-Authenticate", `Basic realm="HTTP proxy"`) + + req, err := http.NewRequest(http.MethodGet, "http://localhost:9080/proxy", nil) + if err != nil { + t.Fatalf("unable to create request %v", err) + } + req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame"))) + tester.AssertResponseCode(req, http.StatusOK) +} diff --git a/caddytest/integration/caddyfile_adapt/basic_auth_algorithm.caddyfiletest b/caddytest/integration/caddyfile_adapt/basic_auth_algorithm.caddyfiletest new file mode 100644 index 00000000000..8d8aeb96066 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/basic_auth_algorithm.caddyfiletest @@ -0,0 +1,59 @@ +https://example.com +basic_auth bcrypt { + Aladdin $2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa +} + +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "handler": "authentication", + "providers": { + "http_basic": { + "accounts": [ + { + "password": "$2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa", + "username": "Aladdin" + } + ], + "hash": { + "algorithm": "bcrypt" + }, + "hash_cache": {} + } + } + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/basic_auth_empty.caddyfiletest b/caddytest/integration/caddyfile_adapt/basic_auth_empty.caddyfiletest new file mode 100644 index 00000000000..c6d97ad055f --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/basic_auth_empty.caddyfiletest @@ -0,0 +1,51 @@ +https://example.com +basic_auth + +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "handler": "authentication", + "providers": { + "http_basic": { + "hash": { + "algorithm": "bcrypt" + }, + "hash_cache": {} + } + } + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/basic_auth_proxy.caddyfiletest b/caddytest/integration/caddyfile_adapt/basic_auth_proxy.caddyfiletest new file mode 100644 index 00000000000..04f74a03106 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/basic_auth_proxy.caddyfiletest @@ -0,0 +1,78 @@ +https://example.com { + basic_auth proxy bcrypt { + Aladdin $2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa + } + # Alternatively, use https://github.com/caddyserver/forwardproxy instead + # of external forward proxy. + reverse_proxy https://localhost:54321 +} + +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "handler": "authentication", + "providers": { + "http_basic": { + "accounts": [ + { + "password": "$2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa", + "username": "Aladdin" + } + ], + "authenticate_header": "Proxy-Authenticate", + "authorization_header": "Proxy-Authorization", + "hash": { + "algorithm": "bcrypt" + }, + "hash_cache": {} + } + }, + "status_code": 407 + }, + { + "handler": "reverse_proxy", + "transport": { + "protocol": "http", + "tls": {} + }, + "upstreams": [ + { + "dial": "localhost:54321" + } + ] + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/basic_auth_realm.caddyfiletest b/caddytest/integration/caddyfile_adapt/basic_auth_realm.caddyfiletest new file mode 100644 index 00000000000..c6504f003f6 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/basic_auth_realm.caddyfiletest @@ -0,0 +1,60 @@ +https://example.com +basic_auth bcrypt "my realm" { + Aladdin $2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa +} + +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "handler": "authentication", + "providers": { + "http_basic": { + "accounts": [ + { + "password": "$2a$14$U5nG2p.Ac09gzn9oo5aRe.YnsXn30UdXA6pRUn45KFqADG636dRHa", + "username": "Aladdin" + } + ], + "hash": { + "algorithm": "bcrypt" + }, + "hash_cache": {}, + "realm": "my realm" + } + } + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + } + } +} diff --git a/modules/caddyhttp/reverseproxy/ascii.go b/internal/ascii/ascii.go similarity index 79% rename from modules/caddyhttp/reverseproxy/ascii.go rename to internal/ascii/ascii.go index 75b8220f353..cd202ee644a 100644 --- a/modules/caddyhttp/reverseproxy/ascii.go +++ b/internal/ascii/ascii.go @@ -21,33 +21,33 @@ // Original source, copied because the package was marked internal: // https://github.com/golang/go/blob/5c489514bc5e61ad9b5b07bd7d8ec65d66a0512a/src/net/http/internal/ascii/print.go -package reverseproxy +package ascii -// asciiEqualFold is strings.EqualFold, ASCII only. It reports whether s and t +// EqualFold is strings.EqualFold, ASCII only. It reports whether s and t // are equal, ASCII-case-insensitively. -func asciiEqualFold(s, t string) bool { +func EqualFold(s, t string) bool { if len(s) != len(t) { return false } for i := 0; i < len(s); i++ { - if asciiLower(s[i]) != asciiLower(t[i]) { + if lower(s[i]) != lower(t[i]) { return false } } return true } -// asciiLower returns the ASCII lowercase version of b. -func asciiLower(b byte) byte { +// lower returns the ASCII lowercase version of b. +func lower(b byte) byte { if 'A' <= b && b <= 'Z' { return b + ('a' - 'A') } return b } -// asciiIsPrint returns whether s is ASCII and printable according to +// IsPrint returns whether s is ASCII and printable according to // https://tools.ietf.org/html/rfc20#section-4.2. -func asciiIsPrint(s string) bool { +func IsPrint(s string) bool { for i := 0; i < len(s); i++ { if s[i] < ' ' || s[i] > '~' { return false diff --git a/modules/caddyhttp/reverseproxy/ascii_test.go b/internal/ascii/ascii_test.go similarity index 95% rename from modules/caddyhttp/reverseproxy/ascii_test.go rename to internal/ascii/ascii_test.go index de67963bd7c..96d12dabcce 100644 --- a/modules/caddyhttp/reverseproxy/ascii_test.go +++ b/internal/ascii/ascii_test.go @@ -21,7 +21,7 @@ // Original source, copied because the package was marked internal: // https://github.com/golang/go/blob/5c489514bc5e61ad9b5b07bd7d8ec65d66a0512a/src/net/http/internal/ascii/print_test.go -package reverseproxy +package ascii import "testing" @@ -56,7 +56,7 @@ func TestEqualFold(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := asciiEqualFold(tt.a, tt.b); got != tt.want { + if got := EqualFold(tt.a, tt.b); got != tt.want { t.Errorf("AsciiEqualFold(%q,%q): got %v want %v", tt.a, tt.b, got, tt.want) } }) @@ -106,7 +106,7 @@ func TestIsPrint(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := asciiIsPrint(tt.in); got != tt.want { + if got := IsPrint(tt.in); got != tt.want { t.Errorf("IsASCIIPrint(%q): got %v want %v", tt.in, got, tt.want) } }) diff --git a/modules/caddyhttp/caddyauth/basicauth.go b/modules/caddyhttp/caddyauth/basicauth.go index 5a9e167e102..e446ba867fe 100644 --- a/modules/caddyhttp/caddyauth/basicauth.go +++ b/modules/caddyhttp/caddyauth/basicauth.go @@ -27,6 +27,7 @@ import ( "golang.org/x/sync/singleflight" "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/internal/ascii" ) func init() { @@ -41,6 +42,12 @@ type HTTPBasicAuth struct { // The list of accounts to authenticate. AccountList []Account `json:"accounts,omitempty"` + // The name of the HTTP header for challenge response. Default: WWW-Authenticate + AuthenticateHeader string `json:"authenticate_header,omitempty"` + + // The name of the HTTP header to check for credentials. Default: Authorization + AuthorizationHeader string `json:"authorization_header,omitempty"` + // The name of the realm. Default: restricted Realm string `json:"realm,omitempty"` @@ -141,7 +148,7 @@ func (hba *HTTPBasicAuth) Provision(ctx caddy.Context) error { // Authenticate validates the user credentials in req and returns the user, if valid. func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request) (User, bool, error) { - username, plaintextPasswordStr, ok := req.BasicAuth() + username, plaintextPasswordStr, ok := hba.credentials(req) if !ok { return hba.promptForCredentials(w, nil) } @@ -162,6 +169,40 @@ func (hba HTTPBasicAuth) Authenticate(w http.ResponseWriter, req *http.Request) return User{ID: username}, true, nil } +func (hba HTTPBasicAuth) credentials(r *http.Request) (username, password string, ok bool) { + header := hba.AuthorizationHeader + if header == "" { + header = "Authorization" + } + auth := r.Header.Get(header) + if auth == "" { + return "", "", false + } + return parseBasicAuth(auth) +} + +// parseBasicAuth parses an HTTP Basic Authentication string. +// "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true). +// +// Copied from Go’s net/http.parseBasicAuth unexported function. +func parseBasicAuth(auth string) (username, password string, ok bool) { + const prefix = "Basic " + // Case insensitive prefix match. See https://go.dev/issue/22736. + if len(auth) < len(prefix) || !ascii.EqualFold(auth[:len(prefix)], prefix) { + return "", "", false + } + c, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) + if err != nil { + return "", "", false + } + cs := string(c) + username, password, ok = strings.Cut(cs, ":") + if !ok { + return "", "", false + } + return username, password, true +} + func (hba HTTPBasicAuth) correctPassword(account Account, plaintextPassword []byte) (bool, error) { compare := func() (bool, error) { return hba.Hash.Compare(account.password, plaintextPassword) @@ -212,7 +253,11 @@ func (hba HTTPBasicAuth) promptForCredentials(w http.ResponseWriter, err error) if realm == "" { realm = "restricted" } - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm)) + header := hba.AuthenticateHeader + if header == "" { + header = "WWW-Authenticate" + } + w.Header().Set(header, fmt.Sprintf(`Basic realm="%s"`, realm)) return User{}, false, err } diff --git a/modules/caddyhttp/caddyauth/basicauth_test.go b/modules/caddyhttp/caddyauth/basicauth_test.go new file mode 100644 index 00000000000..8a8db9e7ba3 --- /dev/null +++ b/modules/caddyhttp/caddyauth/basicauth_test.go @@ -0,0 +1,178 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddyauth + +import ( + "encoding/base64" + "testing" +) + +func TestParseBasicAuth(t *testing.T) { + type basicAuthTest struct { + username string + password string + ok bool + } + testCases := []struct { + name string + header string + want basicAuthTest + }{ + { + name: "Empty header", + header: "", + want: basicAuthTest{ + username: "", + password: "", + ok: false, + }, + }, + { + name: "Valid header", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")), + want: basicAuthTest{ + username: "Aladdin", + password: "open sesame", + ok: true, + }, + }, + { + name: "Upper case scheme", + header: "BASIC " + base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")), + want: basicAuthTest{ + username: "Aladdin", + password: "open sesame", + ok: true, + }, + }, + { + name: "Lower case scheme", + header: "basic " + base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")), + want: basicAuthTest{ + username: "Aladdin", + password: "open sesame", + ok: true, + }, + }, + { + name: "Mixed case scheme", + header: "BaSiC " + base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")), + want: basicAuthTest{ + username: "Aladdin", + password: "open sesame", + ok: true, + }, + }, + { + name: "Password with colon", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte("Aladdin:open:sesame")), + want: basicAuthTest{ + username: "Aladdin", + password: "open:sesame", + ok: true, + }, + }, + { + name: "Empty username and password", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")), + want: basicAuthTest{ + username: "", + password: "", + ok: true, + }, + }, + { + name: "Missing password", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte("Aladdin")), + want: basicAuthTest{ + username: "", + password: "", + ok: false, + }, + }, + { + name: "Empty username", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte(":open sesame")), + want: basicAuthTest{ + username: "", + password: "open sesame", + ok: true, + }, + }, + { + name: "Missing space between scheme and credentials", + header: "Basic" + base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")), + want: basicAuthTest{ + username: "", + password: "", + ok: false, + }, + }, + { + name: "Multiple spaces between scheme and credentials", + header: "Basic " + base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")), + want: basicAuthTest{ + username: "", + password: "", + ok: false, + }, + }, + { + name: "Missing scheme", + header: base64.StdEncoding.EncodeToString([]byte("Aladdin:open sesame")), + want: basicAuthTest{ + username: "", + password: "", + ok: false, + }, + }, + { + name: "Missing credentials", + header: "Basic ", + want: basicAuthTest{ + username: "", + password: "", + ok: false, + }, + }, + { + name: "Credentials are not base64-encoded", + header: "Basic Aladdin:open sesame", + want: basicAuthTest{ + username: "", + password: "", + ok: false, + }, + }, + { + name: "Invalid scheme", + header: `Digest username="Aladdin"`, + want: basicAuthTest{ + username: "", + password: "", + ok: false, + }, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(*testing.T) { + username, password, ok := parseBasicAuth(tt.header) + actual := basicAuthTest{username, password, ok} + if tt.want != actual { + t.Errorf("BasicAuth() = %#v, want %#v", actual, tt.want) + } + }) + } +} diff --git a/modules/caddyhttp/caddyauth/caddyauth.go b/modules/caddyhttp/caddyauth/caddyauth.go index 792c198ee5f..ccd3de0f6be 100644 --- a/modules/caddyhttp/caddyauth/caddyauth.go +++ b/modules/caddyhttp/caddyauth/caddyauth.go @@ -15,8 +15,10 @@ package caddyauth import ( + "errors" "fmt" "net/http" + "strconv" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -29,6 +31,8 @@ func init() { caddy.RegisterModule(Authentication{}) } +var errNotAuthenticated = errors.New("not authenticated") + // Authentication is a middleware which provides user authentication. // Rejects requests with HTTP 401 if the request is not authenticated. // @@ -47,6 +51,11 @@ type Authentication struct { // all requests will always be unauthenticated. ProvidersRaw caddy.ModuleMap `json:"providers,omitempty" caddy:"namespace=http.authentication.providers"` + // The HTTP status code to respind with for unauthenticated requests. + // Can be either an integer or a string if placeholders are needed. + // Optional. Default is 401. + StatusCode caddyhttp.WeakString `json:"status_code,omitempty"` + Providers map[string]Authenticator `json:"-"` logger *zap.Logger @@ -96,7 +105,15 @@ func (a Authentication) ServeHTTP(w http.ResponseWriter, r *http.Request, next c } } if !authed { - return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("not authenticated")) + statusCode := http.StatusUnauthorized + if codeStr := a.StatusCode.String(); codeStr != "" { + intVal, err := strconv.Atoi(repl.ReplaceAll(codeStr, "")) + if err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + statusCode = intVal + } + return caddyhttp.Error(statusCode, errNotAuthenticated) } repl.Set("http.auth.user.id", user.ID) diff --git a/modules/caddyhttp/caddyauth/caddyfile.go b/modules/caddyhttp/caddyauth/caddyfile.go index 99a33aff596..7f789b29606 100644 --- a/modules/caddyhttp/caddyauth/caddyfile.go +++ b/modules/caddyhttp/caddyauth/caddyfile.go @@ -28,7 +28,7 @@ func init() { // parseCaddyfile sets up the handler from Caddyfile tokens. Syntax: // -// basic_auth [] [ []] { +// basic_auth [] [proxy] [ []] { // // ... // } @@ -48,6 +48,16 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) var cmp Comparer args := h.RemainingArgs() + var statusCode caddyhttp.WeakString + if len(args) > 0 && args[0] == "proxy" { + args = args[1:] + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication#proxy_authentication + statusCode = "407" // http.StatusProxyAuthRequired + ba.AuthenticateHeader = "Proxy-Authenticate" + ba.AuthorizationHeader = "Proxy-Authorization" + } + var hashName string switch len(args) { case 0: @@ -92,6 +102,7 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) } return Authentication{ + StatusCode: statusCode, ProvidersRaw: caddy.ModuleMap{ "http_basic": caddyconfig.JSON(ba, nil), }, diff --git a/modules/caddyhttp/reverseproxy/streaming.go b/modules/caddyhttp/reverseproxy/streaming.go index 66dd106d53c..862c5a90758 100644 --- a/modules/caddyhttp/reverseproxy/streaming.go +++ b/modules/caddyhttp/reverseproxy/streaming.go @@ -35,6 +35,7 @@ import ( "go.uber.org/zap/zapcore" "golang.org/x/net/http/httpguts" + "github.com/caddyserver/caddy/v2/internal/ascii" "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) @@ -63,13 +64,13 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, wg *sync.WaitGroup, // Taken from https://github.com/golang/go/commit/5c489514bc5e61ad9b5b07bd7d8ec65d66a0512a // We know reqUpType is ASCII, it's checked by the caller. - if !asciiIsPrint(resUpType) { + if !ascii.IsPrint(resUpType) { if c := logger.Check(zapcore.DebugLevel, "backend tried to switch to invalid protocol"); c != nil { c.Write(zap.String("backend_upgrade", resUpType)) } return } - if !asciiEqualFold(reqUpType, resUpType) { + if !ascii.EqualFold(reqUpType, resUpType) { if c := logger.Check(zapcore.DebugLevel, "backend tried to switch to unexpected protocol via Upgrade header"); c != nil { c.Write( zap.String("backend_upgrade", resUpType),