From 03734a37aa0999fdbd0c49b46d996b471d2e7b18 Mon Sep 17 00:00:00 2001 From: Zach Wasserman Date: Fri, 1 Jul 2022 12:52:55 -0700 Subject: [PATCH] Add server support for Fleet Sandbox demo login (#6387) * Add server support for Fleet Sandbox demo login This adds an endpoint `/api/latest/fleet/demologin` that provides a redirect for the fleetdm.com portion of Fleet Sandbox to automatically log in a user. The username and password must be provided as form values. The endpoint is only enabled if `FLEET_DEMO=1` is set in the server environment. This was tested locally with the following HTML served by `python3 -m http.server`, and the Fleet server running with `FLEET_DEMO=1 ./build/fleet serve --dev`: ```
``` For Fleet sandbox purposes, the `action` should be set to the correct hostname for the sandbox instance, while the `email` and `password` should be set to the same credentials that were provided when creating the instance. * lucas comments * Add integration tests * Fix status codes and add comments Co-authored-by: Martin Angers --- server/service/handler.go | 5 ++ server/service/integration_core_test.go | 37 +++++++++++ server/service/sessions.go | 87 +++++++++++++++++++++++++ server/service/transport.go | 4 ++ 4 files changed, 133 insertions(+) diff --git a/server/service/handler.go b/server/service/handler.go index b2e6908f6b..aac7dcb33b 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -445,6 +445,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ne.POST("/api/v1/fleet/sso", initiateSSOEndpoint, initiateSSORequest{}) ne.POST("/api/v1/fleet/sso/callback", makeCallbackSSOEndpoint(config.Server.URLPrefix), callbackSSORequest{}) ne.GET("/api/v1/fleet/sso", settingsSSOEndpoint, nil) + // the websocket distributed query results endpoint is a bit different - the // provided path is a prefix, not an exact match, and it is not a go-kit // endpoint but a raw http.Handler. It uses the NoAuthEndpointer because @@ -464,6 +465,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ne.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). POST("/api/_version_/fleet/login", loginEndpoint, loginRequest{}) + + // Fleet Sandbox demo login (always errors unless FLEET_DEMO environment variable is set) + ne.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). + POST("/api/_version_/fleet/demologin", makeDemologinEndpoint(config.Server.URLPrefix), demologinRequest{}) } func newServer(e endpoint.Endpoint, decodeFn kithttp.DecodeRequestFunc, opts []kithttp.ServerOption) http.Handler { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 0a707dcb47..1d073dd35c 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5130,6 +5130,43 @@ func (s *integrationTestSuite) TestSSODisabled() { require.Contains(t, string(body), "/login?status=org_disabled") // html contains a script that redirects to this path } +func (s *integrationTestSuite) TestFleetSandboxDemoLogin() { + t := s.T() + + validEmail := testUsers["user1"].Email + validPwd := testUsers["user1"].PlaintextPassword + wrongPwd := "nope" + hdrs := map[string]string{"Content-Type": "application/x-www-form-urlencoded"} + + os.Unsetenv("FLEET_DEMO") // ensure it is not accidentally set + + // without the FLEET_DEMO env var set, the login always fails + formBody := make(url.Values) + formBody.Set("email", validEmail) + formBody.Set("password", validPwd) + res := s.DoRawWithHeaders("POST", "/api/v1/fleet/demologin", []byte(formBody.Encode()), http.StatusInternalServerError, hdrs) + require.NotEqual(t, http.StatusOK, res.StatusCode) + + // with the FLEET_DEMO env var set, the login works as expected, validating + // the credentials + os.Setenv("FLEET_DEMO", "1") + defer os.Unsetenv("FLEET_DEMO") + + formBody.Set("email", validEmail) + formBody.Set("password", wrongPwd) + res = s.DoRawWithHeaders("POST", "/api/v1/fleet/demologin", []byte(formBody.Encode()), http.StatusUnauthorized, hdrs) + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + + formBody.Set("email", validEmail) + formBody.Set("password", validPwd) + res = s.DoRawWithHeaders("POST", "/api/v1/fleet/demologin", []byte(formBody.Encode()), http.StatusOK, hdrs) + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Contains(t, string(resBody), `window.location = "/"`) + require.Regexp(t, `window.localStorage.setItem\('FLEET::auth_token', '[^']+'\)`, string(resBody)) +} + func (s *integrationTestSuite) TestGetHostBatteries() { t := s.T() diff --git a/server/service/sessions.go b/server/service/sessions.go index e35305412a..3b7b90b445 100644 --- a/server/service/sessions.go +++ b/server/service/sessions.go @@ -11,6 +11,7 @@ import ( "html/template" "net/http" "net/url" + "os" "strings" "time" @@ -594,3 +595,89 @@ func (svc *Service) validateSession(ctx context.Context, session *fleet.Session) return svc.ds.MarkSessionAccessed(ctx, session) } + +//////////////////////////////////////////////////////////////////////////////// +// Demo Login +//////////////////////////////////////////////////////////////////////////////// + +// This is a special kind of login where the username and password come in form values rather than +// JSON as is typical for API requests. This is intended to support logins from demo environments, +// when users are being redirected from fleetdm.com. + +type demologinRequest struct { + Email string + Password string +} + +func (demologinRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + err := r.ParseForm() + if err != nil { + return nil, ctxerr.Wrap(ctx, &badRequestError{message: err.Error()}, "decode demo login") + } + + return demologinRequest{ + Email: r.FormValue("email"), + Password: r.FormValue("password"), + }, nil +} + +type demologinResponse struct { + content string + Err error `json:"error,omitempty"` +} + +func (r demologinResponse) error() error { return r.Err } + +// If html is present we return a web page +func (r demologinResponse) html() string { return r.content } + +func makeDemologinEndpoint(urlPrefix string) handlerFunc { + return func(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) { + req := request.(demologinRequest) + + // Undocumented FLEET_DEMO environment variable, as this endpoint is intended only to be + // used in the Fleet Sandbox demo environment. + if os.Getenv("FLEET_DEMO") != "1" { + return nil, errors.New("this endpoint only enabled in demo mode") + } + + _, sess, err := svc.Login(ctx, req.Email, req.Password) + + // This endpoint handles errors slightly differently in that we want to still return the + // HTML page redirect to login if there was some error, so we can't just return the response + // error without doing the rest of the logic. + + session := struct { + Token string + }{} + var resp demologinResponse + if err != nil { + resp.Err = err + } + if sess != nil { + session.Token = sess.Key + } + + relayStateLoadPage := ` + + + Redirecting to Fleet... + + + ` + tmpl, err := template.New("demologin").Parse(relayStateLoadPage) + if err != nil { + return nil, err + } + var writer bytes.Buffer + err = tmpl.Execute(&writer, session) + if err != nil { + return nil, err + } + resp.content = writer.String() + return resp, nil + } +} diff --git a/server/service/transport.go b/server/service/transport.go index f317981833..aa40cb2918 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -13,6 +13,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + kithttp "github.com/go-kit/kit/transport/http" "github.com/gorilla/mux" ) @@ -24,6 +25,9 @@ func encodeResponse(ctx context.Context, w http.ResponseWriter, response interfa // page and the error will be logged if page, ok := response.(htmlPage); ok { w.Header().Set("Content-Type", "text/html; charset=UTF-8") + if coder, ok := page.error().(kithttp.StatusCoder); ok { + w.WriteHeader(coder.StatusCode()) + } _, err := io.WriteString(w, page.html()) return err }