fleet/server/service/frontend.go
Scott Gress d4271986e0
End-user authentication for Window/Linux setup experience: backend (#34835)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34529 

# Details

This PR implements the backend (and some related front-end screens) for
allowing Fleet admins to require that users authenticate with an IdP
prior to having their devices set up. I'll comment on changes inline but
the high-level for the device enrollment flow is:

1. The handler for the `/orbit/enroll` endpoint now checks whether the
end-user authentication is required for the team (or globally, if using
the global enroll secret).
2. If so, it checks whether a `host_mdm_idp_accounts` row exists with a
`host_uuid` matching the identifier sent with the request
3. If a row exists, enroll. If not, return back a new flavor of
`OrbitError` with a `401` status code and a message
(`END_USER_AUTH_REQUIRED`) that Orbit can interpret and act accordingly.

Additionally some changes were made to the MDM SSO flow. Namely, adding
more data to the session we store for correlating requests we make to
the IdP to initiate SSO to responses aimed at our callback. We now store
a `RequestData` struct which contains the UUID of the device making the
request, as well as the "initiator" (in this case, "setup_experience").
When our SSO callback detects that the initiator was the setup
experience, it attempts to add all of the relevant records to our
database to associate the host with an IdP account. This removes the
enrollment gate in the `/orbit/enroll` endpoint.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
Will put the changelog in the last ticket for the story

- [X] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing

- [X] Added/updated automated tests
will see if there's any more to update

- [X] QA'd all new/changed functionality manually
To test w/ SimpleSAML

1. Log in to your local Fleet dashboard with MDM and IdP set up for
SimpleSAML
   1. Go to Settings -> Integrations -> Identity provider
   2. Use "SimpleSAML" for the provider name
   3. Use `mdm.test.com` for the entity ID
4. Use `http://127.0.0.1:9080/simplesaml/saml2/idp/metadata.php` for the
metadata URL
1. Set up a team (or "no team") to have End User Authentication required
(Controls -> Setup experience)
1. Get the enroll secret of that team
1. In the browser console, do:
```
fetch("https://localhost:8080/api/fleet/orbit/enroll", {
  "headers": {
    "accept": "application/json, text/plain, */*",
    "cache-control": "no-cache",
    "content-type": "application/json",
    "pragma": "no-cache",
  },
  "body": "{\"enroll_secret\":\"<enroll secret>", \"hardware_uuid\":\"abc123\" }",
  "method": "POST",
});
``` 
replacing `<enroll secret>` with your team's enroll secret.

8. Verify in the network tab that you get a 401 error with message
`END_USER_AUTH_REQUIRED`
1. Go to
https://localhost:8080/mdm/sso?initiator=setup_experience&host_uuid=abc123
1. Verify that a new screen appears asking you to log in to your IdP
1. Log in to SimpleSAML with `sso_user / user123#`
1. Verify that you're taken to a success screen
1. In your database, verify that records exist in the `mdm_idp_accounts`
and `host_mdm_idp_accounts` tables with uuid `abc123`
1. Try the `fetch` command in the browser console again, verify that it
succeeds.

## fleetd/orbit/Fleet Desktop

- [ ] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
This is _not_ compatible with the current version of fleetd or the
soon-to-be-released 1.49.x. Until #34847 changes are released in fleetd,
this will need to be put behind a feature flag or withheld from Fleet
releases.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added support for device UUID linkage during MDM enrollment to enable
host-initiated enrollment tracking
* Introduced setup experience flow for device authentication during
enrollment
* Added end-user authentication requirement configuration for macOS MDM
enrollment

* **Improvements**
* Enhanced MDM enrollment process to maintain device context through
authentication
* Updated authentication UI to display completion status for device
setup flows
  * Refined form layout styling for improved visual consistency

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-31 11:16:42 -05:00

246 lines
7.2 KiB
Go

package service
import (
"fmt"
"html/template"
"io"
"net/http"
"net/url"
assetfs "github.com/elazarl/go-bindata-assetfs"
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
"github.com/fleetdm/fleet/v4/server/bindata"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
"github.com/go-kit/log"
"github.com/klauspost/compress/gzhttp"
)
func newBinaryFileSystem(root string) *assetfs.AssetFS {
return &assetfs.AssetFS{
Asset: bindata.Asset,
AssetDir: bindata.AssetDir,
AssetInfo: bindata.AssetInfo,
Prefix: root,
}
}
func ServeFrontend(urlPrefix string, sandbox bool, logger log.Logger) http.Handler {
herr := func(w http.ResponseWriter, err string) {
logger.Log("err", err)
http.Error(w, err, http.StatusInternalServerError)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
endpoint_utils.WriteBrowserSecurityHeaders(w)
// The following check is to prevent a misconfigured osquery from submitting
// data to the root endpoint (the osquery remote API uses POST for all its endpoints).
// See https://github.com/fleetdm/fleet/issues/16182.
if r.Method == "POST" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
fs := newBinaryFileSystem("/frontend")
file, err := fs.Open("templates/react.tmpl")
if err != nil {
herr(w, "load react template: "+err.Error())
return
}
data, err := io.ReadAll(file)
if err != nil {
herr(w, "read bindata file: "+err.Error())
return
}
t, err := template.New("react").Parse(string(data))
if err != nil {
herr(w, "create react template: "+err.Error())
return
}
serverType := "on-premise"
if sandbox {
serverType = "sandbox"
}
if err := t.Execute(w, struct {
URLPrefix string
ServerType string
}{
URLPrefix: urlPrefix,
ServerType: serverType,
}); err != nil {
herr(w, "execute react template: "+err.Error())
return
}
})
}
// ServeEndUserEnrollOTA implements the entrypoint handler for the /enroll
// path, used to add hosts in "BYOD" mode (currently, iPhone/iPad/Android).
func ServeEndUserEnrollOTA(
svc fleet.Service,
urlPrefix string,
ds fleet.Datastore,
logger log.Logger,
) http.Handler {
herr := func(w http.ResponseWriter, err string) {
logger.Log("err", err)
http.Error(w, err, http.StatusInternalServerError)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
endpoint_utils.WriteBrowserSecurityHeaders(w)
setupRequired, err := svc.SetupRequired(r.Context())
if err != nil {
herr(w, "setup required err: "+err.Error())
return
}
if setupRequired {
herr(w, "fleet instance not setup")
return
}
appCfg, err := ds.AppConfig(r.Context())
if err != nil {
herr(w, "load appconfig err: "+err.Error())
return
}
errorMsg := r.URL.Query().Get("error")
if errorMsg != "" {
if err := renderEnrollPage(w, appCfg, urlPrefix, "", errorMsg); err != nil {
herr(w, err.Error())
}
return
}
enrollSecret := r.URL.Query().Get("enroll_secret")
if enrollSecret == "" {
if err := renderEnrollPage(w, appCfg, urlPrefix, "", "This URL is invalid. : Enroll secret is invalid. Please contact your IT admin."); err != nil {
herr(w, err.Error())
}
return
}
authRequired, err := shared_mdm.RequiresEnrollOTAAuthentication(r.Context(), ds,
enrollSecret, appCfg.MDM.MacOSSetup.EnableEndUserAuthentication)
if err != nil {
herr(w, "check if authentication is required err: "+err.Error())
return
}
if authRequired {
// check if authentication cookie is present, in which case we go ahead with
// offering the enrollment profile to download.
var cookieIdPRef string
if byodCookie, _ := r.Cookie(shared_mdm.BYODIdpCookieName); byodCookie != nil {
cookieIdPRef = byodCookie.Value
// if the cookie is present, we should also receive a (matching) enroll reference
if cookieIdPRef != "" {
enrollRef := r.URL.Query().Get("enrollment_reference")
if cookieIdPRef != enrollRef {
cookieIdPRef = "" // cookie does not match the enroll reference, so we ignore it and require authentication
}
}
}
if cookieIdPRef == "" {
// IdP authentication has not been completed yet, initiate it by
// redirecting to the configured IdP provider.
if err := initiateOTAEnrollSSO(svc, w, r, enrollSecret); err != nil {
herr(w, "initiate IdP SSO authentication err: "+err.Error())
return
}
return
}
}
// if we get here, IdP SSO authentication is either not required, or has
// been successfully completed (we have a cookie with the IdP account
// reference).
if err := renderEnrollPage(w, appCfg, urlPrefix, enrollSecret, ""); err != nil {
herr(w, err.Error())
return
}
})
}
func generateEnrollOTAURL(fleetURL string, enrollSecret string) (string, error) {
path, err := url.JoinPath(fleetURL, "/api/v1/fleet/enrollment_profiles/ota")
if err != nil {
return "", fmt.Errorf("creating path for end user ota enrollment url: %w", err)
}
enrollURL, err := url.Parse(path)
if err != nil {
return "", fmt.Errorf("parsing end user ota enrollment url: %w", err)
}
q := enrollURL.Query()
q.Set("enroll_secret", enrollSecret)
enrollURL.RawQuery = q.Encode()
return enrollURL.String(), nil
}
func renderEnrollPage(w io.Writer, appCfg *fleet.AppConfig, urlPrefix, enrollSecret, errorMessage string) error {
fs := newBinaryFileSystem("/frontend")
file, err := fs.Open("templates/enroll-ota.html")
if err != nil {
return fmt.Errorf("load enroll ota template: %w", err)
}
data, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("read bindata file: %w", err)
}
t, err := template.New("enroll-ota").Parse(string(data))
if err != nil {
return fmt.Errorf("create react template: %w", err)
}
enrollURL, err := generateEnrollOTAURL(urlPrefix, enrollSecret)
if err != nil {
return fmt.Errorf("generate enroll ota url: %w", err)
}
if err := t.Execute(w, struct {
EnrollURL string
URLPrefix string
ErrorMessage string
AndroidMDMEnabled bool
MacMDMEnabled bool
AndroidFeatureEnabled bool
}{
URLPrefix: urlPrefix,
EnrollURL: enrollURL,
ErrorMessage: errorMessage,
AndroidMDMEnabled: appCfg.MDM.AndroidEnabledAndConfigured,
MacMDMEnabled: appCfg.MDM.EnabledAndConfigured,
AndroidFeatureEnabled: true,
}); err != nil {
return fmt.Errorf("execute react template: %w", err)
}
return nil
}
func initiateOTAEnrollSSO(svc fleet.Service, w http.ResponseWriter, r *http.Request, enrollSecret string) error {
ssnID, ssnDurationSecs, idpURL, err := svc.InitiateMDMSSO(r.Context(), "ota_enroll", "/enroll?enroll_secret="+url.QueryEscape(enrollSecret), "")
if err != nil {
return err
}
setSSOCookie(w, ssnID, ssnDurationSecs)
http.Redirect(w, r, idpURL, http.StatusSeeOther)
return nil
}
func ServeStaticAssets(path string) http.Handler {
contentTypes := []string{"text/javascript", "text/css"}
withoutGzip := http.StripPrefix(path, http.FileServer(newBinaryFileSystem("/assets")))
withOpts, err := gzhttp.NewWrapper(gzhttp.ContentTypes(contentTypes))
if err != nil { // fall back to serving without gzip if serving with gzip somehow fails
return withoutGzip
}
return withOpts(withoutGzip)
}