Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split out UserAuthenticate #101

Merged
merged 4 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/nanomdm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func main() {
flRetro = flag.Bool("retro", false, "Allow retroactive certificate-authorization association")
flDMURLPfx = flag.String("dm", "", "URL to send Declarative Management requests to")
flAuthProxy = flag.String("auth-proxy-url", "", "Reverse proxy URL target for MDM-authenticated HTTP requests")
flUAZLChal = flag.Bool("ua-zl-dc", false, "reply with zero-length DigestChallenge for UserAuthenticate")
)
flag.Parse()

Expand Down Expand Up @@ -110,7 +111,10 @@ func main() {
}

// create 'core' MDM service
nanoOpts := []nanomdm.Option{nanomdm.WithLogger(logger.With("service", "nanomdm"))}
nanoOpts := []nanomdm.Option{
nanomdm.WithLogger(logger.With("service", "nanomdm")),
nanomdm.WithUserAuthenticate(nanomdm.NewUAService(mdmStorage, *flUAZLChal)),
}
if *flDMURLPfx != "" {
logger.Debug("msg", "declarative management setup", "url", *flDMURLPfx)
dm, err := nanomdm.NewDeclarativeManagementHTTPCaller(*flDMURLPfx, http.DefaultClient)
Expand Down
8 changes: 8 additions & 0 deletions docs/operations-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ NanoMDM supports a MicroMDM-compatible [webhook callback](https://github.com/mic

Enables the authentication proxy and reverse proxies HTTP requests from the server's `/authproxy/` endpoint to this URL if the client provides the device's enrollment authentication. See below for more information.

### -ua-zl-dc

* reply with zero-length DigestChallenge for UserAuthenticate

By default NanoMDM will respond to a `UserAuthenticate` message with an HTTP 410. This effectively declines management of that the user channel for that MDM user. Enabling this option turns on the "zero-length" Digest Challenge mode where NanoMDM replies with an empty Digest Challenge to enable management each time a client enrolls.

Note that the `UserAuthenticate` message is only for "directory" MDM users and not the "primary" MDM user enrollment. See also [Apple's discussion of UserAthenticate](https://developer.apple.com/documentation/devicemanagement/userauthenticate#discussion) for more information.

## HTTP endpoints & APIs

### MDM
Expand Down
57 changes: 14 additions & 43 deletions service/nanomdm/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package nanomdm
import (
"errors"
"fmt"
"net/http"

"github.com/micromdm/nanomdm/log"
"github.com/micromdm/nanomdm/log/ctxlog"
Expand All @@ -19,16 +18,11 @@ type Service struct {
normalizer func(e *mdm.Enrollment) *mdm.EnrollID
store storage.ServiceStore

// By default the UserAuthenticate message will be rejected (410
// response). If this is set true then a static zero-length
// digest challenge will be supplied to the first UserAuthenticate
// check-in message. See the Discussion section of
// https://developer.apple.com/documentation/devicemanagement/userauthenticate
sendEmptyDigestChallenge bool
storeRejectedUserAuth bool

// Declarative Management
dm service.DeclarativeManagement

// UserAuthenticate processor
ua service.UserAuthenticate
}

// normalize generates enrollment IDs that are used by other
Expand Down Expand Up @@ -72,6 +66,13 @@ func WithDeclarativeManagement(dm service.DeclarativeManagement) Option {
}
}

// WithUserAuthenticate configures a UserAuthenticate check-in message handler.
func WithUserAuthenticate(ua service.UserAuthenticate) Option {
return func(s *Service) {
s.ua = ua
}
}

// New returns a new NanoMDM main service.
func New(store storage.ServiceStore, opts ...Option) *Service {
nanomdm := &Service{
Expand Down Expand Up @@ -144,45 +145,15 @@ func (s *Service) CheckOut(r *mdm.Request, message *mdm.CheckOut) error {
return s.store.Disable(r)
}

const emptyDigestChallenge = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DigestChallenge</key>
<string></string>
</dict>
</plist>`

var emptyDigestChallengeBytes = []byte(emptyDigestChallenge)

// UserAuthenticate Check-in message implementation
func (s *Service) UserAuthenticate(r *mdm.Request, message *mdm.UserAuthenticate) ([]byte, error) {
if err := s.setupRequest(r, &message.Enrollment); err != nil {
return nil, err
}
logger := ctxlog.Logger(r.Context, s.logger)
if s.sendEmptyDigestChallenge || s.storeRejectedUserAuth {
if err := s.store.StoreUserAuthenticate(r, message); err != nil {
return nil, err
}
}
// if the DigestResponse is empty then this is the first (of two)
// UserAuthenticate messages depending on our response
if message.DigestResponse == "" {
if s.sendEmptyDigestChallenge {
logger.Info(
"msg", "sending empty DigestChallenge response to UserAuthenticate",
)
return emptyDigestChallengeBytes, nil
}
return nil, service.NewHTTPStatusError(
http.StatusGone,
fmt.Errorf("declining management of user: %s", r.ID),
)
if s.ua == nil {
return nil, errors.New("no UserAuthenticate handler")
}
logger.Debug(
"msg", "sending empty response to second UserAuthenticate",
)
return nil, nil
return s.ua.UserAuthenticate(r, message)
}

func (s *Service) SetBootstrapToken(r *mdm.Request, message *mdm.SetBootstrapToken) error {
Expand Down
78 changes: 78 additions & 0 deletions service/nanomdm/ua.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package nanomdm

import (
"fmt"
"net/http"

"github.com/micromdm/nanomdm/log"
"github.com/micromdm/nanomdm/log/ctxlog"
"github.com/micromdm/nanomdm/mdm"
"github.com/micromdm/nanomdm/service"
"github.com/micromdm/nanomdm/storage"
)

// UAService is a basic UserAuthenticate service that optionally implements
// the "zero-length" UserAuthenticate protocol.
// See https://developer.apple.com/documentation/devicemanagement/userauthenticate
type UAService struct {
logger log.Logger
store storage.UserAuthenticateStore

// By default the UserAuthenticate message will be rejected (410
// response). If this is set true then a static zero-length
// digest challenge will be supplied to the first UserAuthenticate
// check-in message. See the Discussion section of
// https://developer.apple.com/documentation/devicemanagement/userauthenticate
sendEmptyDigestChallenge bool
storeRejectedUserAuth bool
}

// NewUAService creates a new UserAuthenticate check-in message handler.
func NewUAService(store storage.UserAuthenticateStore, sendEmptyDigestChallenge bool) *UAService {
return &UAService{
logger: log.NopLogger,
store: store,
sendEmptyDigestChallenge: sendEmptyDigestChallenge,
}
}

const emptyDigestChallenge = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DigestChallenge</key>
<string></string>
</dict>
</plist>`

var emptyDigestChallengeBytes = []byte(emptyDigestChallenge)

// UserAuthenticate will decline management of a user unless configured
// for the empty digest 2-step UserAuthenticate protocol.
// It implements the NanoMDM service method for UserAuthenticate check-in messages.
func (s *UAService) UserAuthenticate(r *mdm.Request, message *mdm.UserAuthenticate) ([]byte, error) {
logger := ctxlog.Logger(r.Context, s.logger)
if s.sendEmptyDigestChallenge || s.storeRejectedUserAuth {
if err := s.store.StoreUserAuthenticate(r, message); err != nil {
return nil, err
}
}
// if the DigestResponse is empty then this is the first (of two)
// UserAuthenticate messages depending on our response
if message.DigestResponse == "" {
if s.sendEmptyDigestChallenge {
logger.Info(
"msg", "sending empty DigestChallenge response to UserAuthenticate",
)
return emptyDigestChallengeBytes, nil
}
return nil, service.NewHTTPStatusError(
http.StatusGone,
fmt.Errorf("declining management of user: %s", r.ID),
)
}
logger.Debug(
"msg", "sending empty response to second UserAuthenticate",
)
return nil, nil
}
62 changes: 62 additions & 0 deletions service/nanomdm/ua_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package nanomdm

import (
"bytes"
"errors"
"testing"

"github.com/micromdm/nanomdm/mdm"
"github.com/micromdm/nanomdm/service"
)

type fauxStore struct {
ua *mdm.UserAuthenticate
}

func (f *fauxStore) StoreUserAuthenticate(_ *mdm.Request, msg *mdm.UserAuthenticate) error {
f.ua = msg
return nil
}

func newMDMReq() *mdm.Request {
return &mdm.Request{EnrollID: &mdm.EnrollID{ID: "<test>"}}
}

func TestUAServiceReject(t *testing.T) {
store := &fauxStore{}
s := NewUAService(store, false)
_, err := s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{})
var httpErr *service.HTTPStatusError
if !errors.As(err, &httpErr) {
// should be returning a HTTPStatusError (to deny management)
t.Fatalf("no error or incorrect error type")
}
if httpErr.Status != 410 {
// if we've kept the "send-empty" false this needs to return a 410
// i.e. decline management of the user.
t.Error("status not 410")
}
}

func TestUAService(t *testing.T) {
store := &fauxStore{}
s := NewUAService(store, true)
ret, err := s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{})
if err != nil {
// should be no error
t.Fatal(err)
}
if !bytes.Equal(ret, emptyDigestChallengeBytes) {
t.Error("response bytes not equal")
}
// second request with DigestResponse
ret, err = s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{DigestResponse: "test"})
if err != nil {
// should be no error
t.Fatal(err)
}
if ret != nil {
t.Error("response bytes not empty")
}

}
7 changes: 6 additions & 1 deletion service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ type DeclarativeManagement interface {
DeclarativeManagement(*mdm.Request, *mdm.DeclarativeManagement) ([]byte, error)
}

// UserAuthenticate is an interface for processing the UserAuthenticate MDM check-in message.
type UserAuthenticate interface {
UserAuthenticate(*mdm.Request, *mdm.UserAuthenticate) ([]byte, error)
}

// Checkin represents the various check-in requests.
// See https://developer.apple.com/documentation/devicemanagement/check-in
type Checkin interface {
Expand All @@ -19,7 +24,7 @@ type Checkin interface {
CheckOut(*mdm.Request, *mdm.CheckOut) error
SetBootstrapToken(*mdm.Request, *mdm.SetBootstrapToken) error
GetBootstrapToken(*mdm.Request, *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error)
UserAuthenticate(*mdm.Request, *mdm.UserAuthenticate) ([]byte, error)
UserAuthenticate
DeclarativeManagement
}

Expand Down
6 changes: 5 additions & 1 deletion storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ import (
"github.com/micromdm/nanomdm/mdm"
)

type UserAuthenticateStore interface {
StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error
}

// CheckinStore stores MDM check-in data.
type CheckinStore interface {
StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) error
StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error
StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error
Disable(r *mdm.Request) error
UserAuthenticateStore
}

// CommandAndReportResultsStore stores and retrieves MDM command queue data.
Expand Down