refactor: sql updates

feat-otp-verification
James Elliott 2023-02-14 13:53:57 +11:00
parent 236fcb1e37
commit e84ca4956a
No known key found for this signature in database
GPG Key ID: 0F1C4A096E857E49
32 changed files with 560 additions and 321 deletions

2
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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 {

View File

@ -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)

View File

@ -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"

View File

@ -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 {
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, messageMFAValidationFailed)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
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)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
return
}
if credential, err = w.CreateCredential(user, *userSession.Webauthn, response); err != nil {
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)
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)
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)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
ctx.SetStatusCode(fasthttp.StatusConflict)
ctx.SetJSONError(messageSecurityKeyDuplicateName)
return
}
}
device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, bodyJSON.Description, credential)
ctx.Logger.WithFields(map[string]any{
"RPID": device.RPID,
"ID": device.ID,
"KID": device.KID.String(),
"User": device.Username,
}).Debug("Registering New Device")
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})
}

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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.

View File

@ -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{

View File

@ -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))

View File

@ -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",

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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 {

View File

@ -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 (

View File

@ -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",

View File

@ -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

View File

@ -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 {

View File

@ -104,10 +104,20 @@ function getAssertionResultFromDOMException(
}
}
export async function getAttestationCreationOptions(): Promise<PublicKeyCredentialCreationOptionsStatus> {
let response: AxiosResponse<ServiceResponse<CredentialCreation>>;
response = await axios.get<ServiceResponse<CredentialCreation>>(WebauthnRegistrationPath);
export async function getAttestationCreationOptions(
displayname: string,
): Promise<PublicKeyCredentialCreationOptionsStatus> {
const response = await axios.put<ServiceResponse<CredentialCreation>>(
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<AxiosResponse<OptionalDataServiceResponse<any>>> {
return axios.post<OptionalDataServiceResponse<any>>(WebauthnRegistrationPath, {
response: response,
description: description,
});
return axios.post<OptionalDataServiceResponse<any>>(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,

View File

@ -24,7 +24,7 @@ export default function WebauthnDeviceDeleteDialog(props: Props) {
<DialogContent>
<DialogContentText>
{translate("Are you sure you want to remove the Webauthn credential from from your account", {
description: props.device.description,
description: props.device.displayname,
})}
</DialogContentText>
</DialogContent>

View File

@ -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) {
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
{translate("Extended Webauthn credential information for security key", {
description: props.device.description,
displayname: props.device.displayname,
})}
</DialogContentText>
<Stack spacing={0} sx={{ minWidth: 400 }}>
@ -46,16 +46,39 @@ export default function WebauthnDetailsDeleteDialog(props: Props) {
/>
</Stack>
</Box>
<PropertyText name={translate("Description")} value={props.device.description} />
<PropertyText name={translate("Display Name")} value={props.device.displayname} />
<PropertyText name={translate("Relying Party ID")} value={props.device.rpid} />
<PropertyText
name={translate("Authenticator Attestation GUID")}
name={translate("Authenticator GUID")}
value={props.device.aaguid === undefined ? "N/A" : props.device.aaguid}
/>
<PropertyText name={translate("Attestation Type")} value={props.device.attestation_type} />
<PropertyText name={translate("Attachment")} value={props.device.attachment} />
<PropertyText
name={translate("Discoverable")}
value={props.device.discoverable ? translate("Yes") : translate("No")}
/>
<PropertyText
name={translate("User Verified")}
value={props.device.verified ? translate("Yes") : translate("No")}
/>
<PropertyText
name={translate("Backup State")}
value={
props.device.backup_eligible
? props.device.backup_state
? translate("Backed Up")
: translate("Eligible")
: translate("Not Eligible")
}
/>
<PropertyText
name={translate("Transports")}
value={props.device.transports.length === 0 ? "N/A" : props.device.transports.join(", ")}
value={
props.device.transports.length === 0
? "N/A"
: props.device.transports.map((transport) => toTransportName(transport)).join(", ")
}
/>
<PropertyText
name={translate("Clone Warning")}

View File

@ -118,7 +118,7 @@ export default function WebauthnDeviceItem(props: Props) {
<Stack spacing={0} sx={{ minWidth: 400 }}>
<Box>
<Typography display="inline" sx={{ fontWeight: "bold" }}>
{props.device.description}
{props.device.displayname}
</Typography>
<Typography
display="inline"

View File

@ -6,9 +6,9 @@ import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Grid,
Stack,
Step,
StepLabel,
Stepper,
@ -23,15 +23,10 @@ import { useTranslation } from "react-i18next";
import InformationIcon from "@components/InformationIcon";
import WebauthnRegisterIcon from "@components/WebauthnRegisterIcon";
import { useNotifications } from "@hooks/NotificationsContext";
import {
AttestationResult,
AttestationResultFailureString,
RegistrationResult,
WebauthnTouchState,
} from "@models/Webauthn";
import { AttestationResult, AttestationResultFailureString, WebauthnTouchState } from "@models/Webauthn";
import { finishRegistration, getAttestationCreationOptions, startWebauthnRegistration } from "@services/Webauthn";
const steps = ["Confirm device", "Choose name"];
const steps = ["Display Name", "Verification"];
interface Props {
open: boolean;
@ -47,21 +42,20 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
const [state, setState] = useState(WebauthnTouchState.WaitTouch);
const [activeStep, setActiveStep] = useState(0);
const [result, setResult] = useState<RegistrationResult | null>(null);
const [options, setOptions] = useState<PublicKeyCredentialCreationOptionsJSON | null>(null);
const [timeout, setTimeout] = useState<number | null>(null);
const [deviceName, setName] = useState("");
const [credentialDisplayName, setCredentialDisplayName] = useState("");
const [errorDisplayName, setErrorDisplayName] = useState(false);
const nameRef = useRef() as MutableRefObject<HTMLInputElement>;
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 res = await getAttestationCreationOptions();
if (res.status !== 200 || !res.options) {
const handleNext = useCallback(async () => {
if (credentialDisplayName.length === 0 || credentialDisplayName.length > 64) {
setErrorDisplayName(true);
createErrorNotification(
"You must open the link from the same device and browser that initiated the registration process.",
translate("The Display Name must be more than 1 character and less than 64 characters."),
);
return;
}
const res = await getAttestationCreationOptions(credentialDisplayName);
switch (res.status) {
case 200:
if (res.options) {
setOptions(res.options);
})();
}, [setOptions, createErrorNotification, props.open, activeStep]);
} 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(
translate("Error occurred obtaining the Webauthn Credential creation options."),
);
}
}, [createErrorNotification, credentialDisplayName, performCredentialCreation, translate]);
const handleCredentialDisplayName = useCallback(
(displayname: string) => {
setCredentialDisplayName(displayname);
if (errorDisplayName) {
setErrorDisplayName(false);
}
},
[errorDisplayName],
);
function renderStep(step: number) {
switch (step) {
case 0:
return (
<Box>
<Box className={styles.icon}>
<InformationIcon />
</Box>
<Typography className={styles.instruction}>
{translate("Enter a display name for this credential")}
</Typography>
<Grid container spacing={1}>
<Grid item xs={12}>
<TextField
inputRef={nameRef}
id="name-textfield"
label={translate("Display Name")}
variant="outlined"
required
value={credentialDisplayName}
error={errorDisplayName}
disabled={false}
onChange={(v) => handleCredentialDisplayName(v.target.value)}
autoCapitalize="none"
onKeyDown={(ev) => {
if (ev.key === "Enter") {
(async () => {
await handleNext();
})();
ev.preventDefault();
}
}}
/>
</Grid>
</Grid>
</Box>
);
case 1:
return (
<Fragment>
<Box className={styles.icon}>
@ -174,52 +221,6 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
</Typography>
</Fragment>
);
case 1:
return (
<Box id="webauthn-registration-name">
<Box className={styles.icon}>
<InformationIcon />
</Box>
<Typography className={styles.instruction}>{translate("Enter a name for this key")}</Typography>
<Grid container spacing={1}>
<Grid item xs={12}>
<TextField
inputRef={nameRef}
id="name-textfield"
label={translate("Name")}
variant="outlined"
required
value={deviceName}
error={nameError}
disabled={false}
onChange={(v) => 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();
}
}}
/>
</Grid>
<Grid item xs={12}>
<Stack direction="row" spacing={1} justifyContent="center" paddingTop={1}>
<Button color="primary" variant="contained" onClick={finishAttestation}>
{translate("Finish")}
</Button>
</Stack>
</Grid>
</Grid>
</Box>
);
}
}
@ -233,8 +234,13 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
return (
<Dialog open={props.open} onClose={handleOnClose} maxWidth={"xs"} fullWidth={true}>
<DialogTitle>{translate("Register Webauthn Credential (Security Key)")}</DialogTitle>
<DialogTitle>{translate("Register Webauthn Credential")}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
{translate(
"This page allows registration of a new Security Key backed by modern Webauthn Credential technology.",
)}
</DialogContentText>
<Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}>
<Grid item xs={12}>
<Stepper activeStep={activeStep}>
@ -257,9 +263,24 @@ const WebauthnDeviceRegisterDialog = function (props: Props) {
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={activeStep === 0 && state !== WebauthnTouchState.Failure}>
<Button
color={activeStep === 1 && state !== WebauthnTouchState.Failure ? "primary" : "error"}
disabled={activeStep === 1 && state !== WebauthnTouchState.Failure}
onClick={handleClose}
>
{translate("Cancel")}
</Button>
{activeStep === 0 ? (
<Button
color={credentialDisplayName.length !== 0 ? "success" : "primary"}
disabled={activeStep !== 0}
onClick={async () => {
await handleNext();
}}
>
{translate("Next")}
</Button>
) : null}
</DialogActions>
</Dialog>
);