Skip to content

Commit

Permalink
tests: unit testing, mocks, and code cleanup
Browse files Browse the repository at this point in the history
tests: added unit testing, mocks, and general code improvement
  • Loading branch information
connerdouglass committed Aug 11, 2023
2 parents 013b38d + 2757a9f commit 539e40a
Show file tree
Hide file tree
Showing 37 changed files with 1,076 additions and 150 deletions.
10 changes: 10 additions & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
with-expecter: true
dir: "{{.InterfaceDir}}/mocks"
outpkg: mocks
packages:
github.com/spiretechnology/go-webauthn:
config:
dir: "internal/mocks"
interfaces:
Challenges:
Credentials:
19 changes: 11 additions & 8 deletions assertion.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ package webauthn

import (
"github.com/spiretechnology/go-webauthn/internal/errutil"
"github.com/spiretechnology/go-webauthn/spec"
"github.com/spiretechnology/go-webauthn/internal/spec"
)

// AuthenticatorAssertionResponse is an authentication response.
type AuthenticatorAssertionResponse struct {
AuthenticatorData string `json:"authenticatorData"`
ClientDataJSON string `json:"clientDataJSON"`
Signature string `json:"signature"`
UserHandle string `json:"userHandle"`
AuthenticatorData string `json:"authenticatorData"`
ClientDataJSON string `json:"clientDataJSON"`
Signature string `json:"signature"`
UserHandle *string `json:"userHandle"`
}

func (a *AuthenticatorAssertionResponse) Decode(codec Codec) (*spec.AuthenticatorAssertionResponse, error) {
Expand All @@ -33,9 +33,12 @@ func (a *AuthenticatorAssertionResponse) Decode(codec Codec) (*spec.Authenticato
}

// Decode the user handle
userHandleBytes, err := codec.DecodeString(a.UserHandle)
if err != nil {
return nil, errutil.Wrapf(err, "decoding user handle")
var userHandleBytes []byte
if a.UserHandle != nil {
userHandleBytes, err = codec.DecodeString(*a.UserHandle)
if err != nil {
return nil, errutil.Wrapf(err, "decoding user handle")
}
}

// Wrap it in the spec type
Expand Down
2 changes: 1 addition & 1 deletion attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package webauthn

import (
"github.com/spiretechnology/go-webauthn/internal/errutil"
"github.com/spiretechnology/go-webauthn/spec"
"github.com/spiretechnology/go-webauthn/internal/spec"
)

type AuthenticatorAttestationResponse struct {
Expand Down
13 changes: 13 additions & 0 deletions auth_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package webauthn

// AuthType defines a set of methods of authentication (eg. hardware key, biometric, etc).
type AuthType uint8

const (
// HardwareKey is a hardware authenticator.
HardwareKey = AuthType(1 << iota)
// Biometric is a fingerprint or face scan.
Biometric
// AllAuthTypes is a bitmask of all auth types.
AllAuthTypes = AuthType(0xFF)
)
12 changes: 7 additions & 5 deletions authenticate_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package webauthn
import (
"context"

"github.com/spiretechnology/go-webauthn/internal/challenge"
"github.com/spiretechnology/go-webauthn/internal/errutil"
)

Expand All @@ -18,21 +17,24 @@ type AllowedCredential struct {
ID string `json:"id"`
}

func (w *webauthn) CreateAuthentication(ctx context.Context, userID string) (*AuthenticationChallenge, error) {
func (w *webauthn) CreateAuthentication(ctx context.Context, user User) (*AuthenticationChallenge, error) {
// Get all credentials for the user
credentials, err := w.options.Credentials.GetCredentials(ctx, userID)
credentials, err := w.options.Credentials.GetCredentials(ctx, user)
if err != nil {
return nil, errutil.Wrapf(err, "getting credentials")
}
if len(credentials) == 0 {
return nil, errutil.Wrap(ErrNoCredentials)
}

// Generate the random challenge
challengeBytes, err := challenge.GenerateChallenge()
challengeBytes, err := w.options.ChallengeFunc()
if err != nil {
return nil, errutil.Wrapf(err, "generating challenge")
}

// Store the challenge in the challenge store
if err := w.options.Challenges.StoreChallenge(ctx, userID, challengeBytes); err != nil {
if err := w.options.Challenges.StoreChallenge(ctx, user, challengeBytes); err != nil {
return nil, errutil.Wrapf(err, "storing challenge")
}

Expand Down
67 changes: 67 additions & 0 deletions authenticate_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package webauthn_test

import (
"context"
"errors"
"testing"

"github.com/spiretechnology/go-webauthn"
"github.com/spiretechnology/go-webauthn/internal/testutil"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestCreateAuthentication(t *testing.T) {
ctx := context.Background()
for _, tc := range testCases {
tcChallenge := tc.AuthenticationChallenge()

t.Run(tc.Name, func(t *testing.T) {
t.Run("user has no credentials", func(t *testing.T) {
w, credentials, challenges := setupMocks(nil)
credentials.On("GetCredentials", ctx, tc.User).Return([]webauthn.Credential{}, nil).Once()

challenge, err := w.CreateAuthentication(ctx, tc.User)
require.Nil(t, challenge, "challenge should be nil")
require.ErrorIs(t, err, webauthn.ErrNoCredentials, "error should be ErrNoCredentials")

credentials.AssertExpectations(t)
challenges.AssertExpectations(t)
})

t.Run("storing challenge fails", func(t *testing.T) {
w, credentials, challenges := setupMocks(nil)
credentials.On("GetCredentials", ctx, tc.User).Return([]webauthn.Credential{*tc.Credential()}, nil).Once()
challenges.On("StoreChallenge", mock.Anything, tc.User, mock.Anything).Return(errors.New("test error")).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)
})

t.Run("creates authentication successfully", func(t *testing.T) {
w, credentials, challenges := setupMocks(&webauthn.Options{
ChallengeFunc: func() ([32]byte, error) {
return tcChallenge, nil
},
})
credentials.On("GetCredentials", ctx, tc.User).Return([]webauthn.Credential{*tc.Credential()}, nil).Once()
challenges.On("StoreChallenge", mock.Anything, tc.User, mock.Anything).Return(nil).Once()

challenge, err := w.CreateAuthentication(ctx, tc.User)
require.NotNil(t, challenge, "challenge should not be nil")
require.Nil(t, err, "error should be nil")

require.Equal(t, testutil.Encode(tcChallenge[:]), challenge.Challenge, "challenge should match")
require.Equal(t, testRP.ID, challenge.RPID, "relying party should match")
require.Equal(t, 1, len(challenge.AllowCredentials), "allow credentials should match")

credentials.AssertExpectations(t)
challenges.AssertExpectations(t)
})
})
}
}
41 changes: 26 additions & 15 deletions authenticate_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,45 @@ import (
"context"

"github.com/spiretechnology/go-webauthn/internal/errutil"
"github.com/spiretechnology/go-webauthn/spec"
"github.com/spiretechnology/go-webauthn/internal/spec"
)

type AuthenticationResponse struct {
Challenge string `json:"challenge"`
CredentialID string `json:"credentialId"`
Response AuthenticatorAssertionResponse `json:"response"`
}

type AuthenticationResult struct{}

func (w *webauthn) VerifyAuthentication(ctx context.Context, userID string, res *AuthenticationResponse) (*AuthenticationResult, error) {
func (w *webauthn) VerifyAuthentication(ctx context.Context, user User, res *AuthenticationResponse) (*AuthenticationResult, error) {
// Decode the challenge from the response
challengeBytesSlice, err := w.options.Codec.DecodeString(res.Challenge)
if err != nil {
return nil, errutil.Wrapf(err, "decoding challenge")
}
challengeBytes := [32]byte(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(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")
}

// Decode the received credential ID
credentialID, err := w.options.Codec.DecodeString(res.CredentialID)
if err != nil {
return nil, errutil.Wrapf(err, "decoding credential ID")
}

// Get the credential with the user and ID
credential, err := w.options.Credentials.GetCredential(ctx, userID, credentialID)
credential, err := w.options.Credentials.GetCredential(ctx, user, credentialID)
if err != nil {
return nil, errutil.Wrapf(err, "getting credential")
}
Expand Down Expand Up @@ -58,21 +78,12 @@ func (w *webauthn) VerifyAuthentication(ctx context.Context, userID string, res
}

// Verify that this challenge was issued to the client
challengeBytes, err := clientData.DecodeChallenge()
clientDataChallengeBytes, err := clientData.DecodeChallenge()
if err != nil {
return nil, errutil.Wrapf(err, "decoding challenge")
}
ok, err := w.options.Challenges.HasChallenge(ctx, userID, challengeBytes)
if err != nil {
return nil, errutil.Wrapf(err, "checking challenge")
}
if !ok {
return nil, errutil.Wrap(ErrUnrecognizedChallenge)
}

// Remove the challenge from the store. It's no longer needed.
if err := w.options.Challenges.RemoveChallenge(ctx, userID, challengeBytes); err != nil {
return nil, errutil.Wrapf(err, "removing challenge")
if clientDataChallengeBytes != challengeBytes {
return nil, errutil.Wrapf(err, "invalid challenge")
}

//================================================================================
Expand Down
72 changes: 72 additions & 0 deletions authenticate_verify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package webauthn_test

import (
"context"
"testing"

"github.com/spiretechnology/go-webauthn"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestVerifyAuthentication(t *testing.T) {
ctx := context.Background()
for _, tc := range testCases {
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(nil)
challenges.On("HasChallenge", mock.Anything, tc.User, tcChallenge).Return(false, nil).Once()

result, err := w.VerifyAuthentication(ctx, tc.User, tc.AuthenticationResponse())
require.Nil(t, result, "result should be nil")
require.ErrorIs(t, err, webauthn.ErrUnrecognizedChallenge, "error should be errTest")

credentials.AssertExpectations(t)
challenges.AssertExpectations(t)
})

t.Run("verifies registration successfully", func(t *testing.T) {
w, credentials, challenges := setupMocks(&webauthn.Options{
ChallengeFunc: func() ([32]byte, 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()
credentials.On("GetCredential", mock.Anything, tc.User, mock.Anything).Return(tc.Credential(), nil).Once()

result, err := w.VerifyAuthentication(ctx, tc.User, tc.AuthenticationResponse())
require.Nil(t, err, "error should be nil")
require.NotNil(t, result, "result should not be nil")

credentials.AssertExpectations(t)
challenges.AssertExpectations(t)
})

// t.Run("fails with invalid public key alg", func(t *testing.T) {
// w, users, credentials, challenges := setupMocks(&webauthn.Options{
// ChallengeFunc: func() ([32]byte, 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()
// users.On("GetUser", ctx, tc.User).Return(&tc.User, nil).Once()

// // Switch to an unsupported public key alg
// res := tc.AuthenticationResponse()
// res.PublicKeyAlg = 0

// result, err := w.VerifyAuthentication(ctx, tc.User, res)
// require.Nil(t, result, "result should be nil")
// require.Error(t, err, "error should not be nil")

// users.AssertExpectations(t)
// challenges.AssertExpectations(t)
// credentials.AssertExpectations(t)
// })
})
}
}
1 change: 1 addition & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ var (
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")
)
13 changes: 11 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ module github.com/spiretechnology/go-webauthn

go 1.20

require github.com/fxamacker/cbor/v2 v2.4.0
require (
github.com/fxamacker/cbor/v2 v2.4.0
github.com/stretchr/testify v1.8.4
)

require github.com/x448/float16 v0.8.4 // indirect
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
18 changes: 18 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88=
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/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=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading

0 comments on commit 539e40a

Please sign in to comment.