Skip to content

Commit

Permalink
Split out UserAuthenticate (#101)
Browse files Browse the repository at this point in the history
  • Loading branch information
jessepeterson authored Apr 22, 2024
1 parent 3fcbd3b commit 0c46445
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 46 deletions.
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

0 comments on commit 0c46445

Please sign in to comment.