diff --git a/lib/http/auth.go b/lib/http/auth.go index 219b140a8..2e2e9f6dd 100644 --- a/lib/http/auth.go +++ b/lib/http/auth.go @@ -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 diff --git a/lib/http/middleware.go b/lib/http/middleware.go index 8030ec4f1..f7088ba8e 100644 --- a/lib/http/middleware.go +++ b/lib/http/middleware.go @@ -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 diff --git a/lib/http/middleware_test.go b/lib/http/middleware_test.go index 845e28013..c0e3911c2 100644 --- a/lib/http/middleware_test.go +++ b/lib/http/middleware_test.go @@ -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) + } }) }) } diff --git a/lib/http/server.go b/lib/http/server.go index ce13b82b6..57ce8b8bb 100644 --- a/lib/http/server.go +++ b/lib/http/server.go @@ -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 }