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`:

```
<!DOCTYPE html>
  <body>
    <form
      method="post"
      action="https://localhost:8080/api/latest/fleet/demologin"
      id="demologin"
    >
      <input type="hidden" name="email" value="admin@example.com" />
      <input type="hidden" name="password" value="admin123123#" />
      <input type="submit"/>
    </form>
    <script type="text/javascript">
      document.forms["demologin"].submit();
    </script>
  </body>
</html>

```

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 <martin.n.angers@gmail.com>
This commit is contained in:
Zach Wasserman 2022-07-01 12:52:55 -07:00 committed by GitHub
parent 2fa678cb19
commit 03734a37aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 133 additions and 0 deletions

View file

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

View file

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

View file

@ -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 := `<!DOCTYPE html>
<script type='text/javascript'>
window.localStorage.setItem('FLEET::auth_token', '{{ .Token }}');
window.location = "/";
</script>
<body>
Redirecting to Fleet...
</body>
</html>
`
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
}
}

View file

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