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
parent
ff26673659
commit
2584e3d328
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = ?;`
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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;
|
||||
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 {
|
||||
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<number> {
|
||||
let response = await axios.delete(`${WebauthnDevicesPath}/${deviceID}`);
|
||||
return response.status;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<div className={styles.icon}>
|
||||
<WebauthnTryIcon onRetryClick={startAttestation} webauthnTouchState={state} />
|
||||
</div>
|
||||
<Typography className={styles.instruction}>Touch the token on your security key</Typography>
|
||||
<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="Touch Security Key">
|
||||
<div className={styles.icon}>
|
||||
<FingerTouchIcon size={64} animated />
|
||||
</div>
|
||||
<Typography className={styles.instruction}>Touch the token on your security key</Typography>
|
||||
<Button color="primary" onClick={handleBackClick}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button color="primary" onClick={handleBackClick}>
|
||||
Cancel
|
||||
</Button>
|
||||
<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),
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -188,7 +188,7 @@ const LoginPortal = function (props: Props) {
|
|||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${SecondFactorRoute}*`}
|
||||
path={`${SecondFactorRoute}/*`}
|
||||
element={
|
||||
state && userInfo && configuration ? (
|
||||
<SecondFactorForm
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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,20 +51,24 @@ const SecondFactorForm = function (props: Props) {
|
|||
setWebauthnSupported(isWebauthnSupported());
|
||||
}, [setWebauthnSupported]);
|
||||
|
||||
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => {
|
||||
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>, 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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<Routes>
|
||||
<Route path={IndexRoute} element={<SettingsView />} />
|
||||
<Route path={SettingsTwoFactorAuthenticationSubRoute} element={<TwoFactorAuthenticationView />} />
|
||||
</Routes>
|
||||
<SettingsLayout>
|
||||
<Routes>
|
||||
<Route path={IndexRoute} element={<SettingsView />} />
|
||||
<Route
|
||||
path={SettingsTwoFactorAuthenticationSubRoute}
|
||||
element={<TwoFactorAuthenticationView state={state} />}
|
||||
/>
|
||||
</Routes>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
<Box>
|
||||
<Typography>Placeholder</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<WebauthnDevices state={props.state} />
|
||||
</Grid>
|
||||
<AddSecurityKeyDialog open={addKeyOpen} onClose={handleKeyClose} />
|
||||
</SettingsLayout>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorAuthenticationView;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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()],
|
||||
};
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue