mirror of
https://github.com/fleetdm/fleet
synced 2026-05-22 16:39:01 +00:00
<!-- 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 -->
116 lines
3.6 KiB
Go
116 lines
3.6 KiB
Go
package sso
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/datastore/redis"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
redigo "github.com/gomodule/redigo/redis"
|
|
)
|
|
|
|
type SSORequestData struct {
|
|
HostUUID string `json:"host_uuid,omitempty"`
|
|
Initiator string `json:"initiator,omitempty"`
|
|
}
|
|
|
|
// Session stores state for the lifetime of a single sign on session.
|
|
type Session struct {
|
|
// RequestID is the SAMLRequest ID that must match "InResponseTo" in the SAMLResponse.
|
|
RequestID string `json:"request_id"`
|
|
// Metadata is the IdP's Metadata used to validate the response.
|
|
Metadata string `json:"metadata"`
|
|
// OriginalURL is the resource being accessed when login request was triggered
|
|
OriginalURL string `json:"original_url"`
|
|
// Additional request data that may be needed to complete the SSO process.
|
|
RequestData SSORequestData `json:"request_data,omitempty"`
|
|
}
|
|
|
|
// SessionStore persists state of a sso session across process boundries and
|
|
// method calls by associating the state of the sign on session with a unique
|
|
// token created by the user agent (browser SPA). The lifetime of the state object
|
|
// is constrained in the backing store (Redis) so if the sso process is not completed in
|
|
// a reasonable amount of time, it automatically expires and is removed.
|
|
type SessionStore interface {
|
|
create(sessionID, requestID, originalURL, metadata string, lifetimeSecs uint, requestData SSORequestData) error
|
|
get(sessionID string) (*Session, error)
|
|
expire(sessionID string) error
|
|
// Fullfill loads a session with the given session ID, deletes it and returns it.
|
|
Fullfill(sessionID string) (*Session, error)
|
|
}
|
|
|
|
// NewSessionStore creates a SessionStore
|
|
func NewSessionStore(pool fleet.RedisPool) SessionStore {
|
|
return &store{pool}
|
|
}
|
|
|
|
type store struct {
|
|
pool fleet.RedisPool
|
|
}
|
|
|
|
func (s *store) create(sessionID, requestID, originalURL, metadata string, lifetimeSecs uint, requestData SSORequestData) error {
|
|
if len(sessionID) < 8 {
|
|
return errors.New("request id must be 8 or more characters in length")
|
|
}
|
|
conn := redis.ConfigureDoer(s.pool, s.pool.Get())
|
|
defer conn.Close()
|
|
|
|
session := Session{
|
|
RequestID: requestID,
|
|
Metadata: metadata,
|
|
OriginalURL: originalURL,
|
|
RequestData: requestData,
|
|
}
|
|
var writer bytes.Buffer
|
|
err := json.NewEncoder(&writer).Encode(session)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = conn.Do("SETEX", sessionID, lifetimeSecs, writer.String())
|
|
return err
|
|
}
|
|
|
|
func (s *store) get(sessionID string) (*Session, error) {
|
|
// not reading from a replica here as this gets called in close succession
|
|
// in the auth flow, with initiate SSO writing and callback SSO having to
|
|
// read that write.
|
|
conn := redis.ConfigureDoer(s.pool, s.pool.Get())
|
|
defer conn.Close()
|
|
val, err := redigo.String(conn.Do("GET", sessionID))
|
|
if err != nil {
|
|
if err == redigo.ErrNil {
|
|
return nil, fleet.NewAuthRequiredError("session not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var sess Session
|
|
reader := bytes.NewBufferString(val)
|
|
err = json.NewDecoder(reader).Decode(&sess)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &sess, nil
|
|
}
|
|
|
|
func (s *store) expire(sessionID string) error {
|
|
conn := redis.ConfigureDoer(s.pool, s.pool.Get())
|
|
defer conn.Close()
|
|
_, err := conn.Do("DEL", sessionID)
|
|
return err
|
|
}
|
|
|
|
func (s *store) Fullfill(sessionID string) (*Session, error) {
|
|
session, err := s.get(sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sso request invalid: %w", err)
|
|
}
|
|
// Remove session so that it can't be reused before it expires.
|
|
err = s.expire(sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("remove sso request: %w", err)
|
|
}
|
|
return session, nil
|
|
}
|