Skip to content
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
4 changes: 4 additions & 0 deletions api/types/headlessauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 0 additions & 2 deletions lib/services/headlessauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "":
Expand Down
132 changes: 132 additions & 0 deletions lib/services/local/headlessauthn.go
Original file line number Diff line number Diff line change
@@ -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)
}
149 changes: 149 additions & 0 deletions lib/services/local/headlessauthn_test.go
Original file line number Diff line number Diff line change
@@ -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=`