HTTP Message Signature Auth for certificate_request (#35139)
**Related issue:** Resolves #34278
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
|
||||
@@ -17,6 +18,21 @@ type key int
|
||||
|
||||
const hostIdentityKey key = 0
|
||||
|
||||
var sigAuthenticatedEndpoints = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^/api/(?:v1|latest)/fleet/certificate_authorities/\d+/request_certificate$`),
|
||||
regexp.MustCompile(`^/api/fleet/orbit/`),
|
||||
regexp.MustCompile(`/osquery/`),
|
||||
}
|
||||
|
||||
func IsSigAuthEndpoint(path string) bool {
|
||||
for _, endp := range sigAuthenticatedEndpoints {
|
||||
if endp.Match([]byte(path)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NewContext creates a new context.Context with host identity cert.
|
||||
func NewContext(ctx context.Context, hostIdentity types.HostIdentityCertificate) context.Context {
|
||||
return context.WithValue(ctx, hostIdentityKey, hostIdentity)
|
||||
@@ -43,7 +59,7 @@ func Middleware(ds fleet.Datastore, requireSignature bool, logger kitlog.Logger)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if !strings.Contains(req.URL.Path, "/api/fleet/orbit/") && !strings.Contains(req.URL.Path, "/osquery/") {
|
||||
if !IsSigAuthEndpoint(req.URL.Path) {
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
@@ -21,9 +22,18 @@ import (
|
||||
|
||||
// This code largely adapted from fleet/website/api/controllers/get-est-device-certificate.js
|
||||
func (svc *Service) RequestCertificate(ctx context.Context, p fleet.RequestCertificatePayload) (*string, error) {
|
||||
if err := svc.authz.Authorize(ctx, &fleet.RequestCertificatePayload{}, fleet.ActionWrite); err != nil {
|
||||
auth, authOk := authz.FromContext(ctx)
|
||||
if !authOk {
|
||||
// This shouldn't be possible
|
||||
return nil, &fleet.BadRequestError{Message: "Missing authentication authorization context"}
|
||||
}
|
||||
if auth.AuthnMethod() == authz.AuthnHTTPMessageSignature {
|
||||
// Message Signature auth is not granular, device already checked and authorized in middleware
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
} else if err := svc.authz.Authorize(ctx, &fleet.RequestCertificatePayload{}, fleet.ActionWrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ca, err := svc.ds.GetCertificateAuthorityByID(ctx, p.ID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,7 +10,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/est"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
@@ -127,6 +130,8 @@ func TestRequestCertificate(t *testing.T) {
|
||||
Password: ptr.String("test-password"),
|
||||
}
|
||||
|
||||
useDefaultAuthContext := true
|
||||
|
||||
baseSetupForTests := func() (*Service, context.Context) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
@@ -155,7 +160,13 @@ func TestRequestCertificate(t *testing.T) {
|
||||
est.WithLogger(logger),
|
||||
),
|
||||
}
|
||||
ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
|
||||
|
||||
authCtx := &authz_ctx.AuthorizationContext{}
|
||||
ctx := authz_ctx.NewContext(context.Background(), authCtx)
|
||||
if useDefaultAuthContext {
|
||||
authCtx.SetAuthnMethod(authz_ctx.AuthnUserToken)
|
||||
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
|
||||
}
|
||||
|
||||
oauthIntrospectResponse = defaultOauthIntrospectResponse
|
||||
oauthIntrospectStatus = http.StatusOK
|
||||
@@ -195,6 +206,26 @@ func TestRequestCertificate(t *testing.T) {
|
||||
require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert)
|
||||
})
|
||||
|
||||
t.Run("Request a certificate - Happy path, no IDP, http sig auth", func(t *testing.T) {
|
||||
useDefaultAuthContext = false
|
||||
defer func() { useDefaultAuthContext = true }()
|
||||
|
||||
svc, ctx := baseSetupForTests()
|
||||
|
||||
authCtx, ok := authz_ctx.FromContext(ctx)
|
||||
require.True(t, ok)
|
||||
authCtx.SetAuthnMethod(authz_ctx.AuthnHTTPMessageSignature)
|
||||
ctx = httpsig.NewContext(ctx, types.HostIdentityCertificate{HostID: ptr.Uint(1), NotValidAfter: time.Now().Add(24 * time.Hour)})
|
||||
|
||||
cert, err := svc.RequestCertificate(ctx, fleet.RequestCertificatePayload{
|
||||
ID: hydrantCA.ID,
|
||||
CSR: goodCSR,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cert)
|
||||
require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert)
|
||||
})
|
||||
|
||||
t.Run("Request a certificate - Happy path, no IDP, UPN does not match IDP info(should pass)", func(t *testing.T) {
|
||||
svc, ctx := baseSetupForTests()
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ const (
|
||||
// authentication token. This authentication mode does not support granular
|
||||
// authorization.
|
||||
AuthnOrbitToken
|
||||
// AuthnHTTPMessageSignature is when authentication is done via HTTP Message Signature,
|
||||
// backed by the device's SCEP Identity certificate. This authentication method does not support
|
||||
// granular authorization.
|
||||
AuthnHTTPMessageSignature
|
||||
)
|
||||
|
||||
// AuthorizationContext contains the context information used for the
|
||||
|
||||
@@ -3,7 +3,11 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
kithttp "github.com/go-kit/kit/transport/http"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/token"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||
@@ -40,6 +44,22 @@ func AuthenticatedUser(svc fleet.Service, next endpoint.Endpoint) endpoint.Endpo
|
||||
return next(ctx, request)
|
||||
}
|
||||
|
||||
requestPath, _ := ctx.Value(kithttp.ContextKeyRequestPath).(string)
|
||||
|
||||
httpSig, sigOk := httpsig.FromContext(ctx)
|
||||
if sigOk && httpsig.IsSigAuthEndpoint(requestPath) {
|
||||
if time.Now().After(httpSig.NotValidAfter) {
|
||||
return nil, fleet.NewAuthFailedError("host identity certificate expired")
|
||||
}
|
||||
if httpSig.HostID == nil {
|
||||
return nil, fleet.NewAuthFailedError("identity certificate is not linked to a specific host")
|
||||
}
|
||||
if ac, ok := authz.FromContext(ctx); ok {
|
||||
ac.SetAuthnMethod(authz.AuthnHTTPMessageSignature)
|
||||
}
|
||||
return next(ctx, request)
|
||||
}
|
||||
|
||||
// if not successful, try again this time with errors
|
||||
sessionKey, ok := token.FromContext(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
|
||||
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/types"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
kithttp "github.com/go-kit/kit/transport/http"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHTTPMessageSignAuth(t *testing.T) {
|
||||
var nextCalled bool
|
||||
var nextCtx context.Context
|
||||
next := func(ctx context.Context, request any) (response any, err error) {
|
||||
nextCalled = true
|
||||
nextCtx = ctx
|
||||
return nil, nil
|
||||
}
|
||||
// Pass in a nil service interface. We shouldn't hit the place where it gets called while
|
||||
// http only signatures
|
||||
endpoint := AuthenticatedUser(nil, next)
|
||||
|
||||
tcs := []struct {
|
||||
Name string
|
||||
Path string
|
||||
Err string
|
||||
Called bool
|
||||
HostIdentCert *types.HostIdentityCertificate
|
||||
}{
|
||||
{
|
||||
Name: "no http auth path",
|
||||
Path: "/some/path",
|
||||
Err: "no auth token",
|
||||
},
|
||||
{
|
||||
Name: "no http auth path with cert",
|
||||
Path: "/some/path",
|
||||
Err: "no auth token",
|
||||
HostIdentCert: &types.HostIdentityCertificate{},
|
||||
},
|
||||
{
|
||||
Name: "no http auth path with good cert context",
|
||||
Path: "/some/path",
|
||||
Err: "no auth token",
|
||||
HostIdentCert: &types.HostIdentityCertificate{
|
||||
NotValidAfter: time.Now().Add(24 * time.Hour),
|
||||
HostID: ptr.Uint(1),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "auth path with no cert",
|
||||
Path: "/osquery/",
|
||||
Err: "no auth token",
|
||||
},
|
||||
{
|
||||
Name: "auth path with good cert context",
|
||||
Path: "/osquery/",
|
||||
Err: "",
|
||||
HostIdentCert: &types.HostIdentityCertificate{
|
||||
NotValidAfter: time.Now().Add(24 * time.Hour),
|
||||
HostID: ptr.Uint(1),
|
||||
},
|
||||
Called: true,
|
||||
},
|
||||
{
|
||||
Name: "auth path with good cert context 2",
|
||||
Path: "/api/fleet/orbit/foo",
|
||||
Err: "",
|
||||
HostIdentCert: &types.HostIdentityCertificate{
|
||||
NotValidAfter: time.Now().Add(24 * time.Hour),
|
||||
HostID: ptr.Uint(1),
|
||||
},
|
||||
Called: true,
|
||||
},
|
||||
{
|
||||
Name: "auth path with good cert context 3",
|
||||
Path: "/api/v1/fleet/certificate_authorities/3/request_certificate",
|
||||
Err: "",
|
||||
HostIdentCert: &types.HostIdentityCertificate{
|
||||
NotValidAfter: time.Now().Add(24 * time.Hour),
|
||||
HostID: ptr.Uint(1),
|
||||
},
|
||||
Called: true,
|
||||
},
|
||||
{
|
||||
Name: "auth path with zeroed cert context",
|
||||
Path: "/osquery/",
|
||||
Err: "host identity certificate expired",
|
||||
HostIdentCert: &types.HostIdentityCertificate{},
|
||||
},
|
||||
{
|
||||
Name: "auth path with expired cert context",
|
||||
Path: "/osquery/",
|
||||
Err: "host identity certificate expired",
|
||||
HostIdentCert: &types.HostIdentityCertificate{
|
||||
NotValidAfter: time.Now().Add(-24 * time.Hour),
|
||||
HostID: ptr.Uint(1),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "auth path with missing host id",
|
||||
Path: "/osquery/",
|
||||
Err: "identity certificate is not linked to a specific host",
|
||||
HostIdentCert: &types.HostIdentityCertificate{
|
||||
NotValidAfter: time.Now().Add(24 * time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
nextCalled = false
|
||||
nextCtx = nil
|
||||
ctx := context.Background()
|
||||
ctx = authz.NewContext(ctx, &authz.AuthorizationContext{})
|
||||
ctx = context.WithValue(ctx, kithttp.ContextKeyRequestPath, tc.Path)
|
||||
if tc.HostIdentCert != nil {
|
||||
ctx = httpsig.NewContext(ctx, *tc.HostIdentCert)
|
||||
}
|
||||
|
||||
_, err := endpoint(ctx, nil)
|
||||
if tc.Err == "" {
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
authErr := &fleet.AuthFailedError{}
|
||||
authHeaderErr := &fleet.AuthHeaderRequiredError{}
|
||||
switch {
|
||||
case errors.As(err, &authErr):
|
||||
assert.Contains(t, authErr.Internal(), tc.Err)
|
||||
case errors.As(err, &authHeaderErr):
|
||||
assert.Contains(t, authHeaderErr.Internal(), tc.Err)
|
||||
default:
|
||||
assert.ErrorContains(t, err, tc.Err)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tc.Called, nextCalled)
|
||||
if tc.Called {
|
||||
assert.NotNil(t, ctx)
|
||||
auth, ok := authz.FromContext(nextCtx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, authz.AuthnHTTPMessageSignature, auth.AuthnMethod())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user