diff --git a/api/openapi.yml b/api/openapi.yml
index ae195cfbd..f2f0d6127 100644
--- a/api/openapi.yml
+++ b/api/openapi.yml
@@ -568,6 +568,45 @@ paths:
$ref: '#/components/schemas/middlewares.OkResponse'
security:
- authelia_auth: []
+ /api/secondfactor/webauthn/devices/{deviceID}:
+ delete:
+ tags:
+ - Second Factor
+ summary: Webauthn Device Deletion
+ description: This endpoint deletes the specified Webauthn credential.
+ responses:
+ "200":
+ description: Successful Operation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.OkResponse'
+ security:
+ - authelia_auth: []
+ parameters:
+ - $ref: '#/components/parameters/deviceID'
+ put:
+ tags:
+ - Second Factor
+ summary: Webauthn Device Update
+ description: This endpoint updates the description of the specified Webauthn credential.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/webauthn.DeviceUpdateRequest'
+ responses:
+ "200":
+ description: Successful Operation
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/middlewares.OkResponse'
+ security:
+ - authelia_auth: []
+ parameters:
+ - $ref: '#/components/parameters/deviceID'
/api/secondfactor/duo:
post:
tags:
@@ -633,6 +672,13 @@ paths:
- authelia_auth: []
components:
parameters:
+ deviceID:
+ in: path
+ name: deviceID
+ schema:
+ type: integer
+ required: true
+ description: Numeric Webauthn Device ID
originalURLParam:
name: X-Original-URL
in: header
@@ -1078,6 +1124,11 @@ components:
workflowID:
type: string
example: 3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c
+ webauthn.DeviceUpdateRequest:
+ type: object
+ properties:
+ description:
+ type: string
webauthn.PublicKeyCredentialCreationOptions:
type: object
properties:
diff --git a/internal/handlers/const.go b/internal/handlers/const.go
index ae9e4d603..ebd15e790 100644
--- a/internal/handlers/const.go
+++ b/internal/handlers/const.go
@@ -55,6 +55,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."
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 26f94d738..33fcf9b10 100644
--- a/internal/handlers/handler_register_webauthn.go
+++ b/internal/handlers/handler_register_webauthn.go
@@ -11,20 +11,28 @@ import (
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/regulation"
+ "github.com/authelia/authelia/v4/internal/storage"
)
// WebauthnIdentityStart the handler for initiating the identity validation.
-var WebauthnIdentityStart = middlewares.IdentityVerificationStart(middlewares.IdentityVerificationStartArgs{
- MailTitle: "Register your key",
- MailButtonContent: "Register",
- TargetEndpoint: "/webauthn/register",
- ActionClaim: ActionWebauthnRegistration,
- IdentityRetrieverFunc: identityRetrieverFromSession,
-}, nil)
+var WebauthnIdentityStart = middlewares.IdentityVerificationStart(
+ middlewares.IdentityVerificationStartArgs{
+ IdentityVerificationCommonArgs: middlewares.IdentityVerificationCommonArgs{
+ SkipIfAuthLevelTwoFactor: true,
+ },
+ MailTitle: "Register your key",
+ MailButtonContent: "Register",
+ TargetEndpoint: "/webauthn/register",
+ ActionClaim: ActionWebauthnRegistration,
+ IdentityRetrieverFunc: identityRetrieverFromSession,
+ }, nil)
// WebauthnIdentityFinish the handler for finishing the identity validation.
var WebauthnIdentityFinish = middlewares.IdentityVerificationFinish(
middlewares.IdentityVerificationFinishArgs{
+ IdentityVerificationCommonArgs: middlewares.IdentityVerificationCommonArgs{
+ SkipIfAuthLevelTwoFactor: true,
+ },
ActionClaim: ActionWebauthnRegistration,
IsTokenUserValidFunc: isTokenUserValidFor2FARegistration,
}, SecondFactorWebauthnAttestationGET)
@@ -57,7 +65,7 @@ func SecondFactorWebauthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string)
var credentialCreation *protocol.CredentialCreation
- if credentialCreation, userSession.Webauthn, err = w.BeginRegistration(user); err != nil {
+ 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)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
@@ -150,6 +158,27 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) {
return
}
+ devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, 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 == postData.Description {
+ ctx.Logger.Errorf("%s device for for user '%s' with name '%s' already exists", regulation.AuthTypeWebauthn, userSession.Username, postData.Description)
+
+ respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
+ ctx.SetStatusCode(fasthttp.StatusConflict)
+ ctx.SetJSONError(messageSecurityKeyDuplicateName)
+
+ return
+ }
+ }
+
device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, postData.Description, credential)
if err = ctx.Providers.StorageProvider.SaveWebauthnDevice(ctx, device); err != nil {
diff --git a/internal/handlers/handler_webauthn_devices.go b/internal/handlers/handler_webauthn_devices.go
index 7e0bec222..2fe2ce19d 100644
--- a/internal/handlers/handler_webauthn_devices.go
+++ b/internal/handlers/handler_webauthn_devices.go
@@ -1,9 +1,35 @@
package handlers
import (
+ "encoding/json"
+ "errors"
+ "strconv"
+
+ "github.com/valyala/fasthttp"
+
"github.com/authelia/authelia/v4/internal/middlewares"
+ "github.com/authelia/authelia/v4/internal/regulation"
)
+func getWebauthnDeviceIDFromContext(ctx *middlewares.AutheliaCtx) (int, error) {
+ deviceIDStr, ok := ctx.UserValue("deviceID").(string)
+ if !ok {
+ ctx.SetStatusCode(fasthttp.StatusBadRequest)
+ return 0, errors.New("Invalid device ID type")
+ }
+
+ deviceID, err := strconv.Atoi(deviceIDStr)
+ if err != nil {
+ ctx.Error(err, messageOperationFailed)
+ ctx.SetStatusCode(fasthttp.StatusBadRequest)
+
+ return 0, err
+ }
+
+ return deviceID, nil
+}
+
+// WebauthnDevicesGet returns all devices registered for the current user.
func WebauthnDevicesGet(ctx *middlewares.AutheliaCtx) {
userSession := ctx.GetSession()
@@ -19,3 +45,49 @@ func WebauthnDevicesGet(ctx *middlewares.AutheliaCtx) {
return
}
}
+
+// WebauthnDeviceUpdate updates the description for a specific device for the current user.
+func WebauthnDeviceUpdate(ctx *middlewares.AutheliaCtx) {
+ type requestPostData struct {
+ Description string `json:"description"`
+ }
+
+ var postData *requestPostData
+
+ userSession := ctx.GetSession()
+
+ err := json.Unmarshal(ctx.PostBody(), &postData)
+ if err != nil {
+ ctx.Logger.Errorf("Unable to parse %s update request data for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
+
+ ctx.SetStatusCode(fasthttp.StatusBadRequest)
+ ctx.Error(err, messageOperationFailed)
+
+ return
+ }
+
+ deviceID, err := getWebauthnDeviceIDFromContext(ctx)
+ if err != nil {
+ return
+ }
+
+ if err := ctx.Providers.StorageProvider.UpdateWebauthnDeviceDescription(ctx, userSession.Username, deviceID, postData.Description); err != nil {
+ ctx.Error(err, messageOperationFailed)
+ return
+ }
+}
+
+// WebauthnDeviceDelete deletes a specific device for the current user.
+func WebauthnDeviceDelete(ctx *middlewares.AutheliaCtx) {
+ userSession := ctx.GetSession()
+
+ deviceID, err := getWebauthnDeviceIDFromContext(ctx)
+ if err != nil {
+ return
+ }
+
+ if err := ctx.Providers.StorageProvider.DeleteWebauthnDeviceByUsernameAndID(ctx, userSession.Username, deviceID); err != nil {
+ ctx.Error(err, messageOperationFailed)
+ return
+ }
+}
diff --git a/internal/middlewares/identity_verification.go b/internal/middlewares/identity_verification.go
index e4cbabce9..92775cbad 100644
--- a/internal/middlewares/identity_verification.go
+++ b/internal/middlewares/identity_verification.go
@@ -10,10 +10,16 @@ import (
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
+ "github.com/authelia/authelia/v4/internal/authentication"
"github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/templates"
)
+// Return true if skip enabled at TwoFactor auth level and user's auth level is 2FA, false otherwise.
+func shouldSkipIdentityVerification(args IdentityVerificationCommonArgs, ctx *AutheliaCtx) bool {
+ return args.SkipIfAuthLevelTwoFactor && ctx.GetSession().AuthenticationLevel >= authentication.TwoFactor
+}
+
// IdentityVerificationStart the handler for initiating the identity validation process.
func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc TimingAttackDelayFunc) RequestHandler {
if args.IdentityRetrieverFunc == nil {
@@ -21,6 +27,11 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
}
return func(ctx *AutheliaCtx) {
+ if shouldSkipIdentityVerification(args.IdentityVerificationCommonArgs, ctx) {
+ ctx.ReplyOK()
+ return
+ }
+
requestTime := time.Now()
success := false
@@ -112,48 +123,62 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
}
}
+func identityVerificationValidateToken(ctx *AutheliaCtx) (*jwt.Token, error) {
+ var finishBody IdentityVerificationFinishBody
+
+ b := ctx.PostBody()
+
+ err := json.Unmarshal(b, &finishBody)
+
+ if err != nil {
+ ctx.Error(err, messageOperationFailed)
+ return nil, err
+ }
+
+ if finishBody.Token == "" {
+ ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
+ return nil, err
+ }
+
+ token, err := jwt.ParseWithClaims(finishBody.Token, &model.IdentityVerificationClaim{},
+ func(token *jwt.Token) (any, error) {
+ return []byte(ctx.Configuration.JWTSecret), nil
+ })
+
+ if err != nil {
+ if ve, ok := err.(*jwt.ValidationError); ok {
+ switch {
+ case ve.Errors&jwt.ValidationErrorMalformed != 0:
+ ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed)
+ return nil, err
+ case ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0:
+ // Token is either expired or not active yet.
+ ctx.Error(fmt.Errorf("Token expired"), messageIdentityVerificationTokenHasExpired)
+ return nil, err
+ default:
+ ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed)
+ return nil, err
+ }
+ }
+
+ ctx.Error(err, messageOperationFailed)
+
+ return nil, err
+ }
+
+ return token, nil
+}
+
// IdentityVerificationFinish the middleware for finishing the identity validation process.
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
return func(ctx *AutheliaCtx) {
- var finishBody IdentityVerificationFinishBody
-
- b := ctx.PostBody()
-
- err := json.Unmarshal(b, &finishBody)
-
- if err != nil {
- ctx.Error(err, messageOperationFailed)
+ if shouldSkipIdentityVerification(args.IdentityVerificationCommonArgs, ctx) {
+ next(ctx, "")
return
}
- if finishBody.Token == "" {
- ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
- return
- }
-
- token, err := jwt.ParseWithClaims(finishBody.Token, &model.IdentityVerificationClaim{},
- func(token *jwt.Token) (any, error) {
- return []byte(ctx.Configuration.JWTSecret), nil
- })
-
- if err != nil {
- if ve, ok := err.(*jwt.ValidationError); ok {
- switch {
- case ve.Errors&jwt.ValidationErrorMalformed != 0:
- ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed)
- return
- case ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0:
- // Token is either expired or not active yet.
- ctx.Error(fmt.Errorf("Token expired"), messageIdentityVerificationTokenHasExpired)
- return
- default:
- ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed)
- return
- }
- }
-
- ctx.Error(err, messageOperationFailed)
-
+ token, err := identityVerificationValidateToken(ctx)
+ if token == nil || err != nil {
return
}
diff --git a/internal/middlewares/identity_verification_test.go b/internal/middlewares/identity_verification_test.go
index f317867aa..87bcdb5fe 100644
--- a/internal/middlewares/identity_verification_test.go
+++ b/internal/middlewares/identity_verification_test.go
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
+ "github.com/authelia/authelia/v4/internal/authentication"
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/mocks"
"github.com/authelia/authelia/v4/internal/model"
@@ -37,6 +38,38 @@ func defaultRetriever(ctx *middlewares.AutheliaCtx) (*session.Identity, error) {
}, nil
}
+func TestShouldSkipStartIdentityVerificationIf2FASkipEnabled(t *testing.T) {
+ testCases := []bool{true, false}
+ for _, testCaseSkipEnabled := range testCases {
+ t.Run(fmt.Sprintf("SkipIfAuthLevelTwoFactor=%t", testCaseSkipEnabled), func(t *testing.T) {
+ mock := mocks.NewMockAutheliaCtx(t)
+ defer mock.Close()
+
+ mock.Ctx.Request.Header.Add("X-Forwarded-Proto", "http")
+ mock.Ctx.Request.Header.Add("X-Forwarded-Host", "host")
+
+ if testCaseSkipEnabled == false {
+ mock.StorageMock.EXPECT().
+ SaveIdentityVerification(mock.Ctx, gomock.Any()).
+ Return(nil)
+ mock.NotifierMock.EXPECT().
+ Send(gomock.Eq(mail.Address{Address: "john@example.com"}), gomock.Eq("Title"), gomock.Any(), gomock.Any()).
+ Return(nil)
+ }
+
+ userSession := mock.Ctx.GetSession()
+ userSession.AuthenticationLevel = authentication.TwoFactor
+ assert.NoError(t, mock.Ctx.SaveSession(userSession))
+
+ args := newArgs(defaultRetriever)
+ args.IdentityVerificationCommonArgs.SkipIfAuthLevelTwoFactor = testCaseSkipEnabled
+ middlewares.IdentityVerificationStart(args, nil)(mock.Ctx)
+
+ assert.Equal(t, 200, mock.Ctx.Response.StatusCode())
+ })
+ }
+}
+
func TestShouldFailStartingProcessIfUserHasNoEmailAddress(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
@@ -292,6 +325,36 @@ func (s *IdentityVerificationFinishProcess) TestShouldReturn200OnFinishComplete(
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
}
+func (s *IdentityVerificationFinishProcess) TestShouldSkipIf2FASkipEnabled() {
+ testCases := []bool{true, false}
+ for _, testCaseSkipEnabled := range testCases {
+ s.Run(fmt.Sprintf("SkipIfAuthLevelTwoFactor=%t", testCaseSkipEnabled), func() {
+ token, verification := createToken(s.mock, "john", "EXP_ACTION",
+ time.Now().Add(1*time.Minute))
+ s.mock.Ctx.Request.SetBodyString(fmt.Sprintf("{\"token\":\"%s\"}", token))
+
+ if testCaseSkipEnabled == false {
+ s.mock.StorageMock.EXPECT().
+ FindIdentityVerification(s.mock.Ctx, gomock.Eq(verification.JTI.String())).
+ Return(true, nil)
+ s.mock.StorageMock.EXPECT().
+ ConsumeIdentityVerification(s.mock.Ctx, gomock.Eq(verification.JTI.String()), gomock.Eq(model.NewNullIP(s.mock.Ctx.RemoteIP()))).
+ Return(nil)
+ }
+
+ userSession := s.mock.Ctx.GetSession()
+ userSession.AuthenticationLevel = authentication.TwoFactor
+ assert.NoError(s.T(), s.mock.Ctx.SaveSession(userSession))
+
+ args := newFinishArgs()
+ args.IdentityVerificationCommonArgs.SkipIfAuthLevelTwoFactor = testCaseSkipEnabled
+ middlewares.IdentityVerificationFinish(args, next)(s.mock.Ctx)
+
+ assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode())
+ })
+ }
+}
+
func TestRunIdentityVerificationFinish(t *testing.T) {
s := new(IdentityVerificationFinishProcess)
suite.Run(t, s)
diff --git a/internal/middlewares/require_authentication_level.go b/internal/middlewares/require_authentication_level.go
new file mode 100644
index 000000000..9b313f1b6
--- /dev/null
+++ b/internal/middlewares/require_authentication_level.go
@@ -0,0 +1,29 @@
+package middlewares
+
+import (
+ "github.com/authelia/authelia/v4/internal/authentication"
+)
+
+// Require1FA requires the user to have authenticated with at least one-factor authentication (i.e. password).
+func Require1FA(next RequestHandler) RequestHandler {
+ return func(ctx *AutheliaCtx) {
+ if ctx.GetSession().AuthenticationLevel < authentication.OneFactor {
+ ctx.ReplyForbidden()
+ return
+ }
+
+ next(ctx)
+ }
+}
+
+// Require2FA requires the user to have authenticated with two-factor authentication.
+func Require2FA(next RequestHandler) RequestHandler {
+ return func(ctx *AutheliaCtx) {
+ if ctx.GetSession().AuthenticationLevel < authentication.TwoFactor {
+ ctx.ReplyForbidden()
+ return
+ }
+
+ next(ctx)
+ }
+}
diff --git a/internal/middlewares/require_first_factor.go b/internal/middlewares/require_first_factor.go
deleted file mode 100644
index cd104583d..000000000
--- a/internal/middlewares/require_first_factor.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package middlewares
-
-import (
- "github.com/authelia/authelia/v4/internal/authentication"
-)
-
-// Require1FA check if user has enough permissions to execute the next handler.
-func Require1FA(next RequestHandler) RequestHandler {
- return func(ctx *AutheliaCtx) {
- if ctx.GetSession().AuthenticationLevel < authentication.OneFactor {
- ctx.ReplyForbidden()
- return
- }
-
- next(ctx)
- }
-}
diff --git a/internal/middlewares/types.go b/internal/middlewares/types.go
index f26e57fc7..906c7e3ae 100644
--- a/internal/middlewares/types.go
+++ b/internal/middlewares/types.go
@@ -70,9 +70,17 @@ type BridgeBuilder struct {
// Basic represents a middleware applied to a fasthttp.RequestHandler.
type Basic func(next fasthttp.RequestHandler) (handler fasthttp.RequestHandler)
+// IdentityVerificationCommonArgs contains shared options for both verification start and finish steps.
+type IdentityVerificationCommonArgs struct {
+ // If true, skip identity verification if the user's AuthenticationLevel is TwoFactor. Otherwise, always perform identity verification.
+ SkipIfAuthLevelTwoFactor bool
+}
+
// IdentityVerificationStartArgs represent the arguments used to customize the starting phase
// of the identity verification process.
type IdentityVerificationStartArgs struct {
+ IdentityVerificationCommonArgs
+
// Email template needs a subject, a title and the content of the button.
MailTitle string
MailButtonContent string
@@ -94,6 +102,8 @@ type IdentityVerificationStartArgs struct {
// IdentityVerificationFinishArgs represent the arguments used to customize the finishing phase
// of the identity verification process.
type IdentityVerificationFinishArgs struct {
+ IdentityVerificationCommonArgs
+
// The action claim that should be in the token to consider the action legitimate.
ActionClaim string
diff --git a/internal/mocks/storage.go b/internal/mocks/storage.go
index 2a20f8acc..a15437923 100644
--- a/internal/mocks/storage.go
+++ b/internal/mocks/storage.go
@@ -10,11 +10,10 @@ import (
reflect "reflect"
time "time"
- gomock "github.com/golang/mock/gomock"
- uuid "github.com/google/uuid"
-
model "github.com/authelia/authelia/v4/internal/model"
storage "github.com/authelia/authelia/v4/internal/storage"
+ gomock "github.com/golang/mock/gomock"
+ uuid "github.com/google/uuid"
)
// MockStorage is a mock of Provider interface.
@@ -195,6 +194,20 @@ func (mr *MockStorageMockRecorder) DeleteWebauthnDeviceByUsername(arg0, arg1, ar
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebauthnDeviceByUsername", reflect.TypeOf((*MockStorage)(nil).DeleteWebauthnDeviceByUsername), arg0, arg1, arg2)
}
+// DeleteWebauthnDeviceByUsernameAndID mocks base method.
+func (m *MockStorage) DeleteWebauthnDeviceByUsernameAndID(arg0 context.Context, arg1 string, arg2 int) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DeleteWebauthnDeviceByUsernameAndID", arg0, arg1, arg2)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DeleteWebauthnDeviceByUsernameAndID indicates an expected call of DeleteWebauthnDeviceByUsernameAndID.
+func (mr *MockStorageMockRecorder) DeleteWebauthnDeviceByUsernameAndID(arg0, arg1, arg2 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWebauthnDeviceByUsernameAndID", reflect.TypeOf((*MockStorage)(nil).DeleteWebauthnDeviceByUsernameAndID), arg0, arg1, arg2)
+}
+
// FindIdentityVerification mocks base method.
func (m *MockStorage) FindIdentityVerification(arg0 context.Context, arg1 string) (bool, error) {
m.ctrl.T.Helper()
@@ -820,6 +833,20 @@ func (mr *MockStorageMockRecorder) UpdateTOTPConfigurationSignIn(arg0, arg1, arg
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTOTPConfigurationSignIn", reflect.TypeOf((*MockStorage)(nil).UpdateTOTPConfigurationSignIn), arg0, arg1, arg2)
}
+// UpdateWebauthnDeviceDescription mocks base method.
+func (m *MockStorage) UpdateWebauthnDeviceDescription(arg0 context.Context, arg1 string, arg2 int, arg3 string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateWebauthnDeviceDescription", arg0, arg1, arg2, arg3)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateWebauthnDeviceDescription indicates an expected call of UpdateWebauthnDeviceDescription.
+func (mr *MockStorageMockRecorder) UpdateWebauthnDeviceDescription(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWebauthnDeviceDescription", reflect.TypeOf((*MockStorage)(nil).UpdateWebauthnDeviceDescription), arg0, arg1, arg2, arg3)
+}
+
// UpdateWebauthnDeviceSignIn mocks base method.
func (m *MockStorage) UpdateWebauthnDeviceSignIn(arg0 context.Context, arg1 int, arg2 string, arg3 sql.NullTime, arg4 uint32, arg5 bool) error {
m.ctrl.T.Helper()
diff --git a/internal/server/handlers.go b/internal/server/handlers.go
index 45b46c23a..4f7ef38d1 100644
--- a/internal/server/handlers.go
+++ b/internal/server/handlers.go
@@ -155,6 +155,11 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)
WithPostMiddlewares(middlewares.Require1FA).
Build()
+ middleware2FA := middlewares.NewBridgeBuilder(config, providers).
+ WithPreMiddlewares(middlewares.SecurityHeaders, middlewares.SecurityHeadersNoStore, middlewares.SecurityHeadersCSPNone).
+ WithPostMiddlewares(middlewares.Require2FA).
+ Build()
+
r.GET("/api/health", middlewareAPI(handlers.HealthGET))
r.GET("/api/state", middlewareAPI(handlers.StateGET))
@@ -209,7 +214,9 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)
r.POST("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebauthnAssertionPOST))
// Management of the webauthn devices.
- r.GET("/api/webauthn/devices", middleware1FA(handlers.WebauthnDevicesGet))
+ r.GET("/api/secondfactor/webauthn/devices", middleware1FA(handlers.WebauthnDevicesGet))
+ r.PUT("/api/secondfactor/webauthn/devices/{deviceID}", middleware2FA(handlers.WebauthnDeviceUpdate))
+ r.DELETE("/api/secondfactor/webauthn/devices/{deviceID}", middleware2FA(handlers.WebauthnDeviceDelete))
}
// Configure DUO api endpoint only if configuration exists.
diff --git a/internal/storage/provider.go b/internal/storage/provider.go
index ecfe104b0..a272fcdfd 100644
--- a/internal/storage/provider.go
+++ b/internal/storage/provider.go
@@ -39,9 +39,11 @@ type Provider interface {
LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []model.TOTPConfiguration, err error)
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)
DeleteWebauthnDevice(ctx context.Context, kid string) (err error)
DeleteWebauthnDeviceByUsername(ctx context.Context, username, description string) (err error)
+ DeleteWebauthnDeviceByUsernameAndID(ctx context.Context, username string, deviceID int) (err error)
LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []model.WebauthnDevice, err error)
LoadWebauthnDevicesByUsername(ctx context.Context, username string) (devices []model.WebauthnDevice, err error)
diff --git a/internal/storage/sql_provider.go b/internal/storage/sql_provider.go
index 7ee60ab5c..e34a81cf1 100644
--- a/internal/storage/sql_provider.go
+++ b/internal/storage/sql_provider.go
@@ -52,13 +52,17 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa
sqlSelectWebauthnDevices: fmt.Sprintf(queryFmtSelectWebauthnDevices, tableWebauthnDevices),
sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices),
- sqlUpdateWebauthnDevicePublicKey: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices),
- sqlUpdateWebauthnDevicePublicKeyByUsername: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDevicePublicKeyByUsername, tableWebauthnDevices),
+ sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID, tableWebauthnDevices),
+
+ sqlUpdateWebauthnDevicePublicKey: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices),
+ sqlUpdateWebauthnDevicePublicKeyByUsername: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDevicePublicKeyByUsername, tableWebauthnDevices),
+
sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices),
sqlUpdateWebauthnDeviceRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignInByUsername, tableWebauthnDevices),
sqlDeleteWebauthnDevice: fmt.Sprintf(queryFmtDeleteWebauthnDevice, tableWebauthnDevices),
sqlDeleteWebauthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsername, tableWebauthnDevices),
+ sqlDeleteWebauthnDeviceByUsernameAndID: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndID, tableWebauthnDevices),
sqlDeleteWebauthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDescription, tableWebauthnDevices),
sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
@@ -171,6 +175,8 @@ type SQLProvider struct {
sqlSelectWebauthnDevices string
sqlSelectWebauthnDevicesByUsername string
+ sqlUpdateWebauthnDeviceDescriptionByUsernameAndID string
+
sqlUpdateWebauthnDevicePublicKey string
sqlUpdateWebauthnDevicePublicKeyByUsername string
sqlUpdateWebauthnDeviceRecordSignIn string
@@ -178,6 +184,7 @@ type SQLProvider struct {
sqlDeleteWebauthnDevice string
sqlDeleteWebauthnDeviceByUsername string
+ sqlDeleteWebauthnDeviceByUsernameAndID string
sqlDeleteWebauthnDeviceByUsernameAndDescription string
// Table: duo_devices.
@@ -870,6 +877,15 @@ func (p *SQLProvider) SaveWebauthnDevice(ctx context.Context, device model.Webau
return nil
}
+// UpdateWebauthnDeviceDescription updates a registered Webauthn device's description.
+func (p *SQLProvider) UpdateWebauthnDeviceDescription(ctx context.Context, username string, deviceID int, description string) (err error) {
+ if _, err = p.db.ExecContext(ctx, p.sqlUpdateWebauthnDeviceDescriptionByUsernameAndID, description, username, deviceID); err != nil {
+ return fmt.Errorf("error updating Webauthn device description to '%s' for device id '%d': %w", description, deviceID, err)
+ }
+
+ return nil
+}
+
// 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 {
@@ -907,6 +923,19 @@ func (p *SQLProvider) DeleteWebauthnDeviceByUsername(ctx context.Context, userna
return nil
}
+// DeleteWebauthnDeviceByUsernameAndID deletes a registered Webauthn device by username and ID.
+func (p *SQLProvider) DeleteWebauthnDeviceByUsernameAndID(ctx context.Context, username string, deviceID int) (err error) {
+ if len(username) == 0 {
+ return fmt.Errorf("error deleting webauthn device with username '%s' and id '%d': username must not be empty", username, deviceID)
+ }
+
+ if _, err = p.db.ExecContext(ctx, p.sqlDeleteWebauthnDeviceByUsernameAndID, username, deviceID); err != nil {
+ return fmt.Errorf("error deleting webauthn device with username '%s' and id '%d': %w", username, deviceID, err)
+ }
+
+ return nil
+}
+
// LoadWebauthnDevices loads Webauthn device registrations.
func (p *SQLProvider) LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []model.WebauthnDevice, err error) {
devices = make([]model.WebauthnDevice, 0, limit)
diff --git a/internal/storage/sql_provider_queries.go b/internal/storage/sql_provider_queries.go
index 327ab546f..cea400a67 100644
--- a/internal/storage/sql_provider_queries.go
+++ b/internal/storage/sql_provider_queries.go
@@ -142,6 +142,11 @@ const (
SET public_key = ?
WHERE username = ? AND kid = ?;`
+ queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID = `
+ UPDATE %s
+ SET description = ?
+ WHERE username = ? AND id = ?;`
+
queryFmtUpdateWebauthnDeviceRecordSignIn = `
UPDATE %s
SET
@@ -174,6 +179,10 @@ const (
DELETE FROM %s
WHERE username = ?;`
+ queryFmtDeleteWebauthnDeviceByUsernameAndID = `
+ DELETE FROM %s
+ WHERE username = ? AND id = ?;`
+
queryFmtDeleteWebauthnDeviceByUsernameAndDescription = `
DELETE FROM %s
WHERE username = ? AND description = ?;`
diff --git a/web/src/components/WebauthnTryIcon.tsx b/web/src/components/WebauthnTryIcon.tsx
new file mode 100644
index 000000000..1a96bf163
--- /dev/null
+++ b/web/src/components/WebauthnTryIcon.tsx
@@ -0,0 +1,69 @@
+import React, { useEffect } from "react";
+
+import { Box, Button, Theme, useTheme } from "@mui/material";
+import makeStyles from "@mui/styles/makeStyles";
+
+import FailureIcon from "@components/FailureIcon";
+import FingerTouchIcon from "@components/FingerTouchIcon";
+import LinearProgressBar from "@components/LinearProgressBar";
+import { useTimer } from "@hooks/Timer";
+import { WebauthnTouchState } from "@models/Webauthn";
+import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
+
+interface Props {
+ timer: number;
+ onRetryClick: () => void;
+ webauthnTouchState: WebauthnTouchState;
+}
+
+export default function WebauthnTryIcon(props: Props) {
+ const touchTimeout = 30;
+ const theme = useTheme();
+ const [timerPercent, triggerTimer, clearTimer] = useTimer(touchTimeout * 1000 - 500);
+
+ const styles = makeStyles((theme: Theme) => ({
+ icon: {
+ display: "inline-block",
+ },
+ progressBar: {
+ marginTop: theme.spacing(),
+ },
+ }))();
+
+ const handleRetryClick = () => {
+ clearTimer();
+ triggerTimer();
+ props.onRetryClick();
+ };
+
+ useEffect(() => {
+ triggerTimer();
+ }, [triggerTimer]);
+
+ const touch = (
+ }
+ className={props.webauthnTouchState === WebauthnTouchState.WaitTouch ? undefined : "hidden"}
+ >
+
+
+ );
+
+ const failure = (
+ }
+ className={props.webauthnTouchState === WebauthnTouchState.Failure ? undefined : "hidden"}
+ >
+
+
+ );
+
+ return (
+
+ {touch}
+ {failure}
+
+ );
+}
diff --git a/web/src/constants/Routes.ts b/web/src/constants/Routes.ts
index 2562a6761..4e98c3e6a 100644
--- a/web/src/constants/Routes.ts
+++ b/web/src/constants/Routes.ts
@@ -2,10 +2,10 @@ export const IndexRoute: string = "/";
export const AuthenticatedRoute: string = "/authenticated";
export const ConsentRoute: string = "/consent";
-export const SecondFactorRoute: string = "/2fa/";
-export const SecondFactorWebauthnSubRoute: string = "webauthn";
-export const SecondFactorTOTPSubRoute: string = "one-time-password";
-export const SecondFactorPushSubRoute: string = "push-notification";
+export const SecondFactorRoute: string = "/2fa";
+export const SecondFactorWebauthnSubRoute: string = "/webauthn";
+export const SecondFactorTOTPSubRoute: string = "/one-time-password";
+export const SecondFactorPushSubRoute: string = "/push-notification";
export const ResetPasswordStep1Route: string = "/reset-password/step1";
export const ResetPasswordStep2Route: string = "/reset-password/step2";
diff --git a/web/src/layouts/SettingsLayout.tsx b/web/src/layouts/SettingsLayout.tsx
index 20660811f..67520a1fa 100644
--- a/web/src/layouts/SettingsLayout.tsx
+++ b/web/src/layouts/SettingsLayout.tsx
@@ -1,10 +1,11 @@
-import React, { ReactNode, useCallback, useEffect } from "react";
+import React, { ReactNode, useEffect } from "react";
import { Dashboard } from "@mui/icons-material";
import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood";
import {
AppBar,
Box,
+ Button,
Drawer,
Grid,
List,
@@ -17,7 +18,7 @@ import {
} from "@mui/material";
import { useTranslation } from "react-i18next";
-import { SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
+import { IndexRoute, SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
import { useRouterNavigate } from "@hooks/RouterNavigate";
export interface Props {
@@ -32,6 +33,7 @@ const defaultDrawerWidth = 240;
const SettingsLayout = function (props: Props) {
const { t: translate } = useTranslation("settings");
+ const navigate = useRouterNavigate();
useEffect(() => {
if (props.title) {
@@ -56,6 +58,15 @@ const SettingsLayout = function (props: Props) {
theme.zIndex.drawer + 1 }}>
{translate("Settings")}
+
{
+export async function getAttestationCreationOptions(
+ token: null | string,
+): Promise {
let response: AxiosResponse>;
response = await axios.post>(WebauthnIdentityFinishPath, {
@@ -248,7 +257,7 @@ export async function getAssertionRequestOptions(): Promise {
const result: AttestationPublicKeyCredentialResult = {
@@ -272,9 +281,7 @@ async function getAttestationPublicKeyCredentialResult(
}
}
- if (result.credential == null) {
- result.result = AttestationResult.Failure;
- } else {
+ if (result.credential != null) {
result.result = AttestationResult.Success;
}
@@ -334,32 +341,27 @@ export async function postAssertionPublicKeyCredentialResult(
return axios.post>(WebauthnAssertionPath, credentialJSON);
}
-export async function performAttestationCeremony(token: string, description: string): Promise {
- const attestationCreationOpts = await getAttestationCreationOptions(token);
-
- if (attestationCreationOpts.status !== 200 || attestationCreationOpts.options == null) {
- if (attestationCreationOpts.status === 403) {
- return AttestationResult.FailureToken;
+export async function finishAttestationCeremony(
+ credential: AttestationPublicKeyCredential,
+ description: string,
+): Promise {
+ let result = {
+ status: AttestationResult.Failure,
+ message: "Device registration failed.",
+ } as AttestationResult;
+ try {
+ const response = await postAttestationPublicKeyCredentialResult(credential, description);
+ if (response.data.status === "OK" && (response.status === 200 || response.status === 201)) {
+ return {
+ status: AttestationResult.Success,
+ } as AttestationFinishResult;
+ }
+ } catch (error) {
+ if (error instanceof AxiosError) {
+ result.message = error.response.data.message;
}
-
- return AttestationResult.Failure;
}
-
- const attestationResult = await getAttestationPublicKeyCredentialResult(attestationCreationOpts.options);
-
- if (attestationResult.result !== AttestationResult.Success) {
- return attestationResult.result;
- } else if (attestationResult.credential == null) {
- return AttestationResult.Failure;
- }
-
- const response = await postAttestationPublicKeyCredentialResult(attestationResult.credential, description);
-
- if (response.data.status === "OK" && (response.status === 200 || response.status === 201)) {
- return AttestationResult.Success;
- }
-
- return AttestationResult.Failure;
+ return result;
}
export async function performAssertionCeremony(
@@ -394,3 +396,8 @@ export async function performAssertionCeremony(
return AssertionResult.Failure;
}
+
+export async function deleteDevice(deviceID: number): Promise {
+ let response = await axios.delete(`${WebauthnDevicesPath}/${deviceID}`);
+ return response.status;
+}
diff --git a/web/src/views/DeviceRegistration/RegisterWebauthn.tsx b/web/src/views/DeviceRegistration/RegisterWebauthn.tsx
index f8372b76b..474989a42 100644
--- a/web/src/views/DeviceRegistration/RegisterWebauthn.tsx
+++ b/web/src/views/DeviceRegistration/RegisterWebauthn.tsx
@@ -1,47 +1,84 @@
-import React, { useCallback, useEffect, useState } from "react";
+import React, { MutableRefObject, useCallback, useEffect, useRef, useState } from "react";
-import { Button, Theme, Typography } from "@mui/material";
+import { Box, Button, Grid, Stack, Step, StepLabel, Stepper, Theme, Typography } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
+import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
-import FingerTouchIcon from "@components/FingerTouchIcon";
+import FixedTextField from "@components/FixedTextField";
+import InformationIcon from "@components/InformationIcon";
+import SuccessIcon from "@components/SuccessIcon";
+import WebauthnTryIcon from "@components/WebauthnTryIcon";
+import { SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
import { useNotifications } from "@hooks/NotificationsContext";
import LoginLayout from "@layouts/LoginLayout";
-import { AttestationResult } from "@models/Webauthn";
-import { FirstFactorPath } from "@services/Api";
-import { performAttestationCeremony } from "@services/Webauthn";
+import { AttestationPublicKeyCredential, AttestationResult, WebauthnTouchState } from "@models/Webauthn";
+import {
+ finishAttestationCeremony,
+ getAttestationCreationOptions,
+ getAttestationPublicKeyCredentialResult,
+} from "@services/Webauthn";
import { extractIdentityToken } from "@utils/IdentityToken";
-const description = "Primary";
+const steps = ["Confirm device", "Choose name"];
-const RegisterWebauthn = function () {
+interface Props {}
+
+const RegisterWebauthn = function (props: Props) {
+ const [state, setState] = useState(WebauthnTouchState.WaitTouch);
const styles = useStyles();
const navigate = useNavigate();
const location = useLocation();
+ const { t: translate } = useTranslation();
const { createErrorNotification } = useNotifications();
- const [, setRegistrationInProgress] = useState(false);
+
+ const [activeStep, setActiveStep] = React.useState(0);
+ const [credential, setCredential] = React.useState(null as null | AttestationPublicKeyCredential);
+ const [creationOptions, setCreationOptions] = useState(null as null | PublicKeyCredentialCreationOptions);
+ const [deviceName, setName] = useState("");
+ const nameRef = useRef() as MutableRefObject;
+ const [nameError, setNameError] = useState(false);
const processToken = extractIdentityToken(location.search);
const handleBackClick = () => {
- navigate(FirstFactorPath);
+ navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
};
- const attestation = useCallback(async () => {
- if (!processToken) {
+ const finishAttestation = async () => {
+ if (!credential) {
return;
}
+ if (!deviceName.length) {
+ setNameError(true);
+ return;
+ }
+ const result = await finishAttestationCeremony(credential, deviceName);
+ switch (result.status) {
+ case AttestationResult.Success:
+ setActiveStep(2);
+ navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
+ break;
+ case AttestationResult.Failure:
+ createErrorNotification(result.message);
+ }
+ };
+
+ const startAttestation = useCallback(async () => {
try {
- setRegistrationInProgress(true);
+ setState(WebauthnTouchState.WaitTouch);
+ setActiveStep(0);
- const result = await performAttestationCeremony(processToken, description);
+ const startResult = await getAttestationPublicKeyCredentialResult(creationOptions);
- setRegistrationInProgress(false);
-
- switch (result) {
+ switch (startResult.result) {
case AttestationResult.Success:
- navigate(FirstFactorPath);
- break;
+ if (startResult.credential == null) {
+ throw new Error("Attestation request succeeded but credential is empty");
+ }
+ setCredential(startResult.credential);
+ setActiveStep(1);
+ return;
case AttestationResult.FailureToken:
createErrorNotification(
"You must open the link from the same device and browser that initiated the registration process.",
@@ -73,30 +110,137 @@ const RegisterWebauthn = function () {
createErrorNotification("An unknown error occurred.");
break;
}
+ setState(WebauthnTouchState.Failure);
} catch (err) {
console.error(err);
createErrorNotification(
"Failed to register your device. The identity verification process might have timed out.",
);
}
- }, [processToken, createErrorNotification, navigate]);
+ }, [creationOptions, createErrorNotification]);
useEffect(() => {
- attestation();
- }, [attestation]);
+ if (creationOptions !== null) {
+ startAttestation();
+ }
+ }, [creationOptions, startAttestation]);
+
+ useEffect(() => {
+ (async () => {
+ const result = await getAttestationCreationOptions(processToken);
+ if (result.status !== 200 || !result.options) {
+ createErrorNotification(
+ "You must open the link from the same device and browser that initiated the registration process.",
+ );
+ return;
+ }
+ setCreationOptions(result.options);
+ })();
+ }, [processToken, setCreationOptions, createErrorNotification]);
+
+ function renderStep(step: number) {
+ switch (step) {
+ case 0:
+ return (
+ <>
+
+
+
+ Touch the token on your security key
+
+
+
+
+
+
+
+ >
+ );
+ case 1:
+ return (
+
+
+
+
+
Enter a name for this key
+
+
+ setName(v.target.value.substring(0, 30))}
+ onFocus={() => setNameError(false)}
+ autoCapitalize="none"
+ autoComplete="username"
+ onKeyPress={(ev) => {
+ if (ev.key === "Enter") {
+ if (!deviceName.length) {
+ setNameError(true);
+ } else {
+ finishAttestation();
+ }
+ ev.preventDefault();
+ }
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ );
+ case 2:
+ return (
+
+
+
+
+
{translate("Registration success")}
+
+ );
+ }
+ }
return (
-
-
-
-
- Touch the token on your security key
-
-
+
+
+
+
+
+ {steps.map((label, index) => {
+ const stepProps: { completed?: boolean } = {};
+ const labelProps: {
+ optional?: React.ReactNode;
+ } = {};
+ return (
+
+ {label}
+
+ );
+ })}
+
+ {renderStep(activeStep)}
+
+
+
);
};
@@ -108,7 +252,18 @@ const useStyles = makeStyles((theme: Theme) => ({
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
+ iconContainer: {
+ marginBottom: theme.spacing(2),
+ flex: "0 0 100%",
+ },
instruction: {
paddingBottom: theme.spacing(4),
},
+ methodContainer: {
+ border: "1px solid #d6d6d6",
+ borderRadius: "10px",
+ padding: theme.spacing(4),
+ marginTop: theme.spacing(2),
+ marginBottom: theme.spacing(2),
+ },
}));
diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx
index 3ccb37644..0a63194e7 100644
--- a/web/src/views/LoginPortal/LoginPortal.tsx
+++ b/web/src/views/LoginPortal/LoginPortal.tsx
@@ -188,7 +188,7 @@ const LoginPortal = function (props: Props) {
}
/>
Promise) => {
+ const initiateRegistration = (initiateRegistrationFunc: () => Promise, redirectRoute: string) => {
return async () => {
- if (registrationInProgress) {
- return;
+ if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
+ navigate(redirectRoute);
+ } else {
+ if (registrationInProgress) {
+ return;
+ }
+ setRegistrationInProgress(true);
+ try {
+ await initiateRegistrationFunc();
+ createInfoNotification(translate("An email has been sent to your address to complete the process"));
+ } catch (err) {
+ console.error(err);
+ createErrorNotification(translate("There was a problem initiating the registration process"));
+ }
+ setRegistrationInProgress(false);
}
- setRegistrationInProgress(true);
- try {
- await initiateRegistrationFunc();
- createInfoNotification(translate("An email has been sent to your address to complete the process"));
- } catch (err) {
- console.error(err);
- createErrorNotification(translate("There was a problem initiating the registration process"));
- }
- setRegistrationInProgress(false);
};
};
@@ -122,7 +129,10 @@ const SecondFactorForm = function (props: Props) {
authenticationLevel={props.authenticationLevel}
// Whether the user has a TOTP secret registered already
registered={props.userInfo.has_totp}
- onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
+ onRegisterClick={initiateRegistration(
+ initiateTOTPRegistrationProcess,
+ RegisterOneTimePasswordRoute,
+ )}
onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess}
/>
@@ -136,7 +146,9 @@ const SecondFactorForm = function (props: Props) {
authenticationLevel={props.authenticationLevel}
// Whether the user has a Webauthn device registered already
registered={props.userInfo.has_webauthn}
- onRegisterClick={initiateRegistration(initiateWebauthnRegistrationProcess)}
+ onRegisterClick={() => {
+ navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
+ }}
onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess}
/>
diff --git a/web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx b/web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx
index 96fa00fcf..023eea4bd 100644
--- a/web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx
+++ b/web/src/views/LoginPortal/SecondFactor/WebauthnMethod.tsx
@@ -1,31 +1,18 @@
-import React, { Fragment, useCallback, useEffect, useRef, useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
-import { Button, Theme, useTheme } from "@mui/material";
-import makeStyles from "@mui/styles/makeStyles";
-
-import FailureIcon from "@components/FailureIcon";
-import FingerTouchIcon from "@components/FingerTouchIcon";
-import LinearProgressBar from "@components/LinearProgressBar";
+import WebauthnTryIcon from "@components/WebauthnTryIcon";
import { useIsMountedRef } from "@hooks/Mounted";
import { useRedirectionURL } from "@hooks/RedirectionURL";
-import { useTimer } from "@hooks/Timer";
import { useWorkflow } from "@hooks/Workflow";
-import { AssertionResult } from "@models/Webauthn";
+import { AssertionResult, WebauthnTouchState } from "@models/Webauthn";
import { AuthenticationLevel } from "@services/State";
import {
getAssertionPublicKeyCredentialResult,
getAssertionRequestOptions,
postAssertionPublicKeyCredentialResult,
} from "@services/Webauthn";
-import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
-export enum State {
- WaitTouch = 1,
- InProgress = 2,
- Failure = 3,
-}
-
export interface Props {
id: string;
authenticationLevel: AuthenticationLevel;
@@ -37,13 +24,10 @@ export interface Props {
}
const WebauthnMethod = function (props: Props) {
- const signInTimeout = 30;
- const [state, setState] = useState(State.WaitTouch);
- const styles = useStyles();
+ const [state, setState] = useState(WebauthnTouchState.WaitTouch);
const redirectionURL = useRedirectionURL();
const [workflow, workflowID] = useWorkflow();
const mounted = useIsMountedRef();
- const [timerPercent, triggerTimer] = useTimer(signInTimeout * 1000 - 500);
const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useRef(onSignInError).current;
@@ -56,12 +40,11 @@ const WebauthnMethod = function (props: Props) {
}
try {
- triggerTimer();
- setState(State.WaitTouch);
+ setState(WebauthnTouchState.WaitTouch);
const assertionRequestResponse = await getAssertionRequestOptions();
if (assertionRequestResponse.status !== 200 || assertionRequestResponse.options == null) {
- setState(State.Failure);
+ setState(WebauthnTouchState.Failure);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
return;
@@ -88,6 +71,9 @@ const WebauthnMethod = function (props: Props) {
case AssertionResult.FailureWebauthnNotSupported:
onSignInErrorCallback(new Error("Your browser does not support the WebAuthN protocol."));
break;
+ case AssertionResult.FailureUnrecognized:
+ onSignInErrorCallback(new Error("This device is not registered."));
+ break;
case AssertionResult.FailureUnknownSecurity:
onSignInErrorCallback(new Error("An unknown security error occurred."));
break;
@@ -98,21 +84,21 @@ const WebauthnMethod = function (props: Props) {
onSignInErrorCallback(new Error("An unexpected error occurred."));
break;
}
- setState(State.Failure);
+ setState(WebauthnTouchState.Failure);
return;
}
if (result.credential == null) {
onSignInErrorCallback(new Error("The browser did not respond with the expected attestation data."));
- setState(State.Failure);
+ setState(WebauthnTouchState.Failure);
return;
}
if (!mounted.current) return;
- setState(State.InProgress);
+ setState(WebauthnTouchState.InProgress);
const response = await postAssertionPublicKeyCredentialResult(
result.credential,
@@ -129,14 +115,14 @@ const WebauthnMethod = function (props: Props) {
if (!mounted.current) return;
onSignInErrorCallback(new Error("The server rejected the security key."));
- setState(State.Failure);
+ setState(WebauthnTouchState.Failure);
} catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime,
// the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return;
console.error(err);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
- setState(State.Failure);
+ setState(WebauthnTouchState.Failure);
}
}, [
onSignInErrorCallback,
@@ -145,7 +131,6 @@ const WebauthnMethod = function (props: Props) {
workflow,
workflowID,
mounted,
- triggerTimer,
props.authenticationLevel,
props.registered,
]);
@@ -171,59 +156,9 @@ const WebauthnMethod = function (props: Props) {
state={methodState}
onRegisterClick={props.onRegisterClick}
>
-
-
-
+
);
};
export default WebauthnMethod;
-
-const useStyles = makeStyles((theme: Theme) => ({
- icon: {
- display: "inline-block",
- },
-}));
-
-interface IconProps {
- state: State;
-
- timer: number;
- onRetryClick: () => void;
-}
-
-function Icon(props: IconProps) {
- const state = props.state as State;
- const theme = useTheme();
-
- const styles = makeStyles((theme: Theme) => ({
- progressBar: {
- marginTop: theme.spacing(),
- },
- }))();
-
- const touch = (
- }
- className={state === State.WaitTouch ? undefined : "hidden"}
- >
-
-
- );
-
- const failure = (
- } className={state === State.Failure ? undefined : "hidden"}>
-
-
- );
-
- return (
-
- {touch}
- {failure}
-
- );
-}
diff --git a/web/src/views/Settings/SettingsRouter.tsx b/web/src/views/Settings/SettingsRouter.tsx
index 77356ca21..d0e32bc76 100644
--- a/web/src/views/Settings/SettingsRouter.tsx
+++ b/web/src/views/Settings/SettingsRouter.tsx
@@ -1,19 +1,42 @@
-import React from "react";
+import React, { useEffect } from "react";
import { Route, Routes } from "react-router-dom";
import { IndexRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
+import { useRouterNavigate } from "@hooks/RouterNavigate";
+import { useAutheliaState } from "@hooks/State";
+import SettingsLayout from "@layouts/SettingsLayout";
+import { AuthenticationLevel } from "@services/State";
import SettingsView from "@views/Settings/SettingsView";
import TwoFactorAuthenticationView from "@views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView";
export interface Props {}
const SettingsRouter = function (props: Props) {
+ const navigate = useRouterNavigate();
+ const [state, fetchState, , fetchStateError] = useAutheliaState();
+
+ // Fetch the state on page load
+ useEffect(() => {
+ fetchState();
+ }, [fetchState]);
+
+ useEffect(() => {
+ if (fetchStateError || (state && state.authentication_level < AuthenticationLevel.OneFactor)) {
+ navigate(IndexRoute);
+ }
+ }, [state, fetchStateError, navigate]);
+
return (
-
- } />
- } />
-
+
+
+ } />
+ }
+ />
+
+
);
};
diff --git a/web/src/views/Settings/SettingsView.tsx b/web/src/views/Settings/SettingsView.tsx
index bba1c4f90..1fc65d80e 100644
--- a/web/src/views/Settings/SettingsView.tsx
+++ b/web/src/views/Settings/SettingsView.tsx
@@ -1,16 +1,12 @@
import { Box, Typography } from "@mui/material";
-import SettingsLayout from "@layouts/SettingsLayout";
-
export interface Props {}
const SettingsView = function (props: Props) {
return (
-
-
- Placeholder
-
-
+
+ Placeholder
+
);
};
diff --git a/web/src/views/Settings/TwoFactorAuthentication/AddSecurityDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/AddSecurityDialog.tsx
deleted file mode 100644
index 659e53bef..000000000
--- a/web/src/views/Settings/TwoFactorAuthentication/AddSecurityDialog.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from "react";
-
-import {
- Button,
- Dialog,
- DialogActions,
- DialogContent,
- DialogContentText,
- DialogProps,
- DialogTitle,
- TextField,
-} from "@mui/material";
-import { useTranslation } from "react-i18next";
-
-interface Props extends DialogProps {}
-
-export default function AddSecurityKeyDialog(props: Props) {
- const { t: translate } = useTranslation("settings");
-
- const handleAddClick = () => {
- if (props.onClose) {
- props.onClose({}, "backdropClick");
- }
- };
-
- const handleCancelClick = () => {
- if (props.onClose) {
- props.onClose({}, "backdropClick");
- }
- };
-
- return (
-
- );
-}
diff --git a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx
index 0471a7b42..ef6434629 100644
--- a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx
+++ b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx
@@ -1,261 +1,21 @@
-import React, { useEffect, useState } from "react";
+import React from "react";
-import DeleteIcon from "@mui/icons-material/Delete";
-import EditIcon from "@mui/icons-material/Edit";
-import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
-import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
-import {
- Box,
- Button,
- Collapse,
- Divider,
- Grid,
- IconButton,
- Paper,
- Stack,
- Switch,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableRow,
- Tooltip,
- Typography,
-} from "@mui/material";
-import { useTranslation } from "react-i18next";
+import { Grid } from "@mui/material";
-import SettingsLayout from "@layouts/SettingsLayout";
-import { WebauthnDevice } from "@root/models/Webauthn";
-import { getWebauthnDevices } from "@root/services/UserWebauthnDevices";
+import { AutheliaState } from "@services/State";
-import AddSecurityKeyDialog from "./AddSecurityDialog";
+import WebauthnDevices from "./WebauthnDevices";
-export interface Props {}
-
-const TwoFactorAuthenticationView = function (props: Props) {
- const { t: translate } = useTranslation("settings");
-
- const [webauthnDevices, setWebauthnDevices] = useState();
- const [addKeyOpen, setAddKeyOpen] = useState(false);
- const [webauthnShowDetails, setWebauthnShowDetails] = useState(-1);
-
- const handleWebAuthnDetailsChange = (idx: number) => {
- if (webauthnShowDetails === idx) {
- setWebauthnShowDetails(-1);
- } else {
- setWebauthnShowDetails(idx);
- }
- };
-
- useEffect(() => {
- (async function () {
- const devices = await getWebauthnDevices();
- setWebauthnDevices(devices);
- })();
- }, []);
-
- const handleKeyClose = () => {
- setAddKeyOpen(false);
- };
-
- const handleAddKeyButtonClick = () => {
- setAddKeyOpen(true);
- };
+interface Props {
+ state: AutheliaState;
+}
+export default function TwoFactorAuthSettings(props: Props) {
return (
-
-
-
- {translate("Manage your security keys")}
-
-
-
-
-
-
-
-
-
-
-
-
- {translate("Name")}
- {translate("Enabled")}
- {translate("Actions")}
-
-
-
- {webauthnDevices
- ? webauthnDevices.map((x, idx) => {
- return (
-
- *": { borderBottom: "unset" } }}
- key={x.kid.toString()}
- >
-
-
- handleWebAuthnDetailsChange(idx)}
- >
- {webauthnShowDetails === idx ? (
-
- ) : (
-
- )}
-
-
-
-
- {x.description}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {translate("Details")}
-
-
-
-
-
-
-
-
- {translate("Webauthn Credential Identifier", {
- id: x.kid.toString(),
- })}
-
-
-
-
- Public Key: {x.public_key}
- {translate("Webauthn Public Key", {
- key: x.public_key.toString(),
- })}
-
-
-
-
-
-
-
- {translate("Relying Party ID")}
-
- {x.rpid}
-
-
-
- {translate("Authenticator Attestation GUID")}
-
- {x.aaguid}
-
-
-
- {translate("Attestation Type")}
-
- {x.attestation_type}
-
-
- {translate("Transports")}
-
- {x.transports.length === 0
- ? "N/A"
- : x.transports.join(", ")}
-
-
-
-
- {translate("Clone Warning")}
-
-
- {x.clone_warning
- ? translate("Yes")
- : translate("No")}
-
-
-
- {translate("Created")}
- {x.created_at.toString()}
-
-
- {translate("Last Used")}
-
- {x.last_used_at === undefined
- ? translate("Never")
- : x.last_used_at.toString()}
-
-
-
-
- {translate("Usage Count")}
-
-
- {x.sign_count === 0
- ? translate("Never")
- : x.sign_count}
-
-
-
-
-
-
-
-
-
-
- );
- })
- : null}
-
-
-
-
+
+
+
-
-
+
);
-};
-
-export default TwoFactorAuthenticationView;
+}
diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceItem.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceItem.tsx
new file mode 100644
index 000000000..e4f5ca6de
--- /dev/null
+++ b/web/src/views/Settings/TwoFactorAuthentication/WebauthnDeviceItem.tsx
@@ -0,0 +1,177 @@
+import React, { useState } from "react";
+
+import DeleteIcon from "@mui/icons-material/Delete";
+import EditIcon from "@mui/icons-material/Edit";
+import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
+import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
+import {
+ Box,
+ CircularProgress,
+ Collapse,
+ Divider,
+ Grid,
+ IconButton,
+ Stack,
+ Switch,
+ TableCell,
+ TableRow,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import { useTranslation } from "react-i18next";
+
+import { useNotifications } from "@hooks/NotificationsContext";
+import { WebauthnDevice } from "@root/models/Webauthn";
+import { deleteDevice } from "@root/services/Webauthn";
+
+interface Props {
+ device: WebauthnDevice;
+ webauthnShowDetails: number;
+ idx: number;
+ handleWebAuthnDetailsChange: (idx: number) => void;
+ handleDeleteItem: (idx: number) => void;
+}
+
+export default function WebauthnDeviceItem(props: Props) {
+ const { t: translate } = useTranslation("settings");
+ const { createErrorNotification } = useNotifications();
+ const [deleting, setDeleting] = useState(false);
+
+ const handleDelete = async () => {
+ setDeleting(true);
+ const status = await deleteDevice(props.device.id);
+ setDeleting(false);
+ if (status !== 200) {
+ createErrorNotification(translate("There was a problem deleting the device"));
+ return;
+ }
+ props.handleDeleteItem(props.idx);
+ };
+
+ return (
+
+ *": { borderBottom: "unset" } }} key={props.device.kid.toString()}>
+
+
+ props.handleWebAuthnDetailsChange(props.idx)}
+ >
+ {props.webauthnShowDetails === props.idx ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {props.device.description}
+
+
+
+
+
+
+
+
+
+
+
+ {deleting ? (
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {translate("Details")}
+
+
+
+
+
+
+
+
+ {translate("Webauthn Credential Identifier", {
+ id: props.device.kid.toString(),
+ })}
+
+
+
+
+ Public Key: {props.device.public_key}
+ {translate("Webauthn Public Key", {
+ key: props.device.public_key.toString(),
+ })}
+
+
+
+
+
+
+ {translate("Relying Party ID")}
+ {props.device.rpid}
+
+
+ {translate("Authenticator Attestation GUID")}
+ {props.device.aaguid}
+
+
+ {translate("Attestation Type")}
+ {props.device.attestation_type}
+
+
+ {translate("Transports")}
+
+ {props.device.transports.length === 0 ? "N/A" : props.device.transports.join(", ")}
+
+
+
+ {translate("Clone Warning")}
+
+ {props.device.clone_warning ? translate("Yes") : translate("No")}
+
+
+
+ {translate("Created")}
+ {props.device.created_at.toString()}
+
+
+ {translate("Last Used")}
+
+ {props.device.last_used_at === undefined
+ ? translate("Never")
+ : props.device.last_used_at.toString()}
+
+
+
+ {translate("Usage Count")}
+
+ {props.device.sign_count === 0 ? translate("Never") : props.device.sign_count}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebauthnDevices.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebauthnDevices.tsx
new file mode 100644
index 000000000..6f50f2919
--- /dev/null
+++ b/web/src/views/Settings/TwoFactorAuthentication/WebauthnDevices.tsx
@@ -0,0 +1,118 @@
+import React, { useEffect, useState } from "react";
+
+import { Box, Button, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+import { RegisterWebauthnRoute } from "@constants/Routes";
+import { useNotifications } from "@hooks/NotificationsContext";
+import { WebauthnDevice } from "@root/models/Webauthn";
+import { initiateWebauthnRegistrationProcess } from "@root/services/RegisterDevice";
+import { AutheliaState, AuthenticationLevel } from "@root/services/State";
+import { getWebauthnDevices } from "@root/services/UserWebauthnDevices";
+
+import WebauthnDeviceItem from "./WebauthnDeviceItem";
+
+interface Props {
+ state: AutheliaState;
+}
+
+export default function TwoFactorAuthSettings(props: Props) {
+ const { t: translate } = useTranslation("settings");
+ const navigate = useNavigate();
+
+ const { createInfoNotification, createErrorNotification } = useNotifications();
+ const [webauthnShowDetails, setWebauthnShowDetails] = useState(-1);
+ const [registrationInProgress, setRegistrationInProgress] = useState(false);
+
+ const [webauthnDevices, setWebauthnDevices] = useState();
+
+ useEffect(() => {
+ (async function () {
+ const devices = await getWebauthnDevices();
+ setWebauthnDevices(devices);
+ })();
+ }, []);
+
+ const handleWebAuthnDetailsChange = (idx: number) => {
+ if (webauthnShowDetails === idx) {
+ setWebauthnShowDetails(-1);
+ } else {
+ setWebauthnShowDetails(idx);
+ }
+ };
+
+ const handleDeleteItem = async (idx: number) => {
+ let updatedDevices = [...webauthnDevices];
+ updatedDevices.splice(idx, 1);
+ setWebauthnDevices(updatedDevices);
+ };
+
+ const initiateRegistration = async (initiateRegistrationFunc: () => Promise, redirectRoute: string) => {
+ if (props.state.authentication_level >= AuthenticationLevel.TwoFactor) {
+ navigate(redirectRoute);
+ } else {
+ if (registrationInProgress) {
+ return;
+ }
+ setRegistrationInProgress(true);
+ try {
+ await initiateRegistrationFunc();
+ createInfoNotification(translate("An email has been sent to your address to complete the process"));
+ } catch (err) {
+ console.error(err);
+ createErrorNotification(translate("There was a problem initiating the registration process"));
+ }
+ setRegistrationInProgress(false);
+ }
+ };
+
+ const handleAddKeyButtonClick = () => {
+ initiateRegistration(initiateWebauthnRegistrationProcess, RegisterWebauthnRoute);
+ };
+
+ return (
+
+
+
+
+ Webauthn Devices
+
+
+
+
+
+
+
+
+
+ {translate("Name")}
+ {translate("Enabled")}
+ {translate("Actions")}
+
+
+
+ {webauthnDevices
+ ? webauthnDevices.map((x, idx) => {
+ return (
+
+ );
+ })
+ : null}
+
+
+
+
+
+
+ );
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 8231f84a3..659ba3e16 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -58,13 +58,6 @@ export default defineConfig(({ mode }) => {
port: 3000,
open: false,
},
- plugins: [
- ,
- /* eslintPlugin({ cache: false }) */ htmlPlugin(),
- istanbulPlugin,
- react(),
- svgr(),
- tsconfigPaths(),
- ],
+ plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
};
});