From 61106d99590789d15fd157a2b9942f1b25b97f58 Mon Sep 17 00:00:00 2001 From: connerdouglass Date: Sat, 12 Aug 2023 17:36:04 -0400 Subject: [PATCH] feat: signed tokens instead of server-side challenge storage --- .mockery.yaml | 2 +- authenticate_create.go | 16 ++- authenticate_create_test.go | 24 ++-- authenticate_verify.go | 14 +-- authenticate_verify_test.go | 31 ++---- go.mod | 1 + go.sum | 2 + internal/mocks/mock_Challenges.go | 179 ------------------------------ internal/mocks/mock_Tokener.go | 132 ++++++++++++++++++++++ pkg/errs/errors.go | 15 ++- register_create.go | 9 +- register_create_test.go | 21 ++-- register_verify.go | 14 +-- register_verify_test.go | 31 ++---- store_challenges.go | 14 --- store_challenges_inmem.go | 53 --------- tokener.go | 12 ++ tokener_jwt.go | 77 +++++++++++++ webauthn.go | 15 ++- webauthn_test.go | 18 +-- 20 files changed, 315 insertions(+), 365 deletions(-) delete mode 100644 internal/mocks/mock_Challenges.go create mode 100644 internal/mocks/mock_Tokener.go delete mode 100644 store_challenges.go delete mode 100644 store_challenges_inmem.go create mode 100644 tokener.go create mode 100644 tokener_jwt.go diff --git a/.mockery.yaml b/.mockery.yaml index 2c18235..2ca1dca 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -6,5 +6,5 @@ packages: config: dir: "internal/mocks" interfaces: - Challenges: Credentials: + Tokener: diff --git a/authenticate_create.go b/authenticate_create.go index b437492..46073c4 100644 --- a/authenticate_create.go +++ b/authenticate_create.go @@ -9,6 +9,7 @@ import ( // AuthenticationChallenge is the challenge that is sent to the client to initiate an authentication ceremony. type AuthenticationChallenge struct { + Token string `json:"token"` Challenge string `json:"challenge"` RPID string `json:"rpId"` AllowCredentials []AllowedCredential `json:"allowCredentials"` @@ -36,15 +37,18 @@ func (w *webauthn) CreateAuthentication(ctx context.Context, user User) (*Authen return nil, errutil.Wrapf(err, "generating challenge") } - // Store the challenge in the challenge store - if err := w.options.Challenges.StoreChallenge(ctx, user, challengeBytes); err != nil { - return nil, errutil.Wrapf(err, "storing challenge") + // Create the token for the challenge + token, err := w.options.Tokener.CreateToken(challengeBytes, user) + if err != nil { + return nil, errutil.Wrapf(err, "creating token") } // Format the response - var res AuthenticationChallenge - res.Challenge = w.options.Codec.EncodeToString(challengeBytes[:]) - res.RPID = w.options.RP.ID + res := AuthenticationChallenge{ + Token: token, + Challenge: w.options.Codec.EncodeToString(challengeBytes[:]), + RPID: w.options.RP.ID, + } for _, cred := range credentials { res.AllowCredentials = append(res.AllowCredentials, AllowedCredential{ Type: cred.Type, diff --git a/authenticate_create_test.go b/authenticate_create_test.go index 4d54376..be3087c 100644 --- a/authenticate_create_test.go +++ b/authenticate_create_test.go @@ -7,9 +7,7 @@ import ( "github.com/spiretechnology/go-webauthn" "github.com/spiretechnology/go-webauthn/internal/testutil" - "github.com/spiretechnology/go-webauthn/pkg/challenge" "github.com/spiretechnology/go-webauthn/pkg/errs" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -22,7 +20,7 @@ func TestCreateAuthentication(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { t.Run("user has no credentials", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, nil) + w, credentials, tokener := setupMocks(tc, tc.AuthenticationChallenge) credentials.On("GetCredentials", ctx, tc.User).Return([]webauthn.Credential{}, nil).Once() challenge, err := w.CreateAuthentication(ctx, tc.User) @@ -30,30 +28,26 @@ func TestCreateAuthentication(t *testing.T) { require.ErrorIs(t, err, errs.ErrNoCredentials, "error should be ErrNoCredentials") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) - t.Run("storing challenge fails", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, nil) + t.Run("creating challenge token fails", func(t *testing.T) { + w, credentials, tokener := setupMocks(tc, tc.AuthenticationChallenge) credentials.On("GetCredentials", ctx, tc.User).Return([]webauthn.Credential{testCred}, nil).Once() - challenges.On("StoreChallenge", mock.Anything, tc.User, mock.Anything).Return(errors.New("test error")).Once() + tokener.On("CreateToken", tcChallenge, tc.User).Return("", errors.New("token creation failed")).Once() challenge, err := w.CreateAuthentication(ctx, tc.User) require.Nil(t, challenge, "challenge should be nil") require.Error(t, err, "error should not be nil") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) t.Run("creates authentication successfully", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, &webauthn.Options{ - ChallengeFunc: func() (challenge.Challenge, error) { - return tcChallenge, nil - }, - }) + w, credentials, tokener := setupMocks(tc, tc.AuthenticationChallenge) credentials.On("GetCredentials", ctx, tc.User).Return([]webauthn.Credential{testCred}, nil).Once() - challenges.On("StoreChallenge", mock.Anything, tc.User, mock.Anything).Return(nil).Once() + tokener.On("CreateToken", tcChallenge, tc.User).Return("token", nil).Once() challenge, err := w.CreateAuthentication(ctx, tc.User) require.NotNil(t, challenge, "challenge should not be nil") @@ -64,7 +58,7 @@ func TestCreateAuthentication(t *testing.T) { require.Equal(t, 1, len(challenge.AllowCredentials), "allow credentials should match") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) }) } diff --git a/authenticate_verify.go b/authenticate_verify.go index c7b4b4f..b2c2c46 100644 --- a/authenticate_verify.go +++ b/authenticate_verify.go @@ -12,6 +12,7 @@ import ( // AuthenticationResponse is the response sent back by the client after an authentication ceremony. type AuthenticationResponse struct { + Token string `json:"token"` Challenge string `json:"challenge"` CredentialID string `json:"credentialId"` Response AuthenticatorAssertionResponse `json:"response"` @@ -32,17 +33,10 @@ func (w *webauthn) VerifyAuthentication(ctx context.Context, user User, res *Aut return nil, errutil.Wrap(errs.ErrInvalidChallenge) } challengeBytes := challenge.Challenge(challengeBytesSlice) - ok, err := w.options.Challenges.HasChallenge(ctx, user, challengeBytes) - if err != nil { - return nil, errutil.Wrapf(err, "checking challenge") - } - if !ok { - return nil, errutil.Wrap(errs.ErrUnrecognizedChallenge) - } - // Remove the challenge from the store. It's no longer needed. - if err := w.options.Challenges.RemoveChallenge(ctx, user, challengeBytes); err != nil { - return nil, errutil.Wrapf(err, "removing challenge") + // Verify the challenge token + if err := w.options.Tokener.VerifyToken(res.Token, challengeBytes, user); err != nil { + return nil, errutil.Wrapf(err, "verifying token") } // Decode the received credential ID diff --git a/authenticate_verify_test.go b/authenticate_verify_test.go index be4a4d6..68a27c5 100644 --- a/authenticate_verify_test.go +++ b/authenticate_verify_test.go @@ -2,21 +2,19 @@ package webauthn_test import ( "context" + "errors" "testing" "github.com/spiretechnology/go-webauthn" "github.com/spiretechnology/go-webauthn/internal/mocks" "github.com/spiretechnology/go-webauthn/internal/testutil" - "github.com/spiretechnology/go-webauthn/pkg/challenge" - "github.com/spiretechnology/go-webauthn/pkg/errs" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -func seedMockWithCredential(t *testing.T, tc testutil.TestCase, w webauthn.WebAuthn, credentials *mocks.MockCredentials, challenges *mocks.MockChallenges) webauthn.Credential { +func seedMockWithCredential(t *testing.T, tc testutil.TestCase, w webauthn.WebAuthn, credentials *mocks.MockCredentials, tokener *mocks.MockTokener) webauthn.Credential { // Seed the store with a valid credential - challenges.On("HasChallenge", mock.Anything, tc.User, tc.RegistrationChallenge()).Return(true, nil).Once() - challenges.On("RemoveChallenge", mock.Anything, tc.User, tc.RegistrationChallenge()).Return(nil).Once() + tokener.On("VerifyToken", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() credentials.On("StoreCredential", mock.Anything, tc.User, mock.Anything, mock.Anything).Return(nil).Once() reg, err := w.VerifyRegistration(context.Background(), tc.User, &tc.Registration) require.NoError(t, err, "seeding credential should not error") @@ -29,30 +27,25 @@ func TestVerifyAuthentication(t *testing.T) { tcChallenge := tc.AuthenticationChallenge() t.Run(tc.Name, func(t *testing.T) { - t.Run("challenge doesn't exist", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, nil) - challenges.On("HasChallenge", mock.Anything, tc.User, tcChallenge).Return(false, nil).Once() + t.Run("challenge token is invalid", func(t *testing.T) { + w, credentials, tokener := setupMocks(tc, tc.AuthenticationChallenge) + tokener.On("VerifyToken", mock.Anything, tcChallenge, tc.User).Return(errors.New("invalid token")).Once() result, err := w.VerifyAuthentication(ctx, tc.User, &tc.Authentication) require.Nil(t, result, "result should be nil") - require.ErrorIs(t, err, errs.ErrUnrecognizedChallenge, "error should be errTest") + require.Error(t, err, "verify authentication should error") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) t.Run("verifies registration successfully", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, &webauthn.Options{ - ChallengeFunc: func() (challenge.Challenge, error) { - return tcChallenge, nil - }, - }) + w, credentials, tokener := setupMocks(tc, tc.AuthenticationChallenge) // Seed the store with a valid credential - credential := seedMockWithCredential(t, tc, w, credentials, challenges) + credential := seedMockWithCredential(t, tc, w, credentials, tokener) - challenges.On("HasChallenge", mock.Anything, tc.User, tcChallenge).Return(true, nil).Once() - challenges.On("RemoveChallenge", mock.Anything, tc.User, tcChallenge).Return(nil).Once() + tokener.On("VerifyToken", mock.Anything, tcChallenge, tc.User).Return(nil).Once() credentials.On("GetCredential", mock.Anything, tc.User, mock.Anything).Return(&credential, nil).Once() result, err := w.VerifyAuthentication(ctx, tc.User, &tc.Authentication) @@ -60,7 +53,7 @@ func TestVerifyAuthentication(t *testing.T) { require.NotNil(t, result, "result should not be nil") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) }) } diff --git a/go.mod b/go.mod index 0cddd9e..d318051 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/fxamacker/cbor/v2 v2.4.0 + github.com/spiretechnology/go-jwt/v2 v2.1.0 github.com/stretchr/testify v1.8.4 golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb ) diff --git a/go.sum b/go.sum index 4efc2b0..9745960 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spiretechnology/go-jwt/v2 v2.1.0 h1:hBSS6rdg+Zg0AdxJqheG9rnFz+0tkyJkmyNfHSznl9k= +github.com/spiretechnology/go-jwt/v2 v2.1.0/go.mod h1:G4t9Mf53sTy+zvYFzcfdnMijb34fywdym5vtm+uSQB8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= diff --git a/internal/mocks/mock_Challenges.go b/internal/mocks/mock_Challenges.go deleted file mode 100644 index eb9438d..0000000 --- a/internal/mocks/mock_Challenges.go +++ /dev/null @@ -1,179 +0,0 @@ -// Code generated by mockery v2.32.4. DO NOT EDIT. - -package mocks - -import ( - context "context" - - webauthn "github.com/spiretechnology/go-webauthn" - mock "github.com/stretchr/testify/mock" -) - -// MockChallenges is an autogenerated mock type for the Challenges type -type MockChallenges struct { - mock.Mock -} - -type MockChallenges_Expecter struct { - mock *mock.Mock -} - -func (_m *MockChallenges) EXPECT() *MockChallenges_Expecter { - return &MockChallenges_Expecter{mock: &_m.Mock} -} - -// HasChallenge provides a mock function with given fields: ctx, user, challege -func (_m *MockChallenges) HasChallenge(ctx context.Context, user webauthn.User, challege [32]byte) (bool, error) { - ret := _m.Called(ctx, user, challege) - - var r0 bool - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, webauthn.User, [32]byte) (bool, error)); ok { - return rf(ctx, user, challege) - } - if rf, ok := ret.Get(0).(func(context.Context, webauthn.User, [32]byte) bool); ok { - r0 = rf(ctx, user, challege) - } else { - r0 = ret.Get(0).(bool) - } - - if rf, ok := ret.Get(1).(func(context.Context, webauthn.User, [32]byte) error); ok { - r1 = rf(ctx, user, challege) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockChallenges_HasChallenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasChallenge' -type MockChallenges_HasChallenge_Call struct { - *mock.Call -} - -// HasChallenge is a helper method to define mock.On call -// - ctx context.Context -// - user webauthn.User -// - challege [32]byte -func (_e *MockChallenges_Expecter) HasChallenge(ctx interface{}, user interface{}, challege interface{}) *MockChallenges_HasChallenge_Call { - return &MockChallenges_HasChallenge_Call{Call: _e.mock.On("HasChallenge", ctx, user, challege)} -} - -func (_c *MockChallenges_HasChallenge_Call) Run(run func(ctx context.Context, user webauthn.User, challege [32]byte)) *MockChallenges_HasChallenge_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(webauthn.User), args[2].([32]byte)) - }) - return _c -} - -func (_c *MockChallenges_HasChallenge_Call) Return(_a0 bool, _a1 error) *MockChallenges_HasChallenge_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockChallenges_HasChallenge_Call) RunAndReturn(run func(context.Context, webauthn.User, [32]byte) (bool, error)) *MockChallenges_HasChallenge_Call { - _c.Call.Return(run) - return _c -} - -// RemoveChallenge provides a mock function with given fields: ctx, user, challege -func (_m *MockChallenges) RemoveChallenge(ctx context.Context, user webauthn.User, challege [32]byte) error { - ret := _m.Called(ctx, user, challege) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, webauthn.User, [32]byte) error); ok { - r0 = rf(ctx, user, challege) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockChallenges_RemoveChallenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveChallenge' -type MockChallenges_RemoveChallenge_Call struct { - *mock.Call -} - -// RemoveChallenge is a helper method to define mock.On call -// - ctx context.Context -// - user webauthn.User -// - challege [32]byte -func (_e *MockChallenges_Expecter) RemoveChallenge(ctx interface{}, user interface{}, challege interface{}) *MockChallenges_RemoveChallenge_Call { - return &MockChallenges_RemoveChallenge_Call{Call: _e.mock.On("RemoveChallenge", ctx, user, challege)} -} - -func (_c *MockChallenges_RemoveChallenge_Call) Run(run func(ctx context.Context, user webauthn.User, challege [32]byte)) *MockChallenges_RemoveChallenge_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(webauthn.User), args[2].([32]byte)) - }) - return _c -} - -func (_c *MockChallenges_RemoveChallenge_Call) Return(_a0 error) *MockChallenges_RemoveChallenge_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockChallenges_RemoveChallenge_Call) RunAndReturn(run func(context.Context, webauthn.User, [32]byte) error) *MockChallenges_RemoveChallenge_Call { - _c.Call.Return(run) - return _c -} - -// StoreChallenge provides a mock function with given fields: ctx, user, challenge -func (_m *MockChallenges) StoreChallenge(ctx context.Context, user webauthn.User, challenge [32]byte) error { - ret := _m.Called(ctx, user, challenge) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, webauthn.User, [32]byte) error); ok { - r0 = rf(ctx, user, challenge) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MockChallenges_StoreChallenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StoreChallenge' -type MockChallenges_StoreChallenge_Call struct { - *mock.Call -} - -// StoreChallenge is a helper method to define mock.On call -// - ctx context.Context -// - user webauthn.User -// - challenge [32]byte -func (_e *MockChallenges_Expecter) StoreChallenge(ctx interface{}, user interface{}, challenge interface{}) *MockChallenges_StoreChallenge_Call { - return &MockChallenges_StoreChallenge_Call{Call: _e.mock.On("StoreChallenge", ctx, user, challenge)} -} - -func (_c *MockChallenges_StoreChallenge_Call) Run(run func(ctx context.Context, user webauthn.User, challenge [32]byte)) *MockChallenges_StoreChallenge_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(webauthn.User), args[2].([32]byte)) - }) - return _c -} - -func (_c *MockChallenges_StoreChallenge_Call) Return(_a0 error) *MockChallenges_StoreChallenge_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockChallenges_StoreChallenge_Call) RunAndReturn(run func(context.Context, webauthn.User, [32]byte) error) *MockChallenges_StoreChallenge_Call { - _c.Call.Return(run) - return _c -} - -// NewMockChallenges creates a new instance of MockChallenges. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockChallenges(t interface { - mock.TestingT - Cleanup(func()) -}) *MockChallenges { - mock := &MockChallenges{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/internal/mocks/mock_Tokener.go b/internal/mocks/mock_Tokener.go new file mode 100644 index 0000000..243e6c5 --- /dev/null +++ b/internal/mocks/mock_Tokener.go @@ -0,0 +1,132 @@ +// Code generated by mockery v2.32.4. DO NOT EDIT. + +package mocks + +import ( + webauthn "github.com/spiretechnology/go-webauthn" + mock "github.com/stretchr/testify/mock" +) + +// MockTokener is an autogenerated mock type for the Tokener type +type MockTokener struct { + mock.Mock +} + +type MockTokener_Expecter struct { + mock *mock.Mock +} + +func (_m *MockTokener) EXPECT() *MockTokener_Expecter { + return &MockTokener_Expecter{mock: &_m.Mock} +} + +// CreateToken provides a mock function with given fields: challenge, user +func (_m *MockTokener) CreateToken(challenge [32]byte, user webauthn.User) (string, error) { + ret := _m.Called(challenge, user) + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func([32]byte, webauthn.User) (string, error)); ok { + return rf(challenge, user) + } + if rf, ok := ret.Get(0).(func([32]byte, webauthn.User) string); ok { + r0 = rf(challenge, user) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func([32]byte, webauthn.User) error); ok { + r1 = rf(challenge, user) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockTokener_CreateToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateToken' +type MockTokener_CreateToken_Call struct { + *mock.Call +} + +// CreateToken is a helper method to define mock.On call +// - challenge [32]byte +// - user webauthn.User +func (_e *MockTokener_Expecter) CreateToken(challenge interface{}, user interface{}) *MockTokener_CreateToken_Call { + return &MockTokener_CreateToken_Call{Call: _e.mock.On("CreateToken", challenge, user)} +} + +func (_c *MockTokener_CreateToken_Call) Run(run func(challenge [32]byte, user webauthn.User)) *MockTokener_CreateToken_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([32]byte), args[1].(webauthn.User)) + }) + return _c +} + +func (_c *MockTokener_CreateToken_Call) Return(_a0 string, _a1 error) *MockTokener_CreateToken_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockTokener_CreateToken_Call) RunAndReturn(run func([32]byte, webauthn.User) (string, error)) *MockTokener_CreateToken_Call { + _c.Call.Return(run) + return _c +} + +// VerifyToken provides a mock function with given fields: token, challenge, user +func (_m *MockTokener) VerifyToken(token string, challenge [32]byte, user webauthn.User) error { + ret := _m.Called(token, challenge, user) + + var r0 error + if rf, ok := ret.Get(0).(func(string, [32]byte, webauthn.User) error); ok { + r0 = rf(token, challenge, user) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockTokener_VerifyToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyToken' +type MockTokener_VerifyToken_Call struct { + *mock.Call +} + +// VerifyToken is a helper method to define mock.On call +// - token string +// - challenge [32]byte +// - user webauthn.User +func (_e *MockTokener_Expecter) VerifyToken(token interface{}, challenge interface{}, user interface{}) *MockTokener_VerifyToken_Call { + return &MockTokener_VerifyToken_Call{Call: _e.mock.On("VerifyToken", token, challenge, user)} +} + +func (_c *MockTokener_VerifyToken_Call) Run(run func(token string, challenge [32]byte, user webauthn.User)) *MockTokener_VerifyToken_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].([32]byte), args[2].(webauthn.User)) + }) + return _c +} + +func (_c *MockTokener_VerifyToken_Call) Return(_a0 error) *MockTokener_VerifyToken_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockTokener_VerifyToken_Call) RunAndReturn(run func(string, [32]byte, webauthn.User) error) *MockTokener_VerifyToken_Call { + _c.Call.Return(run) + return _c +} + +// NewMockTokener creates a new instance of MockTokener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockTokener(t interface { + mock.TestingT + Cleanup(func()) +}) *MockTokener { + mock := &MockTokener{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/errs/errors.go b/pkg/errs/errors.go index f8fcf1f..77a1449 100644 --- a/pkg/errs/errors.go +++ b/pkg/errs/errors.go @@ -5,12 +5,11 @@ import ( ) var ( - ErrUnsupportedPublicKey = errors.New("unsupported public key type") - ErrInvalidKeyForAlg = errors.New("invalid key for alg") - ErrSignatureMismatch = errors.New("signature mismatch") - ErrUserNotFound = errors.New("user not found") - ErrCredentialNotFound = errors.New("credential not found") - ErrNoCredentials = errors.New("user has no credential") - ErrUnrecognizedChallenge = errors.New("unrecognized challenge") - ErrInvalidChallenge = errors.New("invalid challenge size") + ErrUnsupportedPublicKey = errors.New("unsupported public key type") + ErrInvalidKeyForAlg = errors.New("invalid key for alg") + ErrSignatureMismatch = errors.New("signature mismatch") + ErrUserNotFound = errors.New("user not found") + ErrCredentialNotFound = errors.New("credential not found") + ErrNoCredentials = errors.New("user has no credential") + ErrInvalidChallenge = errors.New("invalid challenge size") ) diff --git a/register_create.go b/register_create.go index af8a101..80d109d 100644 --- a/register_create.go +++ b/register_create.go @@ -9,6 +9,7 @@ import ( // RegistrationChallenge is the challenge that is sent to the client to initiate a registration ceremony. type RegistrationChallenge struct { + Token string `json:"token"` Challenge string `json:"challenge"` RP RelyingParty `json:"rp"` User User `json:"user"` @@ -22,9 +23,10 @@ func (w *webauthn) CreateRegistration(ctx context.Context, user User) (*Registra return nil, errutil.Wrapf(err, "generating challenge") } - // Store the challenge in the challenge store - if err := w.options.Challenges.StoreChallenge(ctx, user, challengeBytes); err != nil { - return nil, errutil.Wrapf(err, "storing challenge") + // Create the token for the challenge + token, err := w.options.Tokener.CreateToken(challengeBytes, user) + if err != nil { + return nil, errutil.Wrapf(err, "creating token") } // Format the public key credential params for the client @@ -37,6 +39,7 @@ func (w *webauthn) CreateRegistration(ctx context.Context, user User) (*Registra } return &RegistrationChallenge{ + Token: token, Challenge: w.options.Codec.EncodeToString(challengeBytes[:]), RP: w.options.RP, User: user, diff --git a/register_create_test.go b/register_create_test.go index 1d3c1cd..ad5fb6b 100644 --- a/register_create_test.go +++ b/register_create_test.go @@ -5,10 +5,7 @@ import ( "errors" "testing" - "github.com/spiretechnology/go-webauthn" "github.com/spiretechnology/go-webauthn/internal/testutil" - "github.com/spiretechnology/go-webauthn/pkg/challenge" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -18,25 +15,21 @@ func TestCreateRegistration(t *testing.T) { tcChallenge := tc.RegistrationChallenge() t.Run(tc.Name, func(t *testing.T) { - t.Run("storing challenge fails", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, nil) - challenges.On("StoreChallenge", mock.Anything, tc.User, mock.Anything).Return(errors.New("test error")).Once() + t.Run("creating challenge token fails", func(t *testing.T) { + w, credentials, tokener := setupMocks(tc, tc.RegistrationChallenge) + tokener.On("CreateToken", tcChallenge, tc.User).Return("", errors.New("test error")).Once() challenge, err := w.CreateRegistration(ctx, tc.User) require.Nil(t, challenge, "challenge should be nil") require.Error(t, err, "error should not be nil") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) t.Run("creates registration successfully", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, &webauthn.Options{ - ChallengeFunc: func() (challenge.Challenge, error) { - return tcChallenge, nil - }, - }) - challenges.On("StoreChallenge", mock.Anything, tc.User, mock.Anything).Return(nil).Once() + w, credentials, tokener := setupMocks(tc, tc.RegistrationChallenge) + tokener.On("CreateToken", tcChallenge, tc.User).Return("token", nil).Once() challenge, err := w.CreateRegistration(ctx, tc.User) require.NotNil(t, challenge, "challenge should not be nil") @@ -50,7 +43,7 @@ func TestCreateRegistration(t *testing.T) { require.Equal(t, 9, len(challenge.PubKeyCredParams), "pub key cred params should match") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) }) } diff --git a/register_verify.go b/register_verify.go index e2f200b..3d7a7b8 100644 --- a/register_verify.go +++ b/register_verify.go @@ -15,6 +15,7 @@ import ( // RegistrationResponse is the response sent back by the client after a registration ceremony. type RegistrationResponse struct { + Token string `json:"token"` Challenge string `json:"challenge"` CredentialID string `json:"credentialId"` Response AuthenticatorAttestationResponse `json:"response"` @@ -35,17 +36,10 @@ func (w *webauthn) VerifyRegistration(ctx context.Context, user User, res *Regis return nil, errutil.Wrap(errs.ErrInvalidChallenge) } challengeBytes := challenge.Challenge(challengeBytesSlice) - ok, err := w.options.Challenges.HasChallenge(ctx, user, challengeBytes) - if err != nil { - return nil, errutil.Wrapf(err, "checking challenge") - } - if !ok { - return nil, errutil.Wrap(errs.ErrUnrecognizedChallenge) - } - // Remove the challenge from the store. It's no longer needed. - if err := w.options.Challenges.RemoveChallenge(ctx, user, challengeBytes); err != nil { - return nil, errutil.Wrapf(err, "removing challenge") + // Verify the challenge token + if err := w.options.Tokener.VerifyToken(res.Token, challengeBytes, user); err != nil { + return nil, errutil.Wrapf(err, "verifying token") } // Decode the attestation response to spec types diff --git a/register_verify_test.go b/register_verify_test.go index 8a93b6f..fcfdbf7 100644 --- a/register_verify_test.go +++ b/register_verify_test.go @@ -2,12 +2,10 @@ package webauthn_test import ( "context" + "errors" "testing" - "github.com/spiretechnology/go-webauthn" "github.com/spiretechnology/go-webauthn/internal/testutil" - "github.com/spiretechnology/go-webauthn/pkg/challenge" - "github.com/spiretechnology/go-webauthn/pkg/errs" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -18,26 +16,21 @@ func TestVerifyRegistration(t *testing.T) { tcChallenge := tc.RegistrationChallenge() t.Run(tc.Name, func(t *testing.T) { - t.Run("challenge doesn't exist", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, nil) - challenges.On("HasChallenge", mock.Anything, tc.User, tcChallenge).Return(false, nil).Once() + t.Run("challenge token is invalid", func(t *testing.T) { + w, credentials, tokener := setupMocks(tc, tc.RegistrationChallenge) + tokener.On("VerifyToken", mock.Anything, tcChallenge, tc.User).Return(errors.New("invalid token")).Once() result, err := w.VerifyRegistration(ctx, tc.User, &tc.Registration) require.Nil(t, result, "result should be nil") - require.ErrorIs(t, err, errs.ErrUnrecognizedChallenge, "error should be errTest") + require.Error(t, err, "verify registration should error") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) t.Run("verifies registration successfully", func(t *testing.T) { - w, credentials, challenges := setupMocks(tc, &webauthn.Options{ - ChallengeFunc: func() (challenge.Challenge, error) { - return tcChallenge, nil - }, - }) - challenges.On("HasChallenge", mock.Anything, tc.User, tcChallenge).Return(true, nil).Once() - challenges.On("RemoveChallenge", mock.Anything, tc.User, tcChallenge).Return(nil).Once() + w, credentials, tokener := setupMocks(tc, tc.RegistrationChallenge) + tokener.On("VerifyToken", mock.Anything, tcChallenge, tc.User).Return(nil).Once() credentials.On("StoreCredential", mock.Anything, tc.User, mock.Anything, mock.Anything).Return(nil).Once() result, err := w.VerifyRegistration(ctx, tc.User, &tc.Registration) @@ -45,15 +38,11 @@ func TestVerifyRegistration(t *testing.T) { require.NotNil(t, result, "result should not be nil") credentials.AssertExpectations(t) - challenges.AssertExpectations(t) + tokener.AssertExpectations(t) }) // t.Run("fails with invalid public key alg", func(t *testing.T) { - // w, credentials, challenges := setupMocks(tc, &webauthn.Options{ - // ChallengeFunc: func() (challenge.Challenge, error) { - // return tcChallenge, nil - // }, - // }) + // w, credentials, challenges := setupMocks(tc, tc.RegistrationChallenge) // challenges.On("HasChallenge", mock.Anything, tc.User, tcChallenge).Return(true, nil).Once() // challenges.On("RemoveChallenge", mock.Anything, tc.User, tcChallenge).Return(nil).Once() diff --git a/store_challenges.go b/store_challenges.go deleted file mode 100644 index 64147d8..0000000 --- a/store_challenges.go +++ /dev/null @@ -1,14 +0,0 @@ -package webauthn - -import ( - "context" - - "github.com/spiretechnology/go-webauthn/pkg/challenge" -) - -// Challenges defines the interface for storing and recalling challenges that have been issued to users. -type Challenges interface { - StoreChallenge(ctx context.Context, user User, challenge challenge.Challenge) error - HasChallenge(ctx context.Context, user User, challege challenge.Challenge) (bool, error) - RemoveChallenge(ctx context.Context, user User, challege challenge.Challenge) error -} diff --git a/store_challenges_inmem.go b/store_challenges_inmem.go deleted file mode 100644 index 56dd689..0000000 --- a/store_challenges_inmem.go +++ /dev/null @@ -1,53 +0,0 @@ -package webauthn - -import ( - "context" - "sync" - - "github.com/spiretechnology/go-webauthn/pkg/challenge" -) - -type storedChallege struct { - userID string - challenge challenge.Challenge -} - -func challengeKey(user User, challenge challenge.Challenge) storedChallege { - return storedChallege{ - userID: user.ID, - challenge: challenge, - } -} - -// NewChallengesInMemory returns a new in-memory implementation of the Challenges interface. -func NewChallengesInMemory() Challenges { - return &inMemChallenges{ - challenges: make(map[storedChallege]struct{}), - } -} - -type inMemChallenges struct { - challenges map[storedChallege]struct{} - mut sync.RWMutex -} - -func (c *inMemChallenges) StoreChallenge(ctx context.Context, user User, challenge challenge.Challenge) error { - c.mut.Lock() - defer c.mut.Unlock() - c.challenges[challengeKey(user, challenge)] = struct{}{} - return nil -} - -func (c *inMemChallenges) HasChallenge(ctx context.Context, user User, challenge challenge.Challenge) (bool, error) { - c.mut.RLock() - defer c.mut.RUnlock() - _, ok := c.challenges[challengeKey(user, challenge)] - return ok, nil -} - -func (c *inMemChallenges) RemoveChallenge(ctx context.Context, user User, challenge challenge.Challenge) error { - c.mut.Lock() - defer c.mut.Unlock() - delete(c.challenges, challengeKey(user, challenge)) - return nil -} diff --git a/tokener.go b/tokener.go new file mode 100644 index 0000000..28995ca --- /dev/null +++ b/tokener.go @@ -0,0 +1,12 @@ +package webauthn + +import ( + "github.com/spiretechnology/go-webauthn/pkg/challenge" +) + +// Tokener defines the interface for creating tokens to ensure the authenticity of registration and +// authentication responses from users. +type Tokener interface { + CreateToken(challenge challenge.Challenge, user User) (string, error) + VerifyToken(token string, challenge challenge.Challenge, user User) error +} diff --git a/tokener_jwt.go b/tokener_jwt.go new file mode 100644 index 0000000..0d85134 --- /dev/null +++ b/tokener_jwt.go @@ -0,0 +1,77 @@ +package webauthn + +import ( + "crypto/sha256" + "encoding/hex" + "time" + + "github.com/spiretechnology/go-jwt/v2" + "github.com/spiretechnology/go-webauthn/internal/errutil" + "github.com/spiretechnology/go-webauthn/pkg/challenge" +) + +// NewJwtTokener creates a new tokener that issues JWT tokens. +func NewJwtTokener(signer jwt.Signer, verifier jwt.Verifier) Tokener { + return &jwtTokener{signer, verifier} +} + +type jwtTokener struct { + signer jwt.Signer + verifier jwt.Verifier +} + +type jwtTokenClaims struct { + UserID string `json:"uid"` + ChallengeHash string `json:"chash"` + ExpiresAt int64 `json:"exp"` +} + +func (t *jwtTokener) CreateToken(challenge challenge.Challenge, user User) (string, error) { + challengeHash := sha256.Sum256(challenge[:]) + claims := jwtTokenClaims{ + UserID: user.ID, + ChallengeHash: hex.EncodeToString(challengeHash[:]), + ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), + } + return jwt.Create(claims, t.signer) +} + +func (t *jwtTokener) VerifyToken(token string, challenges challenge.Challenge, user User) error { + // Parse the token to a JWT + jwtToken, err := jwt.Parse(token) + if err != nil { + return errutil.Wrapf(err, "parsing jwt token") + } + + // Verify the token's signature + valid, err := jwtToken.Verify(t.verifier) + if err != nil { + return errutil.Wrapf(err, "verifying jwt token") + } + if !valid { + return errutil.New("invalid jwt token") + } + + // Unmarshal the claims in the token + var claims jwtTokenClaims + if err := jwtToken.Claims(&claims); err != nil { + return errutil.Wrapf(err, "unmarshaling jwt token claims") + } + + // Verify the challenge hash in the token matches the challenge hash in the request + challengeHash := sha256.Sum256(challenges[:]) + if claims.ChallengeHash != hex.EncodeToString(challengeHash[:]) { + return errutil.New("invalid challenge hash") + } + + // Verify the expiration time of the token + if time.Now().Unix() > claims.ExpiresAt { + return errutil.New("token is expired") + } + + // Verify the user ID in the token matches the user ID in the request + if claims.UserID != user.ID { + return errutil.New("invalid user ID") + } + return nil +} diff --git a/webauthn.go b/webauthn.go index 9fc365b..de88f3e 100644 --- a/webauthn.go +++ b/webauthn.go @@ -2,8 +2,10 @@ package webauthn import ( "context" + "crypto/rand" "encoding/base64" + "github.com/spiretechnology/go-jwt/v2" "github.com/spiretechnology/go-webauthn/pkg/challenge" "github.com/spiretechnology/go-webauthn/pkg/codec" "github.com/spiretechnology/go-webauthn/pkg/pubkey" @@ -21,7 +23,7 @@ type Options struct { Codec codec.Codec PublicKeyTypes []pubkey.KeyType Credentials Credentials - Challenges Challenges + Tokener Tokener ChallengeFunc func() (challenge.Challenge, error) } @@ -29,15 +31,20 @@ func New(options Options) WebAuthn { if options.Codec == nil { options.Codec = base64.RawURLEncoding } - if options.Challenges == nil { - options.Challenges = NewChallengesInMemory() - } if options.PublicKeyTypes == nil { options.PublicKeyTypes = pubkey.AllKeyTypes } if options.ChallengeFunc == nil { options.ChallengeFunc = challenge.GenerateChallenge } + if options.Tokener == nil { + secret := make([]byte, 64) + rand.Read(secret) + options.Tokener = NewJwtTokener( + jwt.HS256Signer(secret), + jwt.HS256Verifier(secret), + ) + } return &webauthn{options} } diff --git a/webauthn_test.go b/webauthn_test.go index bd210b5..65e97d0 100644 --- a/webauthn_test.go +++ b/webauthn_test.go @@ -4,21 +4,23 @@ import ( "github.com/spiretechnology/go-webauthn" "github.com/spiretechnology/go-webauthn/internal/mocks" "github.com/spiretechnology/go-webauthn/internal/testutil" + "github.com/spiretechnology/go-webauthn/pkg/challenge" ) -func setupMocks(tc testutil.TestCase, opts *webauthn.Options) (webauthn.WebAuthn, *mocks.MockCredentials, *mocks.MockChallenges) { +func setupMocks(tc testutil.TestCase, challengeFunc func() challenge.Challenge) (webauthn.WebAuthn, *mocks.MockCredentials, *mocks.MockTokener) { credentials := &mocks.MockCredentials{} - challenges := &mocks.MockChallenges{} + tokener := &mocks.MockTokener{} var options webauthn.Options - if opts != nil { - options = *opts - } options.RP = tc.RelyingParty options.Credentials = credentials - options.Challenges = challenges + options.Tokener = tokener + if challengeFunc != nil { + options.ChallengeFunc = func() (challenge.Challenge, error) { + return challengeFunc(), nil + } + } w := webauthn.New(options) - - return w, credentials, challenges + return w, credentials, tokener }