diff --git a/api/types/headlessauthn.go b/api/types/headlessauthn.go index 626b9e0952671..39ff6a6341984 100644 --- a/api/types/headlessauthn.go +++ b/api/types/headlessauthn.go @@ -27,6 +27,10 @@ func (h *HeadlessAuthentication) CheckAndSetDefaults() error { return trace.Wrap(err) } + if h.Metadata.Expires == nil || h.Metadata.Expires.IsZero() { + return trace.BadParameter("headless authentication resource must have non-zero header.metadata.expires") + } + if h.Version == "" { h.Version = V1 } diff --git a/lib/services/headlessauthn.go b/lib/services/headlessauthn.go index 79f5c9dbfbf87..e056d00110387 100644 --- a/lib/services/headlessauthn.go +++ b/lib/services/headlessauthn.go @@ -34,8 +34,6 @@ func ValidateHeadlessAuthentication(h *types.HeadlessAuthentication) error { } switch { - case h.Metadata.Expires == nil: - return trace.BadParameter("headless authentication resource must have non-empty header.metadata.expires") case h.Version != types.V1: return trace.BadParameter("unsupported headless authentication resource version %q, current supported version is %s", h.Version, types.V1) case h.User == "": diff --git a/lib/services/local/headlessauthn.go b/lib/services/local/headlessauthn.go new file mode 100644 index 0000000000000..a0f0515e1ed90 --- /dev/null +++ b/lib/services/local/headlessauthn.go @@ -0,0 +1,132 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package local + +import ( + "context" + "time" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils" +) + +// CreateHeadlessAuthenticationStub creates a headless authentication stub in the backend. +func (s *IdentityService) CreateHeadlessAuthenticationStub(ctx context.Context, name string) (*types.HeadlessAuthentication, error) { + expires := s.Clock().Now().Add(time.Minute) + headlessAuthn := &types.HeadlessAuthentication{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: name, + Expires: &expires, + }, + }, + } + + item, err := marshalHeadlessAuthenticationToItem(headlessAuthn) + if err != nil { + return nil, trace.Wrap(err) + } + + if _, err = s.Create(ctx, *item); err != nil { + return nil, trace.Wrap(err) + } + return headlessAuthn, nil +} + +// CompareAndSwapHeadlessAuthentication validates the new headless authentication and +// performs a compare and swap replacement on a headless authentication resource. +func (s *IdentityService) CompareAndSwapHeadlessAuthentication(ctx context.Context, old, new *types.HeadlessAuthentication) (*types.HeadlessAuthentication, error) { + if err := services.ValidateHeadlessAuthentication(new); err != nil { + return nil, trace.Wrap(err) + } + + oldItem, err := marshalHeadlessAuthenticationToItem(old) + if err != nil { + return nil, trace.Wrap(err) + } + + newItem, err := marshalHeadlessAuthenticationToItem(new) + if err != nil { + return nil, trace.Wrap(err) + } + + _, err = s.CompareAndSwap(ctx, *oldItem, *newItem) + if err != nil { + return nil, trace.Wrap(err) + } + + return new, nil +} + +// GetHeadlessAuthentication returns a headless authentication from the backend by name. +func (s *IdentityService) GetHeadlessAuthentication(ctx context.Context, name string) (*types.HeadlessAuthentication, error) { + item, err := s.Get(ctx, headlessAuthenticationKey(name)) + if err != nil { + return nil, trace.Wrap(err) + } + + headlessAuthn, err := unmarshalHeadlessAuthenticationFromItem(item) + if err != nil { + return nil, trace.Wrap(err) + } + return headlessAuthn, nil +} + +// DeleteHeadlessAuthentication deletes a headless authentication from the backend by name. +func (s *IdentityService) DeleteHeadlessAuthentication(ctx context.Context, name string) error { + err := s.Delete(ctx, headlessAuthenticationKey(name)) + return trace.Wrap(err) +} + +func marshalHeadlessAuthenticationToItem(headlessAuthn *types.HeadlessAuthentication) (*backend.Item, error) { + if err := headlessAuthn.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + value, err := utils.FastMarshal(headlessAuthn) + if err != nil { + return nil, trace.Wrap(err) + } + + return &backend.Item{ + Key: headlessAuthenticationKey(headlessAuthn.Metadata.Name), + Value: value, + Expires: *headlessAuthn.Metadata.Expires, + }, nil +} + +func unmarshalHeadlessAuthenticationFromItem(item *backend.Item) (*types.HeadlessAuthentication, error) { + var headlessAuthn types.HeadlessAuthentication + if err := utils.FastUnmarshal(item.Value, &headlessAuthn); err != nil { + return nil, trace.Wrap(err, "error unmarshalling headless authentication from storage") + } + + headlessAuthn.Metadata.Expires = &item.Expires + if err := headlessAuthn.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return &headlessAuthn, nil +} + +func headlessAuthenticationKey(name string) []byte { + return backend.Key("headless_authentication", name) +} diff --git a/lib/services/local/headlessauthn_test.go b/lib/services/local/headlessauthn_test.go new file mode 100644 index 0000000000000..b79a90f9ed382 --- /dev/null +++ b/lib/services/local/headlessauthn_test.go @@ -0,0 +1,149 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package local_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/services" +) + +// TestIdentityService_HeadlessAuthenticationBackend tests headless authentication +// backend methods for functionality and validation. +func TestIdentityService_HeadlessAuthenticationBackend(t *testing.T) { + t.Parallel() + identity := newIdentityService(t, clockwork.NewFakeClock()) + + ctx := context.Background() + pubUUID := services.NewHeadlessAuthenticationID([]byte(sshPubKey)) + expires := time.Now().Add(time.Minute) + + expectBadParameter := func(tt require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err), "expected bad parameter error but got: %v", err) + } + + tests := []struct { + name string + ha *types.HeadlessAuthentication + createStubErr require.ErrorAssertionFunc + compareAndSwapErr require.ErrorAssertionFunc + }{ + { + name: "OK headless authentication", + ha: &types.HeadlessAuthentication{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: pubUUID, + Expires: &expires, + }, + }, + User: "user", + PublicKey: []byte(sshPubKey), + }, + }, { + name: "NOK name missing", + ha: &types.HeadlessAuthentication{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Expires: &expires, + }, + }, + User: "user", + PublicKey: []byte(sshPubKey), + }, + createStubErr: expectBadParameter, + }, { + name: "NOK expires missing", + ha: &types.HeadlessAuthentication{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: pubUUID, + }, + }, + User: "user", + PublicKey: []byte(sshPubKey), + }, + compareAndSwapErr: expectBadParameter, + }, { + name: "NOK username missing", + ha: &types.HeadlessAuthentication{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: pubUUID, + Expires: &expires, + }, + }, + PublicKey: []byte(sshPubKey), + }, + compareAndSwapErr: expectBadParameter, + }, { + name: "NOK name not derived from public key", + ha: &types.HeadlessAuthentication{ + ResourceHeader: types.ResourceHeader{ + Metadata: types.Metadata{ + Name: uuid.NewString(), + Expires: &expires, + }, + }, + User: "user", + PublicKey: []byte(sshPubKey), + }, + compareAndSwapErr: expectBadParameter, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stub, err := identity.CreateHeadlessAuthenticationStub(ctx, test.ha.Metadata.Name) + if test.createStubErr != nil { + test.createStubErr(t, err) + return + } + require.NoError(t, err, "CreateHeadlessAuthenticationStub returned non-nil error") + + t.Cleanup(func() { + err = identity.DeleteHeadlessAuthentication(ctx, test.ha.Metadata.Name) + require.NoError(t, err) + + _, err = identity.GetHeadlessAuthentication(ctx, test.ha.Metadata.Name) + require.True(t, trace.IsNotFound(err), "expected not found error but got: %v", err) + }) + + swapped, err := identity.CompareAndSwapHeadlessAuthentication(ctx, stub, test.ha) + if test.compareAndSwapErr != nil { + test.compareAndSwapErr(t, err) + return + } + require.NoError(t, err, "CompareAndSwapHeadlessAuthentication returned non-nil error") + + retrieved, err := identity.GetHeadlessAuthentication(ctx, test.ha.Metadata.Name) + require.NoError(t, err, "GetHeadlessAuthentication returned non-nil error") + require.Equal(t, swapped, retrieved) + }) + } +} + +// sshPubKey is a randomly-generated public key used for login tests. +const sshPubKey = `ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGv+gN2C23P08ieJRA9gU/Ik4bsOh3Kw193UYscJDw41mATj+Kqyf45Rmj8F8rs3i7mYKRXXu1IjNRBzNgpXxqc=`