feat: move webauthn device enrollment flow to new settings ui (#4376)

The current 2-factor authentication method registration flow requires
email verification for both initial 2FA registration, and 2FA
re-registration even if the user is already logged in with 2FA.

This change removes email ID verification for users who are already
logged in with 2-factor authentication. Users who have only completed
first factor authentication (password) are still required to complete
email ID verification.
pull/4405/head
Stephen Kent 2022-11-18 21:48:47 -08:00 committed by GitHub
parent ff26673659
commit 2584e3d328
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1113 additions and 561 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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,9 +123,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
}
}
// IdentityVerificationFinish the middleware for finishing the identity validation process.
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
return func(ctx *AutheliaCtx) {
func identityVerificationValidateToken(ctx *AutheliaCtx) (*jwt.Token, error) {
var finishBody IdentityVerificationFinishBody
b := ctx.PostBody()
@ -123,12 +132,12 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c
if err != nil {
ctx.Error(err, messageOperationFailed)
return
return nil, err
}
if finishBody.Token == "" {
ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
return
return nil, err
}
token, err := jwt.ParseWithClaims(finishBody.Token, &model.IdentityVerificationClaim{},
@ -141,19 +150,35 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c
switch {
case ve.Errors&jwt.ValidationErrorMalformed != 0:
ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed)
return
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
return nil, err
default:
ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed)
return
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) {
if shouldSkipIdentityVerification(args.IdentityVerificationCommonArgs, ctx) {
next(ctx, "")
return
}
token, err := identityVerificationValidateToken(ctx)
if token == nil || err != nil {
return
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,13 +52,17 @@ func NewSQLProvider(config *schema.Configuration, name, driverName, dataSourceNa
sqlSelectWebauthnDevices: fmt.Sprintf(queryFmtSelectWebauthnDevices, tableWebauthnDevices),
sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, 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)

View File

@ -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 = ?;`

View File

@ -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 = (
<IconWithContext
icon={<FingerTouchIcon size={64} animated strong />}
className={props.webauthnTouchState === WebauthnTouchState.WaitTouch ? undefined : "hidden"}
>
<LinearProgressBar value={timerPercent} className={styles.progressBar} height={theme.spacing(2)} />
</IconWithContext>
);
const failure = (
<IconWithContext
icon={<FailureIcon />}
className={props.webauthnTouchState === WebauthnTouchState.Failure ? undefined : "hidden"}
>
<Button color="secondary" onClick={handleRetryClick}>
Retry
</Button>
</IconWithContext>
);
return (
<Box className={styles.icon} sx={{ minHeight: 101 }} align="middle">
{touch}
{failure}
</Box>
);
}

View File

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

View File

@ -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) {
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar variant="dense">
<Typography style={{ flexGrow: 1 }}>{translate("Settings")}</Typography>
<Button
variant="contained"
color="success"
onClick={() => {
navigate(IndexRoute);
}}
>
{"Close"}
</Button>
</Toolbar>
</AppBar>
<Drawer

View File

@ -103,6 +103,11 @@ export interface AttestationPublicKeyCredentialResultJSON {
result: AttestationResult;
}
export interface AttestationFinishResult {
result: AttestationResult;
message: string;
}
export enum AssertionResult {
Success = 1,
Failure,
@ -113,6 +118,7 @@ export enum AssertionResult {
FailureUnknownSecurity,
FailureWebauthnNotSupported,
FailureChallenge,
FailureUnrecognized,
}
export interface DiscoverableAssertionResult {
@ -144,3 +150,9 @@ export interface WebauthnDevice {
sign_count: number;
clone_warning: boolean;
}
export enum WebauthnTouchState {
WaitTouch = 1,
InProgress = 2,
Failure = 3,
}

View File

@ -17,6 +17,8 @@ export const WebauthnAttestationPath = basePath + "/api/secondfactor/webauthn/at
export const WebauthnAssertionPath = basePath + "/api/secondfactor/webauthn/assertion";
export const WebauthnDevicesPath = basePath + "/api/secondfactor/webauthn/devices";
export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices";
export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device";
@ -36,8 +38,6 @@ export const UserInfoPath = basePath + "/api/user/info";
export const UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method";
export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp";
export const WebauthnDevicesPath = basePath + "/api/webauthn/devices";
export const ConfigurationPath = basePath + "/api/configuration";
export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy";

View File

@ -1,8 +1,9 @@
import axios, { AxiosResponse } from "axios";
import axios, { AxiosError, AxiosResponse } from "axios";
import {
AssertionPublicKeyCredentialResult,
AssertionResult,
AttestationFinishResult,
AttestationPublicKeyCredential,
AttestationPublicKeyCredentialJSON,
AttestationPublicKeyCredentialResult,
@ -22,6 +23,7 @@ import {
ServiceResponse,
WebauthnAssertionPath,
WebauthnAttestationPath,
WebauthnDevicesPath,
WebauthnIdentityFinishPath,
} from "@services/Api";
import { SignInResponse } from "@services/SignIn";
@ -174,6 +176,7 @@ function getAttestationResultFromDOMException(exception: DOMException): Attestat
case "InvalidStateError":
// § 6.3.2 Step 3.
return AttestationResult.FailureExcluded;
case "AbortError":
case "NotAllowedError":
// § 6.3.2 Step 3 and Step 6.
return AttestationResult.FailureUserConsent;
@ -196,6 +199,10 @@ function getAssertionResultFromDOMException(
case "UnknownError":
// § 6.3.3 Step 1 and Step 12.
return AssertionResult.FailureSyntax;
case "InvalidStateError":
// § 6.3.2 Step 3.
return AssertionResult.FailureUnrecognized;
case "AbortError":
case "NotAllowedError":
// § 6.3.3 Step 6 and Step 7.
return AssertionResult.FailureUserConsent;
@ -212,7 +219,9 @@ function getAssertionResultFromDOMException(
}
}
async function getAttestationCreationOptions(token: string): Promise<PublicKeyCredentialCreationOptionsStatus> {
export async function getAttestationCreationOptions(
token: null | string,
): Promise<PublicKeyCredentialCreationOptionsStatus> {
let response: AxiosResponse<ServiceResponse<CredentialCreation>>;
response = await axios.post<ServiceResponse<CredentialCreation>>(WebauthnIdentityFinishPath, {
@ -248,7 +257,7 @@ export async function getAssertionRequestOptions(): Promise<PublicKeyCredentialR
};
}
async function getAttestationPublicKeyCredentialResult(
export async function getAttestationPublicKeyCredentialResult(
creationOptions: PublicKeyCredentialCreationOptions,
): Promise<AttestationPublicKeyCredentialResult> {
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<ServiceResponse<SignInResponse>>(WebauthnAssertionPath, credentialJSON);
}
export async function performAttestationCeremony(token: string, description: string): Promise<AttestationResult> {
const attestationCreationOpts = await getAttestationCreationOptions(token);
if (attestationCreationOpts.status !== 200 || attestationCreationOpts.options == null) {
if (attestationCreationOpts.status === 403) {
return AttestationResult.FailureToken;
}
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);
export async function finishAttestationCeremony(
credential: AttestationPublicKeyCredential,
description: string,
): Promise<AttestationResult> {
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 AttestationResult.Success;
return {
status: AttestationResult.Success,
} as AttestationFinishResult;
}
return AttestationResult.Failure;
} catch (error) {
if (error instanceof AxiosError) {
result.message = error.response.data.message;
}
}
return result;
}
export async function performAssertionCeremony(
@ -394,3 +396,8 @@ export async function performAssertionCeremony(
return AssertionResult.Failure;
}
export async function deleteDevice(deviceID: number): Promise<number> {
let response = await axios.delete(`${WebauthnDevicesPath}/${deviceID}`);
return response.status;
}

View File

@ -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<HTMLInputElement>;
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;
}
try {
setRegistrationInProgress(true);
const result = await performAttestationCeremony(processToken, description);
setRegistrationInProgress(false);
switch (result) {
if (!deviceName.length) {
setNameError(true);
return;
}
const result = await finishAttestationCeremony(credential, deviceName);
switch (result.status) {
case AttestationResult.Success:
navigate(FirstFactorPath);
setActiveStep(2);
navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
break;
case AttestationResult.Failure:
createErrorNotification(result.message);
}
};
const startAttestation = useCallback(async () => {
try {
setState(WebauthnTouchState.WaitTouch);
setActiveStep(0);
const startResult = await getAttestationPublicKeyCredentialResult(creationOptions);
switch (startResult.result) {
case AttestationResult.Success:
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 (
<LoginLayout title="Touch Security Key">
<>
<div className={styles.icon}>
<FingerTouchIcon size={64} animated />
<WebauthnTryIcon onRetryClick={startAttestation} webauthnTouchState={state} />
</div>
<Typography className={styles.instruction}>Touch the token on your security key</Typography>
<Button color="primary" onClick={handleBackClick}>
Retry
</Button>
<Grid container align="center" spacing={1}>
<Grid item xs={12}>
<Stack direction="row" spacing={1} justifyContent="center">
<Button color="primary" onClick={handleBackClick}>
Cancel
</Button>
</Stack>
</Grid>
</Grid>
</>
);
case 1:
return (
<div id="webauthn-registration-name">
<div className={styles.icon}>
<InformationIcon />
</div>
<Typography className={styles.instruction}>Enter a name for this key</Typography>
<Grid container spacing={1}>
<Grid item xs={12}>
<FixedTextField
// TODO (PR: #806, Issue: #511) potentially refactor
inputRef={nameRef}
id="name-textfield"
label={translate("Name")}
variant="outlined"
required
value={deviceName}
error={nameError}
fullWidth
disabled={false}
onChange={(v) => 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();
}
}}
/>
</Grid>
<Grid item xs={12}>
<Stack direction="row" spacing={1} justifyContent="center">
<Button color="primary" variant="outlined" onClick={startAttestation}>
Back
</Button>
<Button color="primary" variant="contained" onClick={finishAttestation}>
Finish
</Button>
</Stack>
</Grid>
</Grid>
</div>
);
case 2:
return (
<div id="webauthn-registration-success">
<div className={styles.iconContainer}>
<SuccessIcon />
</div>
<Typography>{translate("Registration success")}</Typography>
</div>
);
}
}
return (
<LoginLayout title="Register Security Key">
<Grid container>
<Grid item xs={12} className={styles.methodContainer}>
<Box sx={{ width: "100%" }}>
<Stepper activeStep={activeStep}>
{steps.map((label, index) => {
const stepProps: { completed?: boolean } = {};
const labelProps: {
optional?: React.ReactNode;
} = {};
return (
<Step key={label} {...stepProps}>
<StepLabel {...labelProps}>{label}</StepLabel>
</Step>
);
})}
</Stepper>
{renderStep(activeStep)}
</Box>
</Grid>
</Grid>
</LoginLayout>
);
};
@ -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),
},
}));

View File

@ -188,7 +188,7 @@ const LoginPortal = function (props: Props) {
}
/>
<Route
path={`${SecondFactorRoute}*`}
path={`${SecondFactorRoute}/*`}
element={
state && userInfo && configuration ? (
<SecondFactorForm

View File

@ -33,7 +33,7 @@ const DefaultMethodContainer = function (props: Props) {
const registerMessage = props.registered
? props.title === "Push Notification"
? ""
: translate("Lost your device?")
: translate("Manage devices")
: translate("Register device");
const selectMessage = translate("Select a Device");

View File

@ -6,9 +6,12 @@ import { useTranslation } from "react-i18next";
import { Route, Routes, useNavigate } from "react-router-dom";
import {
RegisterOneTimePasswordRoute,
SecondFactorPushSubRoute,
SecondFactorTOTPSubRoute,
SecondFactorWebauthnSubRoute,
SettingsRoute,
SettingsTwoFactorAuthenticationSubRoute,
LogoutRoute as SignOutRoute,
} from "@constants/Routes";
import { useNotifications } from "@hooks/NotificationsContext";
@ -16,7 +19,7 @@ import LoginLayout from "@layouts/LoginLayout";
import { Configuration } from "@models/Configuration";
import { SecondFactorMethod } from "@models/Methods";
import { UserInfo } from "@models/UserInfo";
import { initiateTOTPRegistrationProcess, initiateWebauthnRegistrationProcess } from "@services/RegisterDevice";
import { initiateTOTPRegistrationProcess } from "@services/RegisterDevice";
import { AuthenticationLevel } from "@services/State";
import { setPreferred2FAMethod } from "@services/UserInfo";
import { isWebauthnSupported } from "@services/Webauthn";
@ -48,8 +51,11 @@ const SecondFactorForm = function (props: Props) {
setWebauthnSupported(isWebauthnSupported());
}, [setWebauthnSupported]);
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => {
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>, redirectRoute: string) => {
return async () => {
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
navigate(redirectRoute);
} else {
if (registrationInProgress) {
return;
}
@ -62,6 +68,7 @@ const SecondFactorForm = function (props: Props) {
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}
/>

View File

@ -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}
>
<div className={styles.icon}>
<Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} />
</div>
<WebauthnTryIcon onRetryClick={doInitiateSignIn} webauthnTouchState={state} />
</MethodContainer>
);
};
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 = (
<IconWithContext
icon={<FingerTouchIcon size={64} animated strong />}
className={state === State.WaitTouch ? undefined : "hidden"}
>
<LinearProgressBar value={props.timer} className={styles.progressBar} height={theme.spacing(2)} />
</IconWithContext>
);
const failure = (
<IconWithContext icon={<FailureIcon />} className={state === State.Failure ? undefined : "hidden"}>
<Button color="secondary" onClick={props.onRetryClick}>
Retry
</Button>
</IconWithContext>
);
return (
<Fragment>
{touch}
{failure}
</Fragment>
);
}

View File

@ -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 (
<SettingsLayout>
<Routes>
<Route path={IndexRoute} element={<SettingsView />} />
<Route path={SettingsTwoFactorAuthenticationSubRoute} element={<TwoFactorAuthenticationView />} />
<Route
path={SettingsTwoFactorAuthenticationSubRoute}
element={<TwoFactorAuthenticationView state={state} />}
/>
</Routes>
</SettingsLayout>
);
};

View File

@ -1,16 +1,12 @@
import { Box, Typography } from "@mui/material";
import SettingsLayout from "@layouts/SettingsLayout";
export interface Props {}
const SettingsView = function (props: Props) {
return (
<SettingsLayout>
<Box>
<Typography>Placeholder</Typography>
</Box>
</SettingsLayout>
);
};

View File

@ -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 (
<Dialog {...props}>
<DialogTitle>{translate("Add new Security Key")}</DialogTitle>
<DialogContent>
<DialogContentText>{translate("Provide the details for the new security key")}.</DialogContentText>
<TextField
autoFocus
margin="dense"
id="description"
label={translate("Description")}
type="text"
fullWidth
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelClick}>{translate("Cancel")}</Button>
<Button onClick={handleAddClick}>{translate("Add")}</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -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<WebauthnDevice[] | undefined>();
const [addKeyOpen, setAddKeyOpen] = useState<boolean>(false);
const [webauthnShowDetails, setWebauthnShowDetails] = useState<number>(-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 (
<SettingsLayout titlePrefix="Two Factor Authentication">
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography>{translate("Manage your security keys")}</Typography>
</Grid>
<Grid item xs={12}>
<Stack spacing={1} direction="row">
<Button color="primary" variant="contained" onClick={handleAddKeyButtonClick}>
{translate("Add")}
</Button>
</Stack>
</Grid>
<Grid item xs={12}>
<Paper>
<Table>
<TableHead>
<TableRow>
<TableCell />
<TableCell>{translate("Name")}</TableCell>
<TableCell>{translate("Enabled")}</TableCell>
<TableCell align="center">{translate("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{webauthnDevices
? webauthnDevices.map((x, idx) => {
return (
<React.Fragment>
<TableRow
sx={{ "& > *": { borderBottom: "unset" } }}
key={x.kid.toString()}
>
<TableCell>
<Tooltip title={translate("Show Details")} placement="right">
<IconButton
aria-label="expand row"
size="small"
onClick={() => handleWebAuthnDetailsChange(idx)}
>
{webauthnShowDetails === idx ? (
<KeyboardArrowUpIcon />
) : (
<KeyboardArrowDownIcon />
)}
</IconButton>
</Tooltip>
</TableCell>
<TableCell component="th" scope="row">
{x.description}
</TableCell>
<TableCell>
<Switch defaultChecked={false} size="small" />
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={1}
alignItems="center"
justifyContent="center"
>
<Tooltip title={translate("Edit")} placement="bottom">
<IconButton aria-label="edit">
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={translate("Delete")} placement="bottom">
<IconButton aria-label="delete">
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
</TableCell>
</TableRow>
<TableRow>
<TableCell
style={{ paddingBottom: 0, paddingTop: 0 }}
colSpan={4}
>
<Collapse
in={webauthnShowDetails === idx}
timeout="auto"
unmountOnExit
>
<Grid container spacing={2} sx={{ mb: 3, margin: 1 }}>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Box sx={{ margin: 1 }}>
<Typography
variant="h6"
gutterBottom
component="div"
>
{translate("Details")}
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Divider variant="middle" />
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Typography>
{translate("Webauthn Credential Identifier", {
id: x.kid.toString(),
})}
</Typography>
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Typography>
Public Key: {x.public_key}
{translate("Webauthn Public Key", {
key: x.public_key.toString(),
})}
</Typography>
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Divider variant="middle" />
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>
{translate("Relying Party ID")}
</Typography>
<Typography>{x.rpid}</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>
{translate("Authenticator Attestation GUID")}
</Typography>
<Typography>{x.aaguid}</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>
{translate("Attestation Type")}
</Typography>
<Typography>{x.attestation_type}</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Transports")}</Typography>
<Typography>
{x.transports.length === 0
? "N/A"
: x.transports.join(", ")}
</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>
{translate("Clone Warning")}
</Typography>
<Typography>
{x.clone_warning
? translate("Yes")
: translate("No")}
</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Created")}</Typography>
<Typography>{x.created_at.toString()}</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Last Used")}</Typography>
<Typography>
{x.last_used_at === undefined
? translate("Never")
: x.last_used_at.toString()}
</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>
{translate("Usage Count")}
</Typography>
<Typography>
{x.sign_count === 0
? translate("Never")
: x.sign_count}
</Typography>
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Divider variant="middle" />
<WebauthnDevices state={props.state} />
</Grid>
</Grid>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})
: null}
</TableBody>
</Table>
</Paper>
</Grid>
</Grid>
<AddSecurityKeyDialog open={addKeyOpen} onClose={handleKeyClose} />
</SettingsLayout>
);
};
export default TwoFactorAuthenticationView;
}

View File

@ -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 (
<React.Fragment>
<TableRow sx={{ "& > *": { borderBottom: "unset" } }} key={props.device.kid.toString()}>
<TableCell>
<Tooltip title={translate("Show Details")} placement="right">
<IconButton
aria-label="expand row"
size="small"
onClick={() => props.handleWebAuthnDetailsChange(props.idx)}
>
{props.webauthnShowDetails === props.idx ? (
<KeyboardArrowUpIcon />
) : (
<KeyboardArrowDownIcon />
)}
</IconButton>
</Tooltip>
</TableCell>
<TableCell component="th" scope="row">
{props.device.description}
</TableCell>
<TableCell>
<Switch defaultChecked={false} size="small" />
</TableCell>
<TableCell align="center">
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
<Tooltip title={translate("Edit")} placement="bottom">
<IconButton aria-label="edit">
<EditIcon />
</IconButton>
</Tooltip>
{deleting ? (
<CircularProgress color="inherit" size={24} />
) : (
<Tooltip title={translate("Delete")} placement="bottom">
<IconButton aria-label="delete" onClick={handleDelete}>
<DeleteIcon />
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={4}>
<Collapse in={props.webauthnShowDetails === props.idx} timeout="auto" unmountOnExit>
<Grid container spacing={2} sx={{ mb: 3, margin: 1 }}>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Box sx={{ margin: 1 }}>
<Typography variant="h6" gutterBottom component="div">
{translate("Details")}
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Divider variant="middle" />
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Typography>
{translate("Webauthn Credential Identifier", {
id: props.device.kid.toString(),
})}
</Typography>
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Typography>
Public Key: {props.device.public_key}
{translate("Webauthn Public Key", {
key: props.device.public_key.toString(),
})}
</Typography>
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Divider variant="middle" />
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Relying Party ID")}</Typography>
<Typography>{props.device.rpid}</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Authenticator Attestation GUID")}</Typography>
<Typography>{props.device.aaguid}</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Attestation Type")}</Typography>
<Typography>{props.device.attestation_type}</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Transports")}</Typography>
<Typography>
{props.device.transports.length === 0 ? "N/A" : props.device.transports.join(", ")}
</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Clone Warning")}</Typography>
<Typography>
{props.device.clone_warning ? translate("Yes") : translate("No")}
</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Created")}</Typography>
<Typography>{props.device.created_at.toString()}</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Last Used")}</Typography>
<Typography>
{props.device.last_used_at === undefined
? translate("Never")
: props.device.last_used_at.toString()}
</Typography>
</Grid>
<Grid item xs={6} sm={6} md={4} lg={4} xl={3}>
<Typography>{translate("Usage Count")}</Typography>
<Typography>
{props.device.sign_count === 0 ? translate("Never") : props.device.sign_count}
</Typography>
</Grid>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Divider variant="middle" />
</Grid>
</Grid>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}

View File

@ -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<number>(-1);
const [registrationInProgress, setRegistrationInProgress] = useState(false);
const [webauthnDevices, setWebauthnDevices] = useState<WebauthnDevice[] | undefined>();
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<void>, 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 (
<Paper variant="outlined">
<Box sx={{ p: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="h5">Webauthn Devices</Typography>
</Box>
<Box>
<Button variant="outlined" color="primary" onClick={handleAddKeyButtonClick}>
{"Add new device"}
</Button>
</Box>
<Box>
<Table>
<TableHead>
<TableRow>
<TableCell />
<TableCell>{translate("Name")}</TableCell>
<TableCell>{translate("Enabled")}</TableCell>
<TableCell align="center">{translate("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{webauthnDevices
? webauthnDevices.map((x, idx) => {
return (
<WebauthnDeviceItem
device={x}
idx={idx}
webauthnShowDetails={webauthnShowDetails}
handleWebAuthnDetailsChange={handleWebAuthnDetailsChange}
handleDeleteItem={handleDeleteItem}
key={`webauthn-device-${idx}`}
/>
);
})
: null}
</TableBody>
</Table>
</Box>
</Stack>
</Box>
</Paper>
);
}

View File

@ -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()],
};
});