This commit is contained in:
Ivan Trubach 2025-10-16 01:18:44 -04:00 committed by GitHub
commit 83a0593043
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 376 additions and 16 deletions

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}
})

View file

@ -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 to check. Default: Authorization
AuthorizationHeader string `json:"authorization_header,omitempty"`
// The name of the HTTP header to check. Default: WWW-Authenticate
AuthenticateHeader string `json:"authenticate_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 Gos 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
}

View file

@ -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)
}
})
}
}

View file

@ -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)

View file

@ -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),