mirror of
https://github.com/caddyserver/caddy.git
synced 2025-10-19 07:43:17 +00:00
caddyauth: Allow user-configurable headers and status code
This commit is contained in:
parent
d115cd1042
commit
dfee04c128
7 changed files with 376 additions and 16 deletions
119
caddytest/integration/authenticate_test.go
Normal file
119
caddytest/integration/authenticate_test.go
Normal 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)
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
})
|
|
@ -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 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
|
||||
}
|
||||
|
||||
|
|
178
modules/caddyhttp/caddyauth/basicauth_test.go
Normal file
178
modules/caddyhttp/caddyauth/basicauth_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue