http servers: add --user-from-header to use for authentication

Retrieve the username from a specified HTTP header if no
other authentication methods are configured
(ideal for proxied setups)
This commit is contained in:
Moises Lima 2025-01-17 12:53:23 -03:00 committed by GitHub
parent bf5a4774c6
commit 347be176af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 115 additions and 30 deletions

View File

@ -19,7 +19,11 @@ By default this will serve files without needing a login.
You can either use an htpasswd file which can take lots of users, or
set a single username and password with the ` + "`--{{ .Prefix }}user` and `--{{ .Prefix }}pass`" + ` flags.
If no static users are configured by either of the above methods, and client
Alternatively, you can have the reverse proxy manage authentication and use the
username provided in the configured header with ` + "`--user-from-header`" + ` (e.g., ` + "`--{{ .Prefix }}--user-from-header=x-remote-user`" + `).
Ensure the proxy is trusted and headers cannot be spoofed, as misconfiguration may lead to unauthorized access.
If either of the above authentication methods is not configured and client
certificates are required by the ` + "`--client-ca`" + ` flag passed to the server, the
client certificate common name will be considered as the username.
@ -85,16 +89,21 @@ var AuthConfigInfo = fs.Options{{
Name: "salt",
Default: "dlPL2MqE",
Help: "Password hashing salt",
}, {
Name: "user_from_header",
Default: "",
Help: "User name from a defined HTTP header",
}}
// AuthConfig contains options for the http authentication
type AuthConfig struct {
HtPasswd string `config:"htpasswd"` // htpasswd file - if not provided no authentication is done
Realm string `config:"realm"` // realm for authentication
BasicUser string `config:"user"` // single username for basic auth if not using Htpasswd
BasicPass string `config:"pass"` // password for BasicUser
Salt string `config:"salt"` // password hashing salt
CustomAuthFn CustomAuthFn `json:"-" config:"-"` // custom Auth (not set by command line flags)
HtPasswd string `config:"htpasswd"` // htpasswd file - if not provided no authentication is done
Realm string `config:"realm"` // realm for authentication
BasicUser string `config:"user"` // single username for basic auth if not using Htpasswd
BasicPass string `config:"pass"` // password for BasicUser
Salt string `config:"salt"` // password hashing salt
UserFromHeader string `config:"user_from_header"` // retrieve user name from a defined HTTP header
CustomAuthFn CustomAuthFn `json:"-" config:"-"` // custom Auth (not set by command line flags)
}
// AddFlagsPrefix adds flags to the flag set for AuthConfig
@ -104,6 +113,7 @@ func (cfg *AuthConfig) AddFlagsPrefix(flagSet *pflag.FlagSet, prefix string) {
flags.StringVarP(flagSet, &cfg.BasicUser, prefix+"user", "", cfg.BasicUser, "User name for authentication", prefix)
flags.StringVarP(flagSet, &cfg.BasicPass, prefix+"pass", "", cfg.BasicPass, "Password for authentication", prefix)
flags.StringVarP(flagSet, &cfg.Salt, prefix+"salt", "", cfg.Salt, "Password hashing salt", prefix)
flags.StringVarP(flagSet, &cfg.UserFromHeader, prefix+"user-from-header", "", cfg.UserFromHeader, "Retrieve the username from a specified HTTP header if no other authentication methods are configured (ideal for proxied setups)", prefix)
}
// AddAuthFlagsPrefix adds flags to the flag set for AuthConfig

View File

@ -5,6 +5,7 @@ import (
"encoding/base64"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
@ -153,6 +154,26 @@ func MiddlewareAuthCustom(fn CustomAuthFn, realm string, userFromContext bool) M
}
}
var validUsernameRegexp = regexp.MustCompile(`^[\p{L}\d@._-]+$`)
// MiddlewareAuthGetUserFromHeader middleware that bypasses authentication and extracts the user via a specified HTTP header(ideal for proxied setups).
func MiddlewareAuthGetUserFromHeader(header string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.Header.Get(header))
if username != "" && validUsernameRegexp.MatchString(username) {
r = r.WithContext(context.WithValue(r.Context(), ctxKeyUser, username))
next.ServeHTTP(w, r)
return
}
code := http.StatusUnauthorized
w.Header().Set("Content-Type", "text/plain")
http.Error(w, http.StatusText(code), code)
})
}
}
var onlyOnceWarningAllowOrigin sync.Once
// MiddlewareCORS instantiates middleware that handles basic CORS protections for rcd

View File

@ -14,11 +14,13 @@ import (
func TestMiddlewareAuth(t *testing.T) {
servers := []struct {
name string
http Config
auth AuthConfig
user string
pass string
name string
expectedUser string
remoteUser string
http Config
auth AuthConfig
user string
pass string
}{
{
name: "Basic",
@ -85,9 +87,32 @@ func TestMiddlewareAuth(t *testing.T) {
},
user: "custom",
pass: "custom",
}, {
name: "UserFromHeader",
remoteUser: "remoteUser",
expectedUser: "remoteUser",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
UserFromHeader: "X-Remote-User",
},
}, {
name: "UserFromHeader/MixedWithHtPasswd",
remoteUser: "remoteUser",
expectedUser: "md5",
http: Config{
ListenAddr: []string{"127.0.0.1:0"},
},
auth: AuthConfig{
UserFromHeader: "X-Remote-User",
Realm: "test",
HtPasswd: "./testdata/.htpasswd",
},
user: "md5",
pass: "md5",
},
}
for _, ss := range servers {
t.Run(ss.name, func(t *testing.T) {
s, err := NewServer(context.Background(), WithConfig(ss.http), WithAuth(ss.auth))
@ -97,7 +122,12 @@ func TestMiddlewareAuth(t *testing.T) {
}()
expected := []byte("secret-page")
s.Router().Mount("/", testEchoHandler(expected))
if ss.expectedUser != "" {
s.Router().Mount("/", testAuthUserHandler())
} else {
s.Router().Mount("/", testEchoHandler(expected))
}
s.Serve()
url := testGetServerURL(t, s)
@ -114,18 +144,24 @@ func TestMiddlewareAuth(t *testing.T) {
}()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "using no creds should return unauthorized")
wwwAuthHeader := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuthHeader, "resp should contain WWW-Authtentication header")
require.Contains(t, wwwAuthHeader, fmt.Sprintf("realm=%q", ss.auth.Realm), "WWW-Authtentication header should contain relam")
if ss.auth.UserFromHeader == "" {
wwwAuthHeader := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuthHeader, "resp should contain WWW-Authtentication header")
require.Contains(t, wwwAuthHeader, fmt.Sprintf("realm=%q", ss.auth.Realm), "WWW-Authtentication header should contain relam")
}
})
t.Run("BadCreds", func(t *testing.T) {
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
req.SetBasicAuth(ss.user+"BAD", ss.pass+"BAD")
if ss.user != "" {
req.SetBasicAuth(ss.user+"BAD", ss.pass+"BAD")
}
if ss.auth.UserFromHeader != "" {
req.Header.Set(ss.auth.UserFromHeader, "/test:")
}
resp, err := client.Do(req)
require.NoError(t, err)
@ -134,10 +170,11 @@ func TestMiddlewareAuth(t *testing.T) {
}()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "using bad creds should return unauthorized")
wwwAuthHeader := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuthHeader, "resp should contain WWW-Authtentication header")
require.Contains(t, wwwAuthHeader, fmt.Sprintf("realm=%q", ss.auth.Realm), "WWW-Authtentication header should contain relam")
if ss.auth.UserFromHeader == "" {
wwwAuthHeader := resp.Header.Get("WWW-Authenticate")
require.NotEmpty(t, wwwAuthHeader, "resp should contain WWW-Authtentication header")
require.Contains(t, wwwAuthHeader, fmt.Sprintf("realm=%q", ss.auth.Realm), "WWW-Authtentication header should contain relam")
}
})
t.Run("GoodCreds", func(t *testing.T) {
@ -145,7 +182,13 @@ func TestMiddlewareAuth(t *testing.T) {
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
req.SetBasicAuth(ss.user, ss.pass)
if ss.user != "" {
req.SetBasicAuth(ss.user, ss.pass)
}
if ss.auth.UserFromHeader != "" {
req.Header.Set(ss.auth.UserFromHeader, ss.remoteUser)
}
resp, err := client.Do(req)
require.NoError(t, err)
@ -155,7 +198,11 @@ func TestMiddlewareAuth(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode, "using good creds should return ok")
testExpectRespBody(t, resp, expected)
if ss.expectedUser != "" {
testExpectRespBody(t, resp, []byte(ss.expectedUser))
} else {
testExpectRespBody(t, resp, expected)
}
})
})
}

View File

@ -392,16 +392,23 @@ func NewServer(ctx context.Context, options ...Option) (*Server, error) {
func (s *Server) initAuth() {
s.usingAuth = false
altUsernameEnabled := s.auth.HtPasswd == "" && s.auth.BasicUser == ""
authCertificateUserEnabled := s.tlsConfig != nil && s.tlsConfig.ClientAuth != tls.NoClientCert && s.auth.HtPasswd == "" && s.auth.BasicUser == ""
if authCertificateUserEnabled {
if altUsernameEnabled {
s.usingAuth = true
s.mux.Use(MiddlewareAuthCertificateUser())
if s.auth.UserFromHeader != "" {
s.mux.Use(MiddlewareAuthGetUserFromHeader(s.auth.UserFromHeader))
} else if s.tlsConfig != nil && s.tlsConfig.ClientAuth != tls.NoClientCert {
s.mux.Use(MiddlewareAuthCertificateUser())
} else {
s.usingAuth = false
altUsernameEnabled = false
}
}
if s.auth.CustomAuthFn != nil {
s.usingAuth = true
s.mux.Use(MiddlewareAuthCustom(s.auth.CustomAuthFn, s.auth.Realm, authCertificateUserEnabled))
s.mux.Use(MiddlewareAuthCustom(s.auth.CustomAuthFn, s.auth.Realm, altUsernameEnabled))
return
}