From e84ca4956aab50c518e728a5f6b574efabb634bc Mon Sep 17 00:00:00 2001 From: James Elliott Date: Tue, 14 Feb 2023 13:53:57 +1100 Subject: [PATCH] refactor: sql updates --- go.mod | 2 +- go.sum | 4 +- internal/commands/storage_run.go | 4 +- .../validator/identity_providers_test.go | 6 +- internal/handlers/const.go | 2 +- .../handlers/handler_register_webauthn.go | 168 +++++++------ internal/handlers/handler_sign_webauthn.go | 16 +- internal/handlers/types.go | 6 +- internal/handlers/webauthn.go | 10 +- internal/handlers/webauthn_test.go | 16 +- internal/mocks/storage.go | 8 +- internal/model/webauthn.go | 92 +++++-- internal/oidc/provider_test.go | 2 +- internal/server/handlers.go | 2 +- internal/server/locales/en/settings.json | 7 +- internal/session/types.go | 7 +- ...008.WebauthnMultiCookieDomain.mysql.up.sql | 36 ++- ....WebauthnMultiCookieDomain.postgres.up.sql | 36 ++- ...08.WebauthnMultiCookieDomain.sqlite.up.sql | 38 ++- internal/storage/provider.go | 2 +- internal/storage/sql_provider.go | 33 +-- .../storage/sql_provider_backend_postgres.go | 2 +- internal/storage/sql_provider_encryption.go | 2 +- internal/storage/sql_provider_queries.go | 40 +-- web/package.json | 2 +- web/pnpm-lock.yaml | 8 +- web/src/models/Webauthn.ts | 28 ++- web/src/services/Webauthn.ts | 28 ++- .../WebauthnDeviceDeleteDialog.tsx | 2 +- .../WebauthnDeviceDetailsDialog.tsx | 33 ++- .../WebauthnDeviceItem.tsx | 2 +- .../WebauthnDeviceRegisterDialog.tsx | 237 ++++++++++-------- 32 files changed, 560 insertions(+), 321 deletions(-) diff --git a/go.mod b/go.mod index 8d0372505..4e912a23a 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/go-ldap/ldap/v3 v3.4.4 github.com/go-rod/rod v0.112.5 github.com/go-sql-driver/mysql v1.7.0 - github.com/go-webauthn/webauthn v0.7.1 + github.com/go-webauthn/webauthn v0.7.2-0.20230214122838-1ee3a4aecef1 github.com/golang-jwt/jwt/v4 v4.4.3 github.com/golang/mock v1.6.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index dd9a94df3..2fd814f3d 100644 --- a/go.sum +++ b/go.sum @@ -192,8 +192,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78 github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-webauthn/revoke v0.1.9 h1:gSJ1ckA9VaKA2GN4Ukp+kiGTk1/EXtaDb1YE8RknbS0= github.com/go-webauthn/revoke v0.1.9/go.mod h1:j6WKPnv0HovtEs++paan9g3ar46gm1NarktkXBaPR+w= -github.com/go-webauthn/webauthn v0.7.1 h1:b1/HP1bkqsW+DIO22WyG7BP9dL0rN151VpruH6cxADA= -github.com/go-webauthn/webauthn v0.7.1/go.mod h1:22OJd+TV8oHrjjXmPHtcPR82lR/yR5m5ilGiF8yPFrE= +github.com/go-webauthn/webauthn v0.7.2-0.20230214122838-1ee3a4aecef1 h1:q8OgN8xHBoJpZ5+ZrRwGmLVCPVhgW+kMx87AkwVGYfA= +github.com/go-webauthn/webauthn v0.7.2-0.20230214122838-1ee3a4aecef1/go.mod h1:22OJd+TV8oHrjjXmPHtcPR82lR/yR5m5ilGiF8yPFrE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= diff --git a/internal/commands/storage_run.go b/internal/commands/storage_run.go index 6bd8575e6..1f166014b 100644 --- a/internal/commands/storage_run.go +++ b/internal/commands/storage_run.go @@ -562,7 +562,7 @@ func (ctx *CmdCtx) StorageUserWebauthnListRunE(cmd *cobra.Command, args []string fmt.Printf("ID\tKID\tDescription\n") for _, device := range devices { - fmt.Printf("%d\t%s\t%s", device.ID, device.KID, device.Description) + fmt.Printf("%d\t%s\t%s", device.ID, device.KID, device.DisplayName) } } @@ -595,7 +595,7 @@ func (ctx *CmdCtx) StorageUserWebauthnListAllRunE(_ *cobra.Command, _ []string) } for _, device := range devices { - output.WriteString(fmt.Sprintf("%d\t%s\t%s\t%s\n", device.ID, device.KID, device.Description, device.Username)) + output.WriteString(fmt.Sprintf("%d\t%s\t%s\t%s\n", device.ID, device.KID, device.DisplayName, device.Username)) } if len(devices) < limit { diff --git a/internal/configuration/validator/identity_providers_test.go b/internal/configuration/validator/identity_providers_test.go index d0e345e97..fe6bdc628 100644 --- a/internal/configuration/validator/identity_providers_test.go +++ b/internal/configuration/validator/identity_providers_test.go @@ -721,7 +721,7 @@ func TestValidateIdentityProvidersShouldSetDefaultValues(t *testing.T) { }, { ID: "b-client", - Description: "Normal Description", + Description: "Normal DisplayName", Secret: MustDecodeSecret("$plaintext$b-client-secret"), Policy: policyOneFactor, UserinfoSigningAlgorithm: "RS256", @@ -783,9 +783,9 @@ func TestValidateIdentityProvidersShouldSetDefaultValues(t *testing.T) { assert.Equal(t, "none", config.OIDC.Clients[0].UserinfoSigningAlgorithm) assert.Equal(t, "RS256", config.OIDC.Clients[1].UserinfoSigningAlgorithm) - // Assert Clients[0] Description is set to the Clients[0] ID, and Clients[1]'s Description is not overridden. + // Assert Clients[0] DisplayName is set to the Clients[0] ID, and Clients[1]'s DisplayName is not overridden. assert.Equal(t, config.OIDC.Clients[0].ID, config.OIDC.Clients[0].Description) - assert.Equal(t, "Normal Description", config.OIDC.Clients[1].Description) + assert.Equal(t, "Normal DisplayName", config.OIDC.Clients[1].Description) // Assert Clients[0] ends up configured with the default Scopes. require.Len(t, config.OIDC.Clients[0].Scopes, 4) diff --git a/internal/handlers/const.go b/internal/handlers/const.go index 01c853f42..8eb0c7bb6 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -66,7 +66,7 @@ const ( messageAuthenticationFailed = "Authentication failed. Check your credentials." messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec messageUnableToRegisterSecurityKey = "Unable to register your security key." - messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that name." + messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name." messageUnableToResetPassword = "Unable to reset your password." messageMFAValidationFailed = "Authentication failed, please retry later." messagePasswordWeak = "Your supplied password does not meet the password policy requirements" diff --git a/internal/handlers/handler_register_webauthn.go b/internal/handlers/handler_register_webauthn.go index f362a38b6..06de27a64 100644 --- a/internal/handlers/handler_register_webauthn.go +++ b/internal/handlers/handler_register_webauthn.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "encoding/json" + "strings" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" @@ -15,17 +16,18 @@ import ( "github.com/authelia/authelia/v4/internal/storage" ) -// WebauthnRegistrationGET returns the attestation challenge from the server. -func WebauthnRegistrationGET(ctx *middlewares.AutheliaCtx) { +// WebauthnRegistrationPUT returns the attestation challenge from the server. +func WebauthnRegistrationPUT(ctx *middlewares.AutheliaCtx) { var ( w *webauthn.WebAuthn user *model.WebauthnUser userSession session.UserSession + bodyJSON bodyRegisterWebauthnPUTRequest err error ) if userSession, err = ctx.GetSession(); err != nil { - ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s attestation challenge", regulation.AuthTypeWebauthn) + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration challenge", regulation.AuthTypeWebauthn) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) @@ -33,40 +35,84 @@ func WebauthnRegistrationGET(ctx *middlewares.AutheliaCtx) { } if w, err = newWebauthn(ctx); err != nil { - ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) + ctx.Logger.Errorf("Unable to create provider to generate %s registration challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) return } - if user, err = getWebauthnUserByRPID(ctx, userSession, w.Config.RPID); err != nil { - ctx.Logger.Errorf("Unable to load %s devices for assertion challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - var credentialCreation *protocol.CredentialCreation - - if credentialCreation, userSession.Webauthn, err = w.BeginRegistration(user, webauthn.WithExclusions(user.WebAuthnCredentialDescriptors())); err != nil { - ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) + if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil { + ctx.Logger.Errorf("Unable to parse %s registration request PUT data for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) return } + if length := len(bodyJSON.DisplayName); length == 0 || length > 64 { + ctx.Logger.Errorf("Failed to validate the user chosen display name for during %s registration for user '%s': the value has a length of %d but must be between 1 and 64", regulation.AuthTypeWebauthn, userSession.Username, length) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } + + devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, w.Config.RPID, userSession.Username) + if err != nil && err != storage.ErrNoWebauthnDevice { + ctx.Logger.Errorf("Unable to load existing %s devices for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } + + for _, device := range devices { + if strings.EqualFold(device.DisplayName, bodyJSON.DisplayName) { + ctx.Logger.Errorf("Unable to generate %s registration challenge: device for for user '%s' with display name '%s' already exists", regulation.AuthTypeWebauthn, userSession.Username, bodyJSON.DisplayName) + + ctx.SetStatusCode(fasthttp.StatusConflict) + ctx.SetJSONError(messageSecurityKeyDuplicateName) + + return + } + } + + if user, err = getWebauthnUserByRPID(ctx, userSession.Username, bodyJSON.DisplayName, w.Config.RPID); err != nil { + ctx.Logger.Errorf("Unable to load %s devices for registration challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } + + var ( + opts *protocol.CredentialCreation + ) + + data := session.Webauthn{ + DisplayName: bodyJSON.DisplayName, + } + + if opts, data.SessionData, err = w.BeginRegistration(user, webauthn.WithExclusions(user.WebAuthnCredentialDescriptors())); err != nil { + ctx.Logger.Errorf("Unable to create %s registration challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) + + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } + + userSession.Webauthn = &data + if err = ctx.SaveSession(userSession); err != nil { - ctx.Logger.Errorf(logFmtErrSessionSave, "attestation challenge", regulation.AuthTypeWebauthn, userSession.Username, err) + ctx.Logger.Errorf(logFmtErrSessionSave, "registration challenge", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) return } - if err = ctx.SetJSONBody(credentialCreation); err != nil { + if err = ctx.SetJSONBody(opts); err != nil { ctx.Logger.Errorf(logFmtErrWriteResponseBody, regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) @@ -87,7 +133,6 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) { response *protocol.ParsedCredentialCreationData credential *webauthn.Credential - bodyJSON bodyRegisterWebauthnRequest ) if userSession, err = ctx.GetSession(); err != nil { @@ -98,10 +143,10 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) { return } - if userSession.Webauthn == nil { + if userSession.Webauthn == nil || userSession.Webauthn.SessionData == nil { ctx.Logger.Errorf("Webauthn session data is not present in order to handle %s registration for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", regulation.AuthTypeWebauthn, userSession.Username) - respondUnauthorized(ctx, messageMFAValidationFailed) + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) return } @@ -114,71 +159,41 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) { return } - if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil { - ctx.Logger.Errorf("Unable to parse %s registration request POST data for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - if response, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(bodyJSON.Response)); err != nil { - ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - ctx.Logger.WithField("att_format", response.Response.AttestationObject.Format).Debug("Response Data") - - if user, err = getWebauthnUser(ctx, userSession); err != nil { - ctx.Logger.Errorf("Unable to load %s user details for registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - if credential, err = w.CreateCredential(user, *userSession.Webauthn, response); err != nil { - ctx.Logger.Errorf("Unable to create %s credential for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) - - respondUnauthorized(ctx, messageMFAValidationFailed) - - return - } - - ctx.Logger.WithField("att_type", credential.AttestationType).Debug("Credential Data") - - devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, w.Config.RPID, userSession.Username) - if err != nil && err != storage.ErrNoWebauthnDevice { - ctx.Logger.Errorf("Unable to load existing %s devices for for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) + if response, err = protocol.ParseCredentialCreationResponseBody(bytes.NewReader(ctx.PostBody())); err != nil { + switch e := err.(type) { + case *protocol.Error: + ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v (%s)", regulation.AuthTypeWebauthn, userSession.Username, err, e.DevInfo) + default: + ctx.Logger.Errorf("Unable to parse %s registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) + } respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) return } - for _, existingDevice := range devices { - if existingDevice.Description == bodyJSON.Description { - ctx.Logger.Errorf("%s device for for user '%s' with name '%s' already exists", regulation.AuthTypeWebauthn, userSession.Username, bodyJSON.Description) + if user, err = getWebauthnUser(ctx, userSession); err != nil { + ctx.Logger.Errorf("Unable to load %s user details for registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) - respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) - ctx.SetStatusCode(fasthttp.StatusConflict) - ctx.SetJSONError(messageSecurityKeyDuplicateName) + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) - return - } + return } - device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, bodyJSON.Description, credential) + if credential, err = w.CreateCredential(user, *userSession.Webauthn.SessionData, response); err != nil { + switch e := err.(type) { + case *protocol.Error: + ctx.Logger.Errorf("Unable to create %s credential for user '%s': %+v (%s)", regulation.AuthTypeWebauthn, userSession.Username, err, e.DevInfo) + default: + ctx.Logger.Errorf("Unable to create %s credential for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) + } - ctx.Logger.WithFields(map[string]any{ - "RPID": device.RPID, - "ID": device.ID, - "KID": device.KID.String(), - "User": device.Username, - }).Debug("Registering New Device") + respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) + + return + } + + device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, userSession.Webauthn.DisplayName, credential) if err = ctx.Providers.StorageProvider.SaveWebauthnDevice(ctx, device); err != nil { ctx.Logger.Errorf("Unable to save %s device registration for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) @@ -189,6 +204,7 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) { } userSession.Webauthn = nil + if err = ctx.SaveSession(userSession); err != nil { ctx.Logger.Errorf(logFmtErrSessionSave, "removal of the registration challenge", regulation.AuthTypeWebauthn, userSession.Username, err) } @@ -196,5 +212,5 @@ func WebauthnRegistrationPOST(ctx *middlewares.AutheliaCtx) { ctx.ReplyOK() ctx.SetStatusCode(fasthttp.StatusCreated) - ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Webauthn Credential", "Credential Description": bodyJSON.Description}) + ctxLogEvent(ctx, userSession.Username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Webauthn Credential", "Credential Display Name": device.DisplayName}) } diff --git a/internal/handlers/handler_sign_webauthn.go b/internal/handlers/handler_sign_webauthn.go index ac69aa44a..071333901 100644 --- a/internal/handlers/handler_sign_webauthn.go +++ b/internal/handlers/handler_sign_webauthn.go @@ -37,7 +37,7 @@ func WebauthnAssertionGET(ctx *middlewares.AutheliaCtx) { return } - if user, err = getWebauthnUserByRPID(ctx, userSession, w.Config.RPID); err != nil { + if user, err = getWebauthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil { ctx.Logger.Errorf("Unable to load %s user details during authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -61,7 +61,9 @@ func WebauthnAssertionGET(ctx *middlewares.AutheliaCtx) { var assertion *protocol.CredentialAssertion - if assertion, userSession.Webauthn, err = w.BeginLogin(user, opts...); err != nil { + data := session.Webauthn{} + + if assertion, data.SessionData, err = w.BeginLogin(user, opts...); err != nil { ctx.Logger.Errorf("Unable to create %s authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -69,6 +71,8 @@ func WebauthnAssertionGET(ctx *middlewares.AutheliaCtx) { return } + userSession.Webauthn = &data + if err = ctx.SaveSession(userSession); err != nil { ctx.Logger.Errorf(logFmtErrSessionSave, "assertion challenge", regulation.AuthTypeWebauthn, userSession.Username, err) @@ -115,7 +119,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { return } - if userSession.Webauthn == nil { + if userSession.Webauthn == nil || userSession.Webauthn.SessionData == nil { ctx.Logger.Errorf("Webauthn session data is not present in order to handle authentication challenge for user '%s'. This could indicate a user trying to POST to the wrong endpoint, or the session data is not present for the browser they used.", userSession.Username) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -145,7 +149,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { return } - if user, err = getWebauthnUserByRPID(ctx, userSession, w.Config.RPID); err != nil { + if user, err = getWebauthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, w.Config.RPID); err != nil { ctx.Logger.Errorf("Unable to load %s credentials for authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -153,7 +157,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { return } - if credential, err = w.ValidateLogin(user, *userSession.Webauthn, assertionResponse); err != nil { + if credential, err = w.ValidateLogin(user, *userSession.Webauthn.SessionData, assertionResponse); err != nil { _ = markAuthenticationAttempt(ctx, false, nil, userSession.Username, regulation.AuthTypeWebauthn, err) respondUnauthorized(ctx, messageMFAValidationFailed) @@ -169,7 +173,7 @@ func WebauthnAssertionPOST(ctx *middlewares.AutheliaCtx) { found = true - if err = ctx.Providers.StorageProvider.UpdateWebauthnDeviceSignIn(ctx, device.ID, device.RPID, device.LastUsedAt, device.SignCount, device.CloneWarning); err != nil { + if err = ctx.Providers.StorageProvider.UpdateWebauthnDeviceSignIn(ctx, device); err != nil { ctx.Logger.Errorf("Unable to save %s device signin count for authentication challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err) respondUnauthorized(ctx, messageMFAValidationFailed) diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 7ca95047b..47a706857 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -40,10 +40,8 @@ type bodySignWebauthnRequest struct { Response json.RawMessage `json:"response"` } -type bodyRegisterWebauthnRequest struct { - Description string `json:"description"` - - Response json.RawMessage `json:"response"` +type bodyRegisterWebauthnPUTRequest struct { + DisplayName string `json:"displayname"` } type bodyEditWebauthnDeviceRequest struct { diff --git a/internal/handlers/webauthn.go b/internal/handlers/webauthn.go index 2dbd14c2a..49ef9ef61 100644 --- a/internal/handlers/webauthn.go +++ b/internal/handlers/webauthn.go @@ -13,20 +13,20 @@ import ( ) func getWebauthnUser(ctx *middlewares.AutheliaCtx, userSession session.UserSession) (user *model.WebauthnUser, err error) { - return getWebauthnUserByRPID(ctx, userSession, "") + return getWebauthnUserByRPID(ctx, userSession.Username, userSession.DisplayName, "") } -func getWebauthnUserByRPID(ctx *middlewares.AutheliaCtx, userSession session.UserSession, rpid string) (user *model.WebauthnUser, err error) { +func getWebauthnUserByRPID(ctx *middlewares.AutheliaCtx, username, displayname string, rpid string) (user *model.WebauthnUser, err error) { user = &model.WebauthnUser{ - Username: userSession.Username, - DisplayName: userSession.DisplayName, + Username: username, + DisplayName: displayname, } if user.DisplayName == "" { user.DisplayName = user.Username } - if user.Devices, err = ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, rpid, userSession.Username); err != nil { + if user.Devices, err = ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, rpid, user.Username); err != nil { return nil, err } diff --git a/internal/handlers/webauthn_test.go b/internal/handlers/webauthn_test.go index 1b6fc9041..ae4018492 100644 --- a/internal/handlers/webauthn_test.go +++ b/internal/handlers/webauthn_test.go @@ -26,7 +26,7 @@ func TestWebauthnGetUser(t *testing.T) { ID: 1, RPID: "example.com", Username: "john", - Description: "Primary", + DisplayName: "Primary", KID: model.NewBase64([]byte("abc123")), AttestationType: "fido-u2f", PublicKey: []byte("data"), @@ -37,7 +37,7 @@ func TestWebauthnGetUser(t *testing.T) { ID: 2, RPID: "example.com", Username: "john", - Description: "Secondary", + DisplayName: "Secondary", KID: model.NewBase64([]byte("123abc")), AttestationType: "packed", Transport: "usb,nfc", @@ -47,7 +47,7 @@ func TestWebauthnGetUser(t *testing.T) { }, }, nil) - user, err := getWebauthnUserByRPID(ctx.Ctx, userSession, "example.com") + user, err := getWebauthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com") require.NoError(t, err) require.NotNil(t, user) @@ -66,7 +66,7 @@ func TestWebauthnGetUser(t *testing.T) { assert.Equal(t, 1, user.Devices[0].ID) assert.Equal(t, "example.com", user.Devices[0].RPID) assert.Equal(t, "john", user.Devices[0].Username) - assert.Equal(t, "Primary", user.Devices[0].Description) + assert.Equal(t, "Primary", user.Devices[0].DisplayName) assert.Equal(t, "", user.Devices[0].Transport) assert.Equal(t, "fido-u2f", user.Devices[0].AttestationType) assert.Equal(t, []byte("data"), user.Devices[0].PublicKey) @@ -83,7 +83,7 @@ func TestWebauthnGetUser(t *testing.T) { assert.Equal(t, 2, user.Devices[1].ID) assert.Equal(t, "example.com", user.Devices[1].RPID) assert.Equal(t, "john", user.Devices[1].Username) - assert.Equal(t, "Secondary", user.Devices[1].Description) + assert.Equal(t, "Secondary", user.Devices[1].DisplayName) assert.Equal(t, "usb,nfc", user.Devices[1].Transport) assert.Equal(t, "packed", user.Devices[1].AttestationType) assert.Equal(t, []byte("data"), user.Devices[1].PublicKey) @@ -111,7 +111,7 @@ func TestWebauthnGetUserWithoutDisplayName(t *testing.T) { ID: 1, RPID: "example.com", Username: "john", - Description: "Primary", + DisplayName: "Primary", KID: model.NewBase64([]byte("abc123")), AttestationType: "fido-u2f", PublicKey: []byte("data"), @@ -120,7 +120,7 @@ func TestWebauthnGetUserWithoutDisplayName(t *testing.T) { }, }, nil) - user, err := getWebauthnUserByRPID(ctx.Ctx, userSession, "example.com") + user, err := getWebauthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com") require.NoError(t, err) require.NotNil(t, user) @@ -138,7 +138,7 @@ func TestWebauthnGetUserWithErr(t *testing.T) { ctx.StorageMock.EXPECT().LoadWebauthnDevicesByUsername(ctx.Ctx, "example.com", "john").Return(nil, errors.New("not found")) - user, err := getWebauthnUserByRPID(ctx.Ctx, userSession, "example.com") + user, err := getWebauthnUserByRPID(ctx.Ctx, userSession.Username, userSession.DisplayName, "example.com") assert.EqualError(t, err, "not found") assert.Nil(t, user) diff --git a/internal/mocks/storage.go b/internal/mocks/storage.go index 8843d3914..03db3576a 100644 --- a/internal/mocks/storage.go +++ b/internal/mocks/storage.go @@ -850,15 +850,15 @@ func (mr *MockStorageMockRecorder) UpdateWebauthnDeviceDescription(arg0, arg1, a } // UpdateWebauthnDeviceSignIn mocks base method. -func (m *MockStorage) UpdateWebauthnDeviceSignIn(arg0 context.Context, arg1 int, arg2 string, arg3 sql.NullTime, arg4 uint32, arg5 bool) error { +func (m *MockStorage) UpdateWebauthnDeviceSignIn(arg0 context.Context, arg1 model.WebauthnDevice) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWebauthnDeviceSignIn", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "UpdateWebauthnDeviceSignIn", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // UpdateWebauthnDeviceSignIn indicates an expected call of UpdateWebauthnDeviceSignIn. -func (mr *MockStorageMockRecorder) UpdateWebauthnDeviceSignIn(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { +func (mr *MockStorageMockRecorder) UpdateWebauthnDeviceSignIn(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebauthnDeviceSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateWebauthnDeviceSignIn), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebauthnDeviceSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateWebauthnDeviceSignIn), arg0, arg1) } diff --git a/internal/model/webauthn.go b/internal/model/webauthn.go index 15eb22dec..d23113fca 100644 --- a/internal/model/webauthn.go +++ b/internal/model/webauthn.go @@ -72,10 +72,17 @@ func (w WebauthnUser) WebAuthnCredentials() (credentials []webauthn.Credential) ID: device.KID.Bytes(), PublicKey: device.PublicKey, AttestationType: device.AttestationType, + Flags: webauthn.CredentialFlags{ + UserPresent: device.Present, + UserVerified: device.Verified, + BackupEligible: device.BackupEligible, + BackupState: device.BackupState, + }, Authenticator: webauthn.Authenticator{ AAGUID: aaguid, SignCount: device.SignCount, CloneWarning: device.CloneWarning, + Attachment: protocol.AuthenticatorAttachment(device.Attachment), }, } @@ -110,7 +117,7 @@ func (w WebauthnUser) WebAuthnCredentialDescriptors() (descriptors []protocol.Cr } // NewWebauthnDeviceFromCredential creates a WebauthnDevice from a webauthn.Credential. -func NewWebauthnDeviceFromCredential(rpid, username, description string, credential *webauthn.Credential) (device WebauthnDevice) { +func NewWebauthnDeviceFromCredential(rpid, username, displayname string, credential *webauthn.Credential) (device WebauthnDevice) { transport := make([]string, len(credential.Transport)) for i, t := range credential.Transport { @@ -121,13 +128,19 @@ func NewWebauthnDeviceFromCredential(rpid, username, description string, credent RPID: rpid, Username: username, CreatedAt: time.Now(), - Description: description, + DisplayName: displayname, KID: NewBase64(credential.ID), - PublicKey: credential.PublicKey, AttestationType: credential.AttestationType, + Attachment: string(credential.Authenticator.Attachment), + Transport: strings.Join(transport, ","), SignCount: credential.Authenticator.SignCount, CloneWarning: credential.Authenticator.CloneWarning, - Transport: strings.Join(transport, ","), + Discoverable: false, + Present: credential.Flags.UserPresent, + Verified: credential.Flags.UserVerified, + BackupEligible: credential.Flags.BackupEligible, + BackupState: credential.Flags.BackupState, + PublicKey: credential.PublicKey, } aaguid, err := uuid.Parse(hex.EncodeToString(credential.Authenticator.AAGUID)) @@ -144,14 +157,20 @@ type WebauthnDeviceJSON struct { CreatedAt time.Time `json:"created_at"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` RPID string `json:"rpid"` - Description string `json:"description"` + DisplayName string `json:"displayname"` KID []byte `json:"kid"` - PublicKey []byte `json:"public_key"` + AAGUID string `json:"aaguid,omitempty"` + Attachment string `json:"attachment"` AttestationType string `json:"attestation_type"` Transports []string `json:"transports"` - AAGUID string `json:"aaguid,omitempty"` SignCount uint32 `json:"sign_count"` CloneWarning bool `json:"clone_warning"` + Discoverable bool `json:"discoverable"` + Present bool `json:"present"` + Verified bool `json:"verified"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` + PublicKey []byte `json:"public_key"` } // WebauthnDevice represents a Webauthn Device in the database storage. @@ -161,14 +180,20 @@ type WebauthnDevice struct { LastUsedAt sql.NullTime `db:"last_used_at"` RPID string `db:"rpid"` Username string `db:"username"` - Description string `db:"description"` + DisplayName string `db:"displayname"` KID Base64 `db:"kid"` - PublicKey []byte `db:"public_key"` - AttestationType string `db:"attestation_type"` - Transport string `db:"transport"` AAGUID uuid.NullUUID `db:"aaguid"` + AttestationType string `db:"attestation_type"` + Attachment string `db:"attachment"` + Transport string `db:"transport"` SignCount uint32 `db:"sign_count"` CloneWarning bool `db:"clone_warning"` + Discoverable bool `db:"discoverable"` + Present bool `db:"present"` + Verified bool `db:"verified"` + BackupEligible bool `db:"backup_eligible"` + BackupState bool `db:"backup_state"` + PublicKey []byte `db:"public_key"` } // MarshalJSON returns the WebauthnDevice in a JSON friendly manner. @@ -177,13 +202,19 @@ func (w *WebauthnDevice) MarshalJSON() (data []byte, err error) { ID: w.ID, CreatedAt: w.CreatedAt, RPID: w.RPID, - Description: w.Description, + DisplayName: w.DisplayName, KID: w.KID.data, - PublicKey: w.PublicKey, AttestationType: w.AttestationType, + Attachment: w.Attachment, Transports: []string{}, SignCount: w.SignCount, CloneWarning: w.CloneWarning, + Discoverable: w.Discoverable, + Present: w.Present, + Verified: w.Verified, + BackupEligible: w.BackupEligible, + BackupState: w.BackupState, + PublicKey: w.PublicKey, } if w.AAGUID.Valid { @@ -234,14 +265,19 @@ func (d *WebauthnDevice) MarshalYAML() (any, error) { LastUsedAt: d.LastUsed(), RPID: d.RPID, Username: d.Username, - Description: d.Description, + DisplayName: d.DisplayName, KID: d.KID.String(), - PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey), - AttestationType: d.AttestationType, - Transport: d.Transport, AAGUID: d.AAGUID.UUID.String(), + AttestationType: d.AttestationType, + Attachment: d.Attachment, + Transport: d.Transport, SignCount: d.SignCount, CloneWarning: d.CloneWarning, + Present: d.Present, + Verified: d.Verified, + BackupEligible: d.BackupEligible, + BackupState: d.BackupState, + PublicKey: base64.StdEncoding.EncodeToString(d.PublicKey), } return yaml.Marshal(o) @@ -280,11 +316,17 @@ func (d *WebauthnDevice) UnmarshalYAML(value *yaml.Node) (err error) { d.CreatedAt = o.CreatedAt d.RPID = o.RPID d.Username = o.Username - d.Description = o.Description + d.DisplayName = o.DisplayName d.AttestationType = o.AttestationType + d.Attachment = o.Attachment d.Transport = o.Transport d.SignCount = o.SignCount d.CloneWarning = o.CloneWarning + d.Discoverable = o.Discoverable + d.Present = o.Present + d.Verified = o.Verified + d.BackupEligible = o.BackupEligible + d.BackupState = o.BackupState if o.LastUsedAt != nil { d.LastUsedAt = sql.NullTime{Valid: true, Time: *o.LastUsedAt} @@ -299,14 +341,20 @@ type WebauthnDeviceData struct { LastUsedAt *time.Time `yaml:"last_used_at"` RPID string `yaml:"rpid"` Username string `yaml:"username"` - Description string `yaml:"description"` + DisplayName string `yaml:"displayname"` KID string `yaml:"kid"` - PublicKey string `yaml:"public_key"` - AttestationType string `yaml:"attestation_type"` - Transport string `yaml:"transport"` AAGUID string `yaml:"aaguid"` + AttestationType string `yaml:"attestation_type"` + Attachment string `yaml:"attachment"` + Transport string `yaml:"transport"` SignCount uint32 `yaml:"sign_count"` CloneWarning bool `yaml:"clone_warning"` + Discoverable bool `yaml:"discoverable"` + Present bool `yaml:"present"` + Verified bool `yaml:"verified"` + BackupEligible bool `yaml:"backup_eligible"` + BackupState bool `yaml:"backup_state"` + PublicKey string `yaml:"public_key"` } // WebauthnDeviceExport represents a WebauthnDevice export file. diff --git a/internal/oidc/provider_test.go b/internal/oidc/provider_test.go index 85ab216ac..4b533084e 100644 --- a/internal/oidc/provider_test.go +++ b/internal/oidc/provider_test.go @@ -70,7 +70,7 @@ func TestOpenIDConnectProvider_NewOpenIDConnectProvider_GoodConfiguration(t *tes }, { ID: "b-client", - Description: "Normal Description", + Description: "Normal DisplayName", Secret: MustDecodeSecret("$plaintext$b-client-secret"), Policy: "two_factor", RedirectURIs: []string{ diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 2d1a22369..5db36fed1 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -240,7 +240,7 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers) // Management of the webauthn devices. r.GET("/api/secondfactor/webauthn/credentials", middleware1FA(handlers.WebauthnDevicesGET)) - r.GET("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebauthnRegistrationGET)) + r.PUT("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebauthnRegistrationPUT)) r.POST("/api/secondfactor/webauthn/credential/register", middleware1FA(handlers.WebauthnRegistrationPOST)) r.PUT("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebauthnDevicePUT)) r.DELETE("/api/secondfactor/webauthn/credential/{deviceID}", middleware2FA(handlers.WebauthnDeviceDELETE)) diff --git a/internal/server/locales/en/settings.json b/internal/server/locales/en/settings.json index 337a4ac31..1fccda34a 100644 --- a/internal/server/locales/en/settings.json +++ b/internal/server/locales/en/settings.json @@ -5,7 +5,7 @@ "Added": "Added {{when, datetime}}", "Are you sure you want to remove the Webauthn credential from from your account": "Are you sure you want to remove the Webauthn credential {{description}} from your account?", "Attestation Type": "Attestation Type", - "Authenticator Attestation GUID": "Authenticator Attestation GUID", + "Authenticator GUID": "Authenticator GUID", "Cancel": "Cancel", "Click to add a Webauthn credential to your account": "Click to add a Webauthn credential to your account", "Click to copy the": "Click to copy the", @@ -19,7 +19,8 @@ "Edit Webauthn Credential": "Edit Webauthn Credential", "Enabled": "Enabled", "Enter a new name for this Webauthn credential": "Enter a new name for this Webauthn credential:", - "Extended Webauthn credential information for security key": "Extended Webauthn credential information for security key {{description}}", + "Enter a display name for this credential": "Enter a display name for this credential", + "Extended Webauthn credential information for security key": "Extended Webauthn credential information for security key {{displayname}}", "Identifier": "Identifier", "Last Used": "Last Used {{when, datetime}}", "Manage your security keys": "Manage your security keys", @@ -28,7 +29,7 @@ "No Registered Webauthn Credentials": "No Registered Webauthn Credentials", "Overview": "Overview", "Provide the details for the new security key": "Provide the details for the new security key", - "Register Webauthn Credential (Security Key)": "Register Webauthn Credential (Security Key)", + "Register Webauthn Credential": "Register Webauthn Credential", "Relying Party ID": "Relying Party ID", "Remove": "Remove", "Remove this Webauthn credential": "Remove this Webauthn credential", diff --git a/internal/session/types.go b/internal/session/types.go index 9fc7bd5b4..e7ca44540 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -36,7 +36,7 @@ type UserSession struct { AuthenticationMethodRefs oidc.AuthenticationMethodsReferences // Webauthn holds the session registration data for this session. - Webauthn *webauthn.SessionData + Webauthn *Webauthn // This boolean is set to true after identity verification and checked // while doing the query actually updating the password. @@ -45,6 +45,11 @@ type UserSession struct { RefreshTTL time.Time } +type Webauthn struct { + *webauthn.SessionData + DisplayName string +} + // Identity identity of the user who is being verified. type Identity struct { Username string diff --git a/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.mysql.up.sql b/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.mysql.up.sql index d7dbcad9e..e11231da7 100644 --- a/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.mysql.up.sql +++ b/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.mysql.up.sql @@ -1,3 +1,33 @@ -DROP INDEX webauthn_devices_lookup_key ON webauthn_devices; -ALTER TABLE webauthn_devices MODIFY COLUMN rpid VARCHAR(512); -CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description); +ALTER TABLE webauthn_devices + RENAME _bkp_UP_V0008_webauthn_devices; + +CREATE TABLE IF NOT EXISTS webauthn_devices ( + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP NULL DEFAULT NULL, + rpid VARCHAR(512) NOT NULL, + username VARCHAR(100) NOT NULL, + displayname VARCHAR(30) NOT NULL, + kid VARCHAR(512) NOT NULL, + aaguid CHAR(36) NOT NULL, + attestation_type VARCHAR(32), + attachment VARCHAR(64) NOT NULL, + transport VARCHAR(20) DEFAULT '', + sign_count INTEGER DEFAULT 0, + clone_warning BOOLEAN NOT NULL DEFAULT FALSE, + discoverable BOOLEAN NOT NULL, + present BOOLEAN NOT NULL DEFAULT FALSE, + verified BOOLEAN NOT NULL DEFAULT FALSE, + backup_eligible BOOLEAN NOT NULL DEFAULT FALSE, + backup_state BOOLEAN NOT NULL DEFAULT FALSE, + public_key BLOB NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid); +CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, displayname); + +INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key) +SELECT id, created_at, last_used_at, CAST(rpid AS CHAR) AS rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, FALSE, FALSE, FALSE, FALSE, public_key +FROM _bkp_UP_V0008_webauthn_devices; + +DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices; diff --git a/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.postgres.up.sql b/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.postgres.up.sql index 10172de0f..83da5f89f 100644 --- a/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.postgres.up.sql +++ b/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.postgres.up.sql @@ -1,3 +1,33 @@ -DROP INDEX webauthn_devices_lookup_key; -ALTER TABLE webauthn_devices ALTER COLUMN rpid SET DATA TYPE VARCHAR(512); -CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description); +ALTER TABLE webauthn_devices + RENAME TO _bkp_UP_V0008_webauthn_devices; + +CREATE TABLE IF NOT EXISTS webauthn_devices ( + id SERIAL CONSTRAINT webauthn_devices_pkey PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP WITH TIME ZONE NULL DEFAULT NULL, + rpid VARCHAR(512) NOT NULL, + username VARCHAR(100) NOT NULL, + displayname VARCHAR(64) NOT NULL, + kid VARCHAR(512) NOT NULL, + aaguid CHAR(36) NOT NULL, + attestation_type VARCHAR(32), + attachment VARCHAR(64) NOT NULL, + transport VARCHAR(20) DEFAULT '', + sign_count INTEGER DEFAULT 0, + clone_warning BOOLEAN NOT NULL DEFAULT FALSE, + discoverable BOOLEAN NOT NULL, + present BOOLEAN NOT NULL DEFAULT FALSE, + verified BOOLEAN NOT NULL DEFAULT FALSE, + backup_eligible BOOLEAN NOT NULL DEFAULT FALSE, + backup_state BOOLEAN NOT NULL DEFAULT FALSE, + public_key BYTEA NOT NULL +); + +CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid); +CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, displayname); + +INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key) +SELECT created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, FALSE, FALSE, FALSE, FALSE, FALSE, public_key +FROM _bkp_UP_V0008_webauthn_devices; + +DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices; diff --git a/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.sqlite.up.sql b/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.sqlite.up.sql index d80ae668a..ac28f2670 100644 --- a/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.sqlite.up.sql +++ b/internal/storage/migrations/V0008.WebauthnMultiCookieDomain.sqlite.up.sql @@ -1,2 +1,36 @@ -DROP INDEX webauthn_devices_lookup_key; -CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, description); +DROP INDEX IF EXISTS webauthn_devices_lookup_key; +DROP INDEX IF EXISTS webauthn_devices_kid_key; + +ALTER TABLE webauthn_devices + RENAME TO _bkp_UP_V0008_webauthn_devices; + +CREATE TABLE IF NOT EXISTS webauthn_devices ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME NULL DEFAULT NULL, + rpid VARCHAR(512) NOT NULL, + username VARCHAR(100) NOT NULL, + displayname VARCHAR(64) NOT NULL, + kid VARCHAR(512) NOT NULL, + aaguid CHAR(36) NULL, + attestation_type VARCHAR(32), + attachment VARCHAR(64), + transport VARCHAR(20) DEFAULT '', + sign_count INTEGER DEFAULT 0, + clone_warning BOOLEAN NOT NULL DEFAULT FALSE, + discoverable BOOLEAN NOT NULL, + present BOOLEAN NOT NULL DEFAULT FALSE, + verified BOOLEAN NOT NULL DEFAULT FALSE, + backup_eligible BOOLEAN NOT NULL DEFAULT FALSE, + backup_state BOOLEAN NOT NULL DEFAULT FALSE, + public_key BLOB NOT NULL +); + +CREATE UNIQUE INDEX webauthn_devices_kid_key ON webauthn_devices (kid); +CREATE UNIQUE INDEX webauthn_devices_lookup_key ON webauthn_devices (rpid, username, displayname); + +INSERT INTO webauthn_devices (created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key) +SELECT created_at, last_used_at, rpid, username, description, kid, aaguid, attestation_type, 'cross-platform', transport, sign_count, clone_warning, FALSE, FALSE, FALSE, FALSE, FALSE, public_key +FROM _bkp_UP_V0008_webauthn_devices; + +DROP TABLE IF EXISTS _bkp_UP_V0008_webauthn_devices; diff --git a/internal/storage/provider.go b/internal/storage/provider.go index a90114a58..c6d9290b8 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -40,7 +40,7 @@ type Provider interface { SaveWebauthnDevice(ctx context.Context, device model.WebauthnDevice) (err error) UpdateWebauthnDeviceDescription(ctx context.Context, username string, deviceID int, description string) (err error) - UpdateWebauthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt sql.NullTime, signCount uint32, cloneWarning bool) (err error) + UpdateWebauthnDeviceSignIn(ctx context.Context, device model.WebauthnDevice) (err error) DeleteWebauthnDevice(ctx context.Context, kid string) (err error) DeleteWebauthnDeviceByUsername(ctx context.Context, username, description string) (err error) LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []model.WebauthnDevice, err error) diff --git a/internal/storage/sql_provider.go b/internal/storage/sql_provider.go index f0d4db21f..cfc87f0fd 100644 --- a/internal/storage/sql_provider.go +++ b/internal/storage/sql_provider.go @@ -51,11 +51,11 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices), sqlSelectWebauthnDevicesByRPIDByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByRPIDByUsername, tableWebauthnDevices), sqlSelectWebauthnDeviceByID: fmt.Sprintf(queryFmtSelectWebauthnDeviceByID, tableWebauthnDevices), - sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID, tableWebauthnDevices), + sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDisplayNameByUsernameAndID, tableWebauthnDevices), sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices), sqlDeleteWebauthnDevice: fmt.Sprintf(queryFmtDeleteWebauthnDevice, tableWebauthnDevices), sqlDeleteWebauthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsername, tableWebauthnDevices), - sqlDeleteWebauthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDescription, tableWebauthnDevices), + sqlDeleteWebauthnDeviceByUsernameAndDisplayName: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDisplayName, tableWebauthnDevices), sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices), sqlDeleteDuoDevice: fmt.Sprintf(queryFmtDeleteDuoDevice, tableDuoDevices), @@ -172,7 +172,7 @@ type SQLProvider struct { sqlDeleteWebauthnDevice string sqlDeleteWebauthnDeviceByUsername string - sqlDeleteWebauthnDeviceByUsernameAndDescription string + sqlDeleteWebauthnDeviceByUsernameAndDisplayName string // Table: duo_devices. sqlUpsertDuoDevice string @@ -842,10 +842,10 @@ func (p *SQLProvider) SaveWebauthnDevice(ctx context.Context, device model.Webau } if _, err = p.db.ExecContext(ctx, p.sqlInsertWebauthnDevice, - device.CreatedAt, device.LastUsedAt, - device.RPID, device.Username, device.Description, - device.KID, device.PublicKey, - device.AttestationType, device.Transport, device.AAGUID, device.SignCount, device.CloneWarning, + device.CreatedAt, device.LastUsedAt, device.RPID, device.Username, device.DisplayName, + device.KID, device.AAGUID, device.AttestationType, device.Attachment, device.Transport, + device.SignCount, device.CloneWarning, device.Discoverable, device.Present, device.Verified, + device.BackupEligible, device.BackupState, device.PublicKey, ); err != nil { return fmt.Errorf("error upserting Webauthn device for user '%s' kid '%x': %w", device.Username, device.KID, err) } @@ -863,9 +863,12 @@ func (p *SQLProvider) UpdateWebauthnDeviceDescription(ctx context.Context, usern } // UpdateWebauthnDeviceSignIn updates a registered Webauthn devices sign in information. -func (p *SQLProvider) UpdateWebauthnDeviceSignIn(ctx context.Context, id int, rpid string, lastUsedAt sql.NullTime, signCount uint32, cloneWarning bool) (err error) { - if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebauthnDeviceRecordSignIn, rpid, lastUsedAt, signCount, cloneWarning, id); err != nil { - return fmt.Errorf("error updating Webauthn signin metadata for id '%x': %w", id, err) +func (p *SQLProvider) UpdateWebauthnDeviceSignIn(ctx context.Context, device model.WebauthnDevice) (err error) { + if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebauthnDeviceRecordSignIn, + device.RPID, device.LastUsedAt, device.SignCount, device.Discoverable, device.Present, device.Verified, + device.BackupEligible, device.BackupState, device.CloneWarning, device.ID, + ); err != nil { + return fmt.Errorf("error updating Webauthn signin metadata for id '%x': %w", device.ID, err) } return nil @@ -881,18 +884,18 @@ func (p *SQLProvider) DeleteWebauthnDevice(ctx context.Context, kid string) (err } // DeleteWebauthnDeviceByUsername deletes registered Webauthn devices by username or username and description. -func (p *SQLProvider) DeleteWebauthnDeviceByUsername(ctx context.Context, username, description string) (err error) { +func (p *SQLProvider) DeleteWebauthnDeviceByUsername(ctx context.Context, username, displayname string) (err error) { if len(username) == 0 { - return fmt.Errorf("error deleting webauthn device with username '%s' and description '%s': username must not be empty", username, description) + return fmt.Errorf("error deleting webauthn device with username '%s' and displayname '%s': username must not be empty", username, displayname) } - if len(description) == 0 { + if len(displayname) == 0 { if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebauthnDeviceByUsername, username); err != nil { return fmt.Errorf("error deleting webauthn devices for username '%s': %w", username, err) } } else { - if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebauthnDeviceByUsernameAndDescription, username, description); err != nil { - return fmt.Errorf("error deleting webauthn device with username '%s' and description '%s': %w", username, description, err) + if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebauthnDeviceByUsernameAndDisplayName, username, displayname); err != nil { + return fmt.Errorf("error deleting webauthn device with username '%s' and displayname '%s': %w", username, displayname, err) } } diff --git a/internal/storage/sql_provider_backend_postgres.go b/internal/storage/sql_provider_backend_postgres.go index 53cc46a6d..41fa34d5a 100644 --- a/internal/storage/sql_provider_backend_postgres.go +++ b/internal/storage/sql_provider_backend_postgres.go @@ -67,7 +67,7 @@ func NewPostgreSQLProvider(config *schema.Configuration, caCertPool *x509.CertPo provider.sqlUpdateWebauthnDeviceRecordSignIn = provider.db.Rebind(provider.sqlUpdateWebauthnDeviceRecordSignIn) provider.sqlDeleteWebauthnDevice = provider.db.Rebind(provider.sqlDeleteWebauthnDevice) provider.sqlDeleteWebauthnDeviceByUsername = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsername) - provider.sqlDeleteWebauthnDeviceByUsernameAndDescription = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsernameAndDescription) + provider.sqlDeleteWebauthnDeviceByUsernameAndDisplayName = provider.db.Rebind(provider.sqlDeleteWebauthnDeviceByUsernameAndDisplayName) provider.sqlSelectDuoDevice = provider.db.Rebind(provider.sqlSelectDuoDevice) provider.sqlDeleteDuoDevice = provider.db.Rebind(provider.sqlDeleteDuoDevice) diff --git a/internal/storage/sql_provider_encryption.go b/internal/storage/sql_provider_encryption.go index 29d334510..5e579fc84 100644 --- a/internal/storage/sql_provider_encryption.go +++ b/internal/storage/sql_provider_encryption.go @@ -174,7 +174,7 @@ func schemaEncryptionChangeKeyWebauthn(ctx context.Context, provider *SQLProvide return fmt.Errorf("error selecting Webauthn devices: %w", err) } - query := provider.db.Rebind(fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices)) + query := provider.db.Rebind(fmt.Sprintf(queryFmtUpdateWebauthnDevicesEncryptedData, tableWebauthnDevices)) for _, d := range devices { if d.PublicKey, err = provider.decrypt(d.PublicKey); err != nil { diff --git a/internal/storage/sql_provider_queries.go b/internal/storage/sql_provider_queries.go index 165c08b3f..88bac771d 100644 --- a/internal/storage/sql_provider_queries.go +++ b/internal/storage/sql_provider_queries.go @@ -120,50 +120,41 @@ const ( const ( queryFmtSelectWebauthnDevices = ` - SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning + SELECT id, created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key FROM %s LIMIT ? OFFSET ?;` - queryFmtSelectWebauthnDevicesEncryptedData = ` - SELECT id, public_key - FROM %s;` - queryFmtSelectWebauthnDevicesByUsername = ` - SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning + SELECT id, created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key FROM %s WHERE username = ?;` queryFmtSelectWebauthnDevicesByRPIDByUsername = ` - SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning + SELECT id, created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key FROM %s WHERE rpid = ? AND username = ?;` queryFmtSelectWebauthnDeviceByID = ` - SELECT id, created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning + SELECT id, created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key FROM %s WHERE id = ?;` - queryFmtUpdateWebauthnDevicePublicKey = ` + queryFmtUpdateUpdateWebauthnDeviceDisplayNameByUsernameAndID = ` UPDATE %s - SET public_key = ? - WHERE id = ?;` - - queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID = ` - UPDATE %s - SET description = ? + SET displayname = ? WHERE username = ? AND id = ?;` queryFmtUpdateWebauthnDeviceRecordSignIn = ` UPDATE %s SET - rpid = ?, last_used_at = ?, sign_count = ?, + rpid = ?, last_used_at = ?, sign_count = ?, discoverable = ?, present = ?, verified = ?, backup_eligible = ?, backup_state = ?, clone_warning = CASE clone_warning WHEN TRUE THEN TRUE ELSE ? END WHERE id = ?;` queryFmtUpsertInsertDevice = ` - INSERT INTO %s (created_at, last_used_at, rpid, username, description, kid, public_key, attestation_type, transport, aaguid, sign_count, clone_warning) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` + INSERT INTO %s (created_at, last_used_at, rpid, username, displayname, kid, aaguid, attestation_type, attachment, transport, sign_count, clone_warning, discoverable, present, verified, backup_eligible, backup_state, public_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` queryFmtDeleteWebauthnDevice = ` DELETE FROM %s @@ -173,9 +164,18 @@ const ( DELETE FROM %s WHERE username = ?;` - queryFmtDeleteWebauthnDeviceByUsernameAndDescription = ` + queryFmtDeleteWebauthnDeviceByUsernameAndDisplayName = ` DELETE FROM %s - WHERE username = ? AND description = ?;` + WHERE username = ? AND displayname = ?;` + + queryFmtSelectWebauthnDevicesEncryptedData = ` + SELECT id, public_key + FROM %s;` + + queryFmtUpdateWebauthnDevicesEncryptedData = ` + UPDATE %s + SET public_key = ? + WHERE id = ?;` ) const ( diff --git a/web/package.json b/web/package.json index e9ec143bd..d2b322c2b 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,7 @@ "@mui/styles": "5.11.7", "@simplewebauthn/browser": "7.1.0", "@simplewebauthn/typescript-types": "7.0.0", - "axios": "1.3.2", + "axios": "1.3.3", "broadcast-channel": "4.20.2", "classnames": "2.3.2", "date-fns": "2.29.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1c9fc2c73..4ee0a6f08 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -27,7 +27,7 @@ specifiers: '@typescript-eslint/eslint-plugin': 5.51.0 '@typescript-eslint/parser': 5.51.0 '@vitejs/plugin-react': 3.1.0 - axios: 1.3.2 + axios: 1.3.3 broadcast-channel: 4.20.2 classnames: 2.3.2 date-fns: 2.29.3 @@ -81,7 +81,7 @@ dependencies: '@mui/styles': 5.11.7_pmekkgnqduwlme35zpnqhenc34 '@simplewebauthn/browser': 7.1.0 '@simplewebauthn/typescript-types': 7.0.0 - axios: 1.3.2 + axios: 1.3.3 broadcast-channel: 4.20.2 classnames: 2.3.2 date-fns: 2.29.3 @@ -4313,8 +4313,8 @@ packages: engines: {node: '>=4'} dev: true - /axios/1.3.2: - resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} + /axios/1.3.3: + resolution: {integrity: sha512-eYq77dYIFS77AQlhzEL937yUBSepBfPIe8FcgEDN35vMNZKMrs81pgnyrQpwfy4NF4b4XWX1Zgx7yX+25w8QJA==} dependencies: follow-redirects: 1.15.1 form-data: 4.0.0 diff --git a/web/src/models/Webauthn.ts b/web/src/models/Webauthn.ts index e9ebd2844..a2c87501c 100644 --- a/web/src/models/Webauthn.ts +++ b/web/src/models/Webauthn.ts @@ -110,14 +110,34 @@ export interface WebauthnDevice { created_at: string; last_used_at?: string; rpid: string; - description: string; + displayname: string; kid: Uint8Array; - public_key: Uint8Array; - attestation_type: string; - transports: string[]; aaguid?: string; + attestation_type: string; + attachment: string; + transports: string[]; sign_count: number; clone_warning: boolean; + discoverable: boolean; + present: boolean; + verified: boolean; + backup_eligible: boolean; + backup_state: boolean; + public_key: Uint8Array; +} + +export function toTransportName(transport: string) { + switch (transport.toLowerCase()) { + case "internal": + return "Internal"; + case "ble": + return "Bluetooth"; + case "nfc": + case "usb": + return transport.toUpperCase(); + default: + return transport; + } } export enum WebauthnTouchState { diff --git a/web/src/services/Webauthn.ts b/web/src/services/Webauthn.ts index e3b1e4697..c4bb6e250 100644 --- a/web/src/services/Webauthn.ts +++ b/web/src/services/Webauthn.ts @@ -104,10 +104,20 @@ function getAssertionResultFromDOMException( } } -export async function getAttestationCreationOptions(): Promise { - let response: AxiosResponse>; - - response = await axios.get>(WebauthnRegistrationPath); +export async function getAttestationCreationOptions( + displayname: string, +): Promise { + const response = await axios.put>( + WebauthnRegistrationPath, + { + displayname: displayname, + }, + { + validateStatus: function (status) { + return status < 300 || status === 409; + }, + }, + ); if (response.data.status !== "OK" || response.data.data == null) { return { @@ -194,12 +204,8 @@ export async function getAuthenticationResult(options: PublicKeyCredentialReques async function postRegistrationResponse( response: RegistrationResponseJSON, - description: string, ): Promise>> { - return axios.post>(WebauthnRegistrationPath, { - response: response, - description: description, - }); + return axios.post>(WebauthnRegistrationPath, response); } export async function postAuthenticationResponse( @@ -216,14 +222,14 @@ export async function postAuthenticationResponse( }); } -export async function finishRegistration(response: RegistrationResponseJSON, description: string) { +export async function finishRegistration(response: RegistrationResponseJSON) { let result = { status: AttestationResult.Failure, message: "Device registration failed.", }; try { - const resp = await postRegistrationResponse(response, description); + const resp = await postRegistrationResponse(response); if (resp.data.status === "OK" && (resp.status === 200 || resp.status === 201)) { return { status: AttestationResult.Success, diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceDeleteDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceDeleteDialog.tsx index 13dee18ff..e071b44b2 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceDeleteDialog.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceDeleteDialog.tsx @@ -24,7 +24,7 @@ export default function WebauthnDeviceDeleteDialog(props: Props) { {translate("Are you sure you want to remove the Webauthn credential from from your account", { - description: props.device.description, + description: props.device.displayname, })} diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceDetailsDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceDetailsDialog.tsx index 48acb61b8..b8660ce89 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceDetailsDialog.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceDetailsDialog.tsx @@ -16,7 +16,7 @@ import { } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { WebauthnDevice } from "@models/Webauthn"; +import { WebauthnDevice, toTransportName } from "@models/Webauthn"; interface Props { open: boolean; @@ -33,7 +33,7 @@ export default function WebauthnDetailsDeleteDialog(props: Props) { {translate("Extended Webauthn credential information for security key", { - description: props.device.description, + displayname: props.device.displayname, })} @@ -46,16 +46,39 @@ export default function WebauthnDetailsDeleteDialog(props: Props) { /> - + + + + + toTransportName(transport)).join(", ") + } /> - {props.device.description} + {props.device.displayname} (null); const [options, setOptions] = useState(null); const [timeout, setTimeout] = useState(null); - const [deviceName, setName] = useState(""); + const [credentialDisplayName, setCredentialDisplayName] = useState(""); + const [errorDisplayName, setErrorDisplayName] = useState(false); const nameRef = useRef() as MutableRefObject; - const [nameError, setNameError] = useState(false); const resetStates = () => { setState(WebauthnTouchState.WaitTouch); - setActiveStep(0); - setResult(null); setOptions(null); + setActiveStep(0); setTimeout(null); - setName(""); + setCredentialDisplayName(""); + setErrorDisplayName(false); }; const handleClose = useCallback(() => { @@ -70,53 +64,41 @@ const WebauthnDeviceRegisterDialog = function (props: Props) { props.setCancelled(); }, [props]); - const finishAttestation = async () => { - if (!result || !result.response) { - return; - } - - if (!deviceName.length) { - setNameError(true); - return; - } - - const res = await finishRegistration(result.response, deviceName); - switch (res.status) { - case AttestationResult.Success: - handleClose(); - break; - case AttestationResult.Failure: - createErrorNotification(res.message); - } - }; - - const startRegistration = useCallback(async () => { + const performCredentialCreation = useCallback(async () => { if (options === null) { return; } setTimeout(options.timeout ? options.timeout : null); + setActiveStep(1); try { setState(WebauthnTouchState.WaitTouch); - setActiveStep(0); - const res = await startWebauthnRegistration(options); + const resultCredentialCreation = await startWebauthnRegistration(options); setTimeout(null); - if (res.result === AttestationResult.Success) { - if (res.response == null) { - throw new Error("Attestation request succeeded but credential is empty"); + if (resultCredentialCreation.result === AttestationResult.Success) { + if (resultCredentialCreation.response == null) { + throw new Error("Credential Creation Request succeeded but Registration Response is empty."); } - setResult(res); - setActiveStep(1); + const response = await finishRegistration(resultCredentialCreation.response); + + switch (response.status) { + case AttestationResult.Success: + handleClose(); + break; + case AttestationResult.Failure: + createErrorNotification(response.message); + break; + } return; } - createErrorNotification(AttestationResultFailureString(res.result)); + createErrorNotification(AttestationResultFailureString(resultCredentialCreation.result)); setState(WebauthnTouchState.Failure); } catch (err) { console.error(err); @@ -124,7 +106,7 @@ const WebauthnDeviceRegisterDialog = function (props: Props) { "Failed to register your device. The identity verification process might have timed out.", ); } - }, [options, createErrorNotification]); + }, [options, createErrorNotification, handleClose]); useEffect(() => { if (state !== WebauthnTouchState.Failure || activeStep !== 0 || !props.open) { @@ -135,35 +117,100 @@ const WebauthnDeviceRegisterDialog = function (props: Props) { }, [props, state, activeStep, handleClose]); useEffect(() => { - (async () => { - if (options === null || !props.open || activeStep !== 0) { + (async function () { + if (!props.open || activeStep !== 0 || options === null) { return; } - await startRegistration(); + await performCredentialCreation(); })(); - }, [options, props.open, activeStep, startRegistration]); + }, [props.open, activeStep, options, performCredentialCreation]); - useEffect(() => { - (async () => { - if (!props.open || activeStep !== 0) { - return; - } + const handleNext = useCallback(async () => { + if (credentialDisplayName.length === 0 || credentialDisplayName.length > 64) { + setErrorDisplayName(true); + createErrorNotification( + translate("The Display Name must be more than 1 character and less than 64 characters."), + ); - const res = await getAttestationCreationOptions(); - if (res.status !== 200 || !res.options) { + return; + } + + const res = await getAttestationCreationOptions(credentialDisplayName); + + switch (res.status) { + case 200: + if (res.options) { + setOptions(res.options); + } else { + throw new Error( + "Credential Creation Options Request succeeded but Credential Creation Options is empty.", + ); + } + + break; + case 409: + setErrorDisplayName(true); + createErrorNotification(translate("A Webauthn Credential with that Display Name already exists.")); + + break; + default: createErrorNotification( - "You must open the link from the same device and browser that initiated the registration process.", + translate("Error occurred obtaining the Webauthn Credential creation options."), ); - return; + } + }, [createErrorNotification, credentialDisplayName, performCredentialCreation, translate]); + + const handleCredentialDisplayName = useCallback( + (displayname: string) => { + setCredentialDisplayName(displayname); + + if (errorDisplayName) { + setErrorDisplayName(false); } - setOptions(res.options); - })(); - }, [setOptions, createErrorNotification, props.open, activeStep]); + }, + [errorDisplayName], + ); function renderStep(step: number) { switch (step) { case 0: + return ( + + + + + + {translate("Enter a display name for this credential")} + + + + handleCredentialDisplayName(v.target.value)} + autoCapitalize="none" + onKeyDown={(ev) => { + if (ev.key === "Enter") { + (async () => { + await handleNext(); + })(); + + ev.preventDefault(); + } + }} + /> + + + + ); + case 1: return ( @@ -174,52 +221,6 @@ const WebauthnDeviceRegisterDialog = function (props: Props) { ); - case 1: - return ( - - - - - {translate("Enter a name for this key")} - - - setName(v.target.value.substring(0, 30))} - onFocus={() => setNameError(false)} - autoCapitalize="none" - autoComplete="webauthn-name" - onKeyDown={(ev) => { - if (ev.key === "Enter") { - if (!deviceName.length) { - setNameError(true); - } else { - (async () => { - await finishAttestation(); - })(); - } - ev.preventDefault(); - } - }} - /> - - - - - - - - - ); } } @@ -233,8 +234,13 @@ const WebauthnDeviceRegisterDialog = function (props: Props) { return ( - {translate("Register Webauthn Credential (Security Key)")} + {translate("Register Webauthn Credential")} + + {translate( + "This page allows registration of a new Security Key backed by modern Webauthn Credential technology.", + )} + @@ -257,9 +263,24 @@ const WebauthnDeviceRegisterDialog = function (props: Props) { - + {activeStep === 0 ? ( + + ) : null} );