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' $ref: '#/components/schemas/middlewares.OkResponse'
security: security:
- authelia_auth: [] - 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: /api/secondfactor/duo:
post: post:
tags: tags:
@ -633,6 +672,13 @@ paths:
- authelia_auth: [] - authelia_auth: []
components: components:
parameters: parameters:
deviceID:
in: path
name: deviceID
schema:
type: integer
required: true
description: Numeric Webauthn Device ID
originalURLParam: originalURLParam:
name: X-Original-URL name: X-Original-URL
in: header in: header
@ -1078,6 +1124,11 @@ components:
workflowID: workflowID:
type: string type: string
example: 3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c example: 3ebcfbc5-b0fd-4ee0-9d3c-080ae1e7298c
webauthn.DeviceUpdateRequest:
type: object
properties:
description:
type: string
webauthn.PublicKeyCredentialCreationOptions: webauthn.PublicKeyCredentialCreationOptions:
type: object type: object
properties: properties:

View File

@ -55,6 +55,7 @@ const (
messageAuthenticationFailed = "Authentication failed. Check your credentials." messageAuthenticationFailed = "Authentication failed. Check your credentials."
messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec
messageUnableToRegisterSecurityKey = "Unable to register your security key." 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." messageUnableToResetPassword = "Unable to reset your password."
messageMFAValidationFailed = "Authentication failed, please retry later." messageMFAValidationFailed = "Authentication failed, please retry later."
messagePasswordWeak = "Your supplied password does not meet the password policy requirements" 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/middlewares"
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/storage"
) )
// WebauthnIdentityStart the handler for initiating the identity validation. // 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", MailTitle: "Register your key",
MailButtonContent: "Register", MailButtonContent: "Register",
TargetEndpoint: "/webauthn/register", TargetEndpoint: "/webauthn/register",
ActionClaim: ActionWebauthnRegistration, ActionClaim: ActionWebauthnRegistration,
IdentityRetrieverFunc: identityRetrieverFromSession, IdentityRetrieverFunc: identityRetrieverFromSession,
}, nil) }, nil)
// WebauthnIdentityFinish the handler for finishing the identity validation. // WebauthnIdentityFinish the handler for finishing the identity validation.
var WebauthnIdentityFinish = middlewares.IdentityVerificationFinish( var WebauthnIdentityFinish = middlewares.IdentityVerificationFinish(
middlewares.IdentityVerificationFinishArgs{ middlewares.IdentityVerificationFinishArgs{
IdentityVerificationCommonArgs: middlewares.IdentityVerificationCommonArgs{
SkipIfAuthLevelTwoFactor: true,
},
ActionClaim: ActionWebauthnRegistration, ActionClaim: ActionWebauthnRegistration,
IsTokenUserValidFunc: isTokenUserValidFor2FARegistration, IsTokenUserValidFunc: isTokenUserValidFor2FARegistration,
}, SecondFactorWebauthnAttestationGET) }, SecondFactorWebauthnAttestationGET)
@ -57,7 +65,7 @@ func SecondFactorWebauthnAttestationGET(ctx *middlewares.AutheliaCtx, _ string)
var credentialCreation *protocol.CredentialCreation 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) ctx.Logger.Errorf("Unable to create %s attestation challenge for user '%s': %+v", regulation.AuthTypeWebauthn, userSession.Username, err)
respondUnauthorized(ctx, messageUnableToRegisterSecurityKey) respondUnauthorized(ctx, messageUnableToRegisterSecurityKey)
@ -150,6 +158,27 @@ func WebauthnAttestationPOST(ctx *middlewares.AutheliaCtx) {
return 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) device := model.NewWebauthnDeviceFromCredential(w.Config.RPID, userSession.Username, postData.Description, credential)
if err = ctx.Providers.StorageProvider.SaveWebauthnDevice(ctx, device); err != nil { if err = ctx.Providers.StorageProvider.SaveWebauthnDevice(ctx, device); err != nil {

View File

@ -1,9 +1,35 @@
package handlers package handlers
import ( import (
"encoding/json"
"errors"
"strconv"
"github.com/valyala/fasthttp"
"github.com/authelia/authelia/v4/internal/middlewares" "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) { func WebauthnDevicesGet(ctx *middlewares.AutheliaCtx) {
userSession := ctx.GetSession() userSession := ctx.GetSession()
@ -19,3 +45,49 @@ func WebauthnDevicesGet(ctx *middlewares.AutheliaCtx) {
return 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/golang-jwt/jwt/v4"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/authelia/authelia/v4/internal/authentication"
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/templates" "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. // IdentityVerificationStart the handler for initiating the identity validation process.
func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc TimingAttackDelayFunc) RequestHandler { func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc TimingAttackDelayFunc) RequestHandler {
if args.IdentityRetrieverFunc == nil { if args.IdentityRetrieverFunc == nil {
@ -21,6 +27,11 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
} }
return func(ctx *AutheliaCtx) { return func(ctx *AutheliaCtx) {
if shouldSkipIdentityVerification(args.IdentityVerificationCommonArgs, ctx) {
ctx.ReplyOK()
return
}
requestTime := time.Now() requestTime := time.Now()
success := false success := false
@ -112,9 +123,7 @@ func IdentityVerificationStart(args IdentityVerificationStartArgs, delayFunc Tim
} }
} }
// IdentityVerificationFinish the middleware for finishing the identity validation process. func identityVerificationValidateToken(ctx *AutheliaCtx) (*jwt.Token, error) {
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
return func(ctx *AutheliaCtx) {
var finishBody IdentityVerificationFinishBody var finishBody IdentityVerificationFinishBody
b := ctx.PostBody() b := ctx.PostBody()
@ -123,12 +132,12 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c
if err != nil { if err != nil {
ctx.Error(err, messageOperationFailed) ctx.Error(err, messageOperationFailed)
return return nil, err
} }
if finishBody.Token == "" { if finishBody.Token == "" {
ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed) ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
return return nil, err
} }
token, err := jwt.ParseWithClaims(finishBody.Token, &model.IdentityVerificationClaim{}, token, err := jwt.ParseWithClaims(finishBody.Token, &model.IdentityVerificationClaim{},
@ -141,19 +150,35 @@ func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(c
switch { switch {
case ve.Errors&jwt.ValidationErrorMalformed != 0: case ve.Errors&jwt.ValidationErrorMalformed != 0:
ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed) ctx.Error(fmt.Errorf("Cannot parse token"), messageOperationFailed)
return return nil, err
case ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0: case ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0:
// Token is either expired or not active yet. // Token is either expired or not active yet.
ctx.Error(fmt.Errorf("Token expired"), messageIdentityVerificationTokenHasExpired) ctx.Error(fmt.Errorf("Token expired"), messageIdentityVerificationTokenHasExpired)
return return nil, err
default: default:
ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed) ctx.Error(fmt.Errorf("Cannot handle this token: %s", ve), messageOperationFailed)
return return nil, err
} }
} }
ctx.Error(err, messageOperationFailed) 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 return
} }

View File

@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "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/middlewares"
"github.com/authelia/authelia/v4/internal/mocks" "github.com/authelia/authelia/v4/internal/mocks"
"github.com/authelia/authelia/v4/internal/model" "github.com/authelia/authelia/v4/internal/model"
@ -37,6 +38,38 @@ func defaultRetriever(ctx *middlewares.AutheliaCtx) (*session.Identity, error) {
}, nil }, 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) { func TestShouldFailStartingProcessIfUserHasNoEmailAddress(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t) mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close() defer mock.Close()
@ -292,6 +325,36 @@ func (s *IdentityVerificationFinishProcess) TestShouldReturn200OnFinishComplete(
assert.Equal(s.T(), 200, s.mock.Ctx.Response.StatusCode()) 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) { func TestRunIdentityVerificationFinish(t *testing.T) {
s := new(IdentityVerificationFinishProcess) s := new(IdentityVerificationFinishProcess)
suite.Run(t, s) 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. // Basic represents a middleware applied to a fasthttp.RequestHandler.
type Basic func(next fasthttp.RequestHandler) (handler 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 // IdentityVerificationStartArgs represent the arguments used to customize the starting phase
// of the identity verification process. // of the identity verification process.
type IdentityVerificationStartArgs struct { type IdentityVerificationStartArgs struct {
IdentityVerificationCommonArgs
// Email template needs a subject, a title and the content of the button. // Email template needs a subject, a title and the content of the button.
MailTitle string MailTitle string
MailButtonContent string MailButtonContent string
@ -94,6 +102,8 @@ type IdentityVerificationStartArgs struct {
// IdentityVerificationFinishArgs represent the arguments used to customize the finishing phase // IdentityVerificationFinishArgs represent the arguments used to customize the finishing phase
// of the identity verification process. // of the identity verification process.
type IdentityVerificationFinishArgs struct { type IdentityVerificationFinishArgs struct {
IdentityVerificationCommonArgs
// The action claim that should be in the token to consider the action legitimate. // The action claim that should be in the token to consider the action legitimate.
ActionClaim string ActionClaim string

View File

@ -10,11 +10,10 @@ import (
reflect "reflect" reflect "reflect"
time "time" time "time"
gomock "github.com/golang/mock/gomock"
uuid "github.com/google/uuid"
model "github.com/authelia/authelia/v4/internal/model" model "github.com/authelia/authelia/v4/internal/model"
storage "github.com/authelia/authelia/v4/internal/storage" 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. // 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) 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. // FindIdentityVerification mocks base method.
func (m *MockStorage) FindIdentityVerification(arg0 context.Context, arg1 string) (bool, error) { func (m *MockStorage) FindIdentityVerification(arg0 context.Context, arg1 string) (bool, error) {
m.ctrl.T.Helper() 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) 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. // UpdateWebauthnDeviceSignIn mocks base method.
func (m *MockStorage) UpdateWebauthnDeviceSignIn(arg0 context.Context, arg1 int, arg2 string, arg3 sql.NullTime, arg4 uint32, arg5 bool) error { func (m *MockStorage) UpdateWebauthnDeviceSignIn(arg0 context.Context, arg1 int, arg2 string, arg3 sql.NullTime, arg4 uint32, arg5 bool) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()

View File

@ -155,6 +155,11 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)
WithPostMiddlewares(middlewares.Require1FA). WithPostMiddlewares(middlewares.Require1FA).
Build() 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/health", middlewareAPI(handlers.HealthGET))
r.GET("/api/state", middlewareAPI(handlers.StateGET)) 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)) r.POST("/api/secondfactor/webauthn/assertion", middleware1FA(handlers.WebauthnAssertionPOST))
// Management of the webauthn devices. // 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. // 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) LoadTOTPConfigurations(ctx context.Context, limit, page int) (configs []model.TOTPConfiguration, err error)
SaveWebauthnDevice(ctx context.Context, device model.WebauthnDevice) (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) 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) DeleteWebauthnDevice(ctx context.Context, kid string) (err error)
DeleteWebauthnDeviceByUsername(ctx context.Context, username, description 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) LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []model.WebauthnDevice, err error)
LoadWebauthnDevicesByUsername(ctx context.Context, username string) (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), sqlSelectWebauthnDevices: fmt.Sprintf(queryFmtSelectWebauthnDevices, tableWebauthnDevices),
sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices), sqlSelectWebauthnDevicesByUsername: fmt.Sprintf(queryFmtSelectWebauthnDevicesByUsername, tableWebauthnDevices),
sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID, tableWebauthnDevices),
sqlUpdateWebauthnDevicePublicKey: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices), sqlUpdateWebauthnDevicePublicKey: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices),
sqlUpdateWebauthnDevicePublicKeyByUsername: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDevicePublicKeyByUsername, tableWebauthnDevices), sqlUpdateWebauthnDevicePublicKeyByUsername: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDevicePublicKeyByUsername, tableWebauthnDevices),
sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices), sqlUpdateWebauthnDeviceRecordSignIn: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignIn, tableWebauthnDevices),
sqlUpdateWebauthnDeviceRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignInByUsername, tableWebauthnDevices), sqlUpdateWebauthnDeviceRecordSignInByUsername: fmt.Sprintf(queryFmtUpdateWebauthnDeviceRecordSignInByUsername, tableWebauthnDevices),
sqlDeleteWebauthnDevice: fmt.Sprintf(queryFmtDeleteWebauthnDevice, tableWebauthnDevices), sqlDeleteWebauthnDevice: fmt.Sprintf(queryFmtDeleteWebauthnDevice, tableWebauthnDevices),
sqlDeleteWebauthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsername, tableWebauthnDevices), sqlDeleteWebauthnDeviceByUsername: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsername, tableWebauthnDevices),
sqlDeleteWebauthnDeviceByUsernameAndID: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndID, tableWebauthnDevices),
sqlDeleteWebauthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDescription, tableWebauthnDevices), sqlDeleteWebauthnDeviceByUsernameAndDescription: fmt.Sprintf(queryFmtDeleteWebauthnDeviceByUsernameAndDescription, tableWebauthnDevices),
sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices), sqlUpsertDuoDevice: fmt.Sprintf(queryFmtUpsertDuoDevice, tableDuoDevices),
@ -171,6 +175,8 @@ type SQLProvider struct {
sqlSelectWebauthnDevices string sqlSelectWebauthnDevices string
sqlSelectWebauthnDevicesByUsername string sqlSelectWebauthnDevicesByUsername string
sqlUpdateWebauthnDeviceDescriptionByUsernameAndID string
sqlUpdateWebauthnDevicePublicKey string sqlUpdateWebauthnDevicePublicKey string
sqlUpdateWebauthnDevicePublicKeyByUsername string sqlUpdateWebauthnDevicePublicKeyByUsername string
sqlUpdateWebauthnDeviceRecordSignIn string sqlUpdateWebauthnDeviceRecordSignIn string
@ -178,6 +184,7 @@ type SQLProvider struct {
sqlDeleteWebauthnDevice string sqlDeleteWebauthnDevice string
sqlDeleteWebauthnDeviceByUsername string sqlDeleteWebauthnDeviceByUsername string
sqlDeleteWebauthnDeviceByUsernameAndID string
sqlDeleteWebauthnDeviceByUsernameAndDescription string sqlDeleteWebauthnDeviceByUsernameAndDescription string
// Table: duo_devices. // Table: duo_devices.
@ -870,6 +877,15 @@ func (p *SQLProvider) SaveWebauthnDevice(ctx context.Context, device model.Webau
return nil 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. // 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) { 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 { 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 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. // LoadWebauthnDevices loads Webauthn device registrations.
func (p *SQLProvider) LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []model.WebauthnDevice, err error) { func (p *SQLProvider) LoadWebauthnDevices(ctx context.Context, limit, page int) (devices []model.WebauthnDevice, err error) {
devices = make([]model.WebauthnDevice, 0, limit) devices = make([]model.WebauthnDevice, 0, limit)

View File

@ -142,6 +142,11 @@ const (
SET public_key = ? SET public_key = ?
WHERE username = ? AND kid = ?;` WHERE username = ? AND kid = ?;`
queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID = `
UPDATE %s
SET description = ?
WHERE username = ? AND id = ?;`
queryFmtUpdateWebauthnDeviceRecordSignIn = ` queryFmtUpdateWebauthnDeviceRecordSignIn = `
UPDATE %s UPDATE %s
SET SET
@ -174,6 +179,10 @@ const (
DELETE FROM %s DELETE FROM %s
WHERE username = ?;` WHERE username = ?;`
queryFmtDeleteWebauthnDeviceByUsernameAndID = `
DELETE FROM %s
WHERE username = ? AND id = ?;`
queryFmtDeleteWebauthnDeviceByUsernameAndDescription = ` queryFmtDeleteWebauthnDeviceByUsernameAndDescription = `
DELETE FROM %s DELETE FROM %s
WHERE username = ? AND description = ?;` 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 AuthenticatedRoute: string = "/authenticated";
export const ConsentRoute: string = "/consent"; export const ConsentRoute: string = "/consent";
export const SecondFactorRoute: string = "/2fa/"; export const SecondFactorRoute: string = "/2fa";
export const SecondFactorWebauthnSubRoute: string = "webauthn"; export const SecondFactorWebauthnSubRoute: string = "/webauthn";
export const SecondFactorTOTPSubRoute: string = "one-time-password"; export const SecondFactorTOTPSubRoute: string = "/one-time-password";
export const SecondFactorPushSubRoute: string = "push-notification"; export const SecondFactorPushSubRoute: string = "/push-notification";
export const ResetPasswordStep1Route: string = "/reset-password/step1"; export const ResetPasswordStep1Route: string = "/reset-password/step1";
export const ResetPasswordStep2Route: string = "/reset-password/step2"; 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 { Dashboard } from "@mui/icons-material";
import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood"; import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood";
import { import {
AppBar, AppBar,
Box, Box,
Button,
Drawer, Drawer,
Grid, Grid,
List, List,
@ -17,7 +18,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; import { IndexRoute, SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
import { useRouterNavigate } from "@hooks/RouterNavigate"; import { useRouterNavigate } from "@hooks/RouterNavigate";
export interface Props { export interface Props {
@ -32,6 +33,7 @@ const defaultDrawerWidth = 240;
const SettingsLayout = function (props: Props) { const SettingsLayout = function (props: Props) {
const { t: translate } = useTranslation("settings"); const { t: translate } = useTranslation("settings");
const navigate = useRouterNavigate();
useEffect(() => { useEffect(() => {
if (props.title) { if (props.title) {
@ -56,6 +58,15 @@ const SettingsLayout = function (props: Props) {
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}> <AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar variant="dense"> <Toolbar variant="dense">
<Typography style={{ flexGrow: 1 }}>{translate("Settings")}</Typography> <Typography style={{ flexGrow: 1 }}>{translate("Settings")}</Typography>
<Button
variant="contained"
color="success"
onClick={() => {
navigate(IndexRoute);
}}
>
{"Close"}
</Button>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer <Drawer

View File

@ -103,6 +103,11 @@ export interface AttestationPublicKeyCredentialResultJSON {
result: AttestationResult; result: AttestationResult;
} }
export interface AttestationFinishResult {
result: AttestationResult;
message: string;
}
export enum AssertionResult { export enum AssertionResult {
Success = 1, Success = 1,
Failure, Failure,
@ -113,6 +118,7 @@ export enum AssertionResult {
FailureUnknownSecurity, FailureUnknownSecurity,
FailureWebauthnNotSupported, FailureWebauthnNotSupported,
FailureChallenge, FailureChallenge,
FailureUnrecognized,
} }
export interface DiscoverableAssertionResult { export interface DiscoverableAssertionResult {
@ -144,3 +150,9 @@ export interface WebauthnDevice {
sign_count: number; sign_count: number;
clone_warning: boolean; 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 WebauthnAssertionPath = basePath + "/api/secondfactor/webauthn/assertion";
export const WebauthnDevicesPath = basePath + "/api/secondfactor/webauthn/devices";
export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices"; export const InitiateDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_devices";
export const CompleteDuoDeviceSelectionPath = basePath + "/api/secondfactor/duo_device"; 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 UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method";
export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp"; export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp";
export const WebauthnDevicesPath = basePath + "/api/webauthn/devices";
export const ConfigurationPath = basePath + "/api/configuration"; export const ConfigurationPath = basePath + "/api/configuration";
export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy"; 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 { import {
AssertionPublicKeyCredentialResult, AssertionPublicKeyCredentialResult,
AssertionResult, AssertionResult,
AttestationFinishResult,
AttestationPublicKeyCredential, AttestationPublicKeyCredential,
AttestationPublicKeyCredentialJSON, AttestationPublicKeyCredentialJSON,
AttestationPublicKeyCredentialResult, AttestationPublicKeyCredentialResult,
@ -22,6 +23,7 @@ import {
ServiceResponse, ServiceResponse,
WebauthnAssertionPath, WebauthnAssertionPath,
WebauthnAttestationPath, WebauthnAttestationPath,
WebauthnDevicesPath,
WebauthnIdentityFinishPath, WebauthnIdentityFinishPath,
} from "@services/Api"; } from "@services/Api";
import { SignInResponse } from "@services/SignIn"; import { SignInResponse } from "@services/SignIn";
@ -174,6 +176,7 @@ function getAttestationResultFromDOMException(exception: DOMException): Attestat
case "InvalidStateError": case "InvalidStateError":
// § 6.3.2 Step 3. // § 6.3.2 Step 3.
return AttestationResult.FailureExcluded; return AttestationResult.FailureExcluded;
case "AbortError":
case "NotAllowedError": case "NotAllowedError":
// § 6.3.2 Step 3 and Step 6. // § 6.3.2 Step 3 and Step 6.
return AttestationResult.FailureUserConsent; return AttestationResult.FailureUserConsent;
@ -196,6 +199,10 @@ function getAssertionResultFromDOMException(
case "UnknownError": case "UnknownError":
// § 6.3.3 Step 1 and Step 12. // § 6.3.3 Step 1 and Step 12.
return AssertionResult.FailureSyntax; return AssertionResult.FailureSyntax;
case "InvalidStateError":
// § 6.3.2 Step 3.
return AssertionResult.FailureUnrecognized;
case "AbortError":
case "NotAllowedError": case "NotAllowedError":
// § 6.3.3 Step 6 and Step 7. // § 6.3.3 Step 6 and Step 7.
return AssertionResult.FailureUserConsent; 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>>; let response: AxiosResponse<ServiceResponse<CredentialCreation>>;
response = await axios.post<ServiceResponse<CredentialCreation>>(WebauthnIdentityFinishPath, { 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, creationOptions: PublicKeyCredentialCreationOptions,
): Promise<AttestationPublicKeyCredentialResult> { ): Promise<AttestationPublicKeyCredentialResult> {
const result: AttestationPublicKeyCredentialResult = { const result: AttestationPublicKeyCredentialResult = {
@ -272,9 +281,7 @@ async function getAttestationPublicKeyCredentialResult(
} }
} }
if (result.credential == null) { if (result.credential != null) {
result.result = AttestationResult.Failure;
} else {
result.result = AttestationResult.Success; result.result = AttestationResult.Success;
} }
@ -334,32 +341,27 @@ export async function postAssertionPublicKeyCredentialResult(
return axios.post<ServiceResponse<SignInResponse>>(WebauthnAssertionPath, credentialJSON); return axios.post<ServiceResponse<SignInResponse>>(WebauthnAssertionPath, credentialJSON);
} }
export async function performAttestationCeremony(token: string, description: string): Promise<AttestationResult> { export async function finishAttestationCeremony(
const attestationCreationOpts = await getAttestationCreationOptions(token); credential: AttestationPublicKeyCredential,
description: string,
if (attestationCreationOpts.status !== 200 || attestationCreationOpts.options == null) { ): Promise<AttestationResult> {
if (attestationCreationOpts.status === 403) { let result = {
return AttestationResult.FailureToken; status: AttestationResult.Failure,
} message: "Device registration failed.",
} as AttestationResult;
return AttestationResult.Failure; try {
} const response = await postAttestationPublicKeyCredentialResult(credential, description);
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)) { if (response.data.status === "OK" && (response.status === 200 || response.status === 201)) {
return AttestationResult.Success; return {
status: AttestationResult.Success,
} as AttestationFinishResult;
} }
} catch (error) {
return AttestationResult.Failure; if (error instanceof AxiosError) {
result.message = error.response.data.message;
}
}
return result;
} }
export async function performAssertionCeremony( export async function performAssertionCeremony(
@ -394,3 +396,8 @@ export async function performAssertionCeremony(
return AssertionResult.Failure; 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 makeStyles from "@mui/styles/makeStyles";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom"; 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 { useNotifications } from "@hooks/NotificationsContext";
import LoginLayout from "@layouts/LoginLayout"; import LoginLayout from "@layouts/LoginLayout";
import { AttestationResult } from "@models/Webauthn"; import { AttestationPublicKeyCredential, AttestationResult, WebauthnTouchState } from "@models/Webauthn";
import { FirstFactorPath } from "@services/Api"; import {
import { performAttestationCeremony } from "@services/Webauthn"; finishAttestationCeremony,
getAttestationCreationOptions,
getAttestationPublicKeyCredentialResult,
} from "@services/Webauthn";
import { extractIdentityToken } from "@utils/IdentityToken"; 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 styles = useStyles();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { t: translate } = useTranslation();
const { createErrorNotification } = useNotifications(); 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 processToken = extractIdentityToken(location.search);
const handleBackClick = () => { const handleBackClick = () => {
navigate(FirstFactorPath); navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
}; };
const attestation = useCallback(async () => { const finishAttestation = async () => {
if (!processToken) { if (!credential) {
return; return;
} }
try { if (!deviceName.length) {
setRegistrationInProgress(true); setNameError(true);
return;
const result = await performAttestationCeremony(processToken, description); }
const result = await finishAttestationCeremony(credential, deviceName);
setRegistrationInProgress(false); switch (result.status) {
switch (result) {
case AttestationResult.Success: case AttestationResult.Success:
navigate(FirstFactorPath); setActiveStep(2);
navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
break; 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: case AttestationResult.FailureToken:
createErrorNotification( createErrorNotification(
"You must open the link from the same device and browser that initiated the registration process.", "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."); createErrorNotification("An unknown error occurred.");
break; break;
} }
setState(WebauthnTouchState.Failure);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createErrorNotification( createErrorNotification(
"Failed to register your device. The identity verification process might have timed out.", "Failed to register your device. The identity verification process might have timed out.",
); );
} }
}, [processToken, createErrorNotification, navigate]); }, [creationOptions, createErrorNotification]);
useEffect(() => { useEffect(() => {
attestation(); if (creationOptions !== null) {
}, [attestation]); 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 ( return (
<LoginLayout title="Touch Security Key"> <>
<div className={styles.icon}> <div className={styles.icon}>
<FingerTouchIcon size={64} animated /> <WebauthnTryIcon onRetryClick={startAttestation} webauthnTouchState={state} />
</div> </div>
<Typography className={styles.instruction}>Touch the token on your security key</Typography> <Typography className={styles.instruction}>Touch the token on your security key</Typography>
<Button color="primary" onClick={handleBackClick}> <Grid container align="center" spacing={1}>
Retry <Grid item xs={12}>
</Button> <Stack direction="row" spacing={1} justifyContent="center">
<Button color="primary" onClick={handleBackClick}> <Button color="primary" onClick={handleBackClick}>
Cancel Cancel
</Button> </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> </LoginLayout>
); );
}; };
@ -108,7 +252,18 @@ const useStyles = makeStyles((theme: Theme) => ({
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4), paddingBottom: theme.spacing(4),
}, },
iconContainer: {
marginBottom: theme.spacing(2),
flex: "0 0 100%",
},
instruction: { instruction: {
paddingBottom: theme.spacing(4), 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 <Route
path={`${SecondFactorRoute}*`} path={`${SecondFactorRoute}/*`}
element={ element={
state && userInfo && configuration ? ( state && userInfo && configuration ? (
<SecondFactorForm <SecondFactorForm

View File

@ -33,7 +33,7 @@ const DefaultMethodContainer = function (props: Props) {
const registerMessage = props.registered const registerMessage = props.registered
? props.title === "Push Notification" ? props.title === "Push Notification"
? "" ? ""
: translate("Lost your device?") : translate("Manage devices")
: translate("Register device"); : translate("Register device");
const selectMessage = translate("Select a 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 { Route, Routes, useNavigate } from "react-router-dom";
import { import {
RegisterOneTimePasswordRoute,
SecondFactorPushSubRoute, SecondFactorPushSubRoute,
SecondFactorTOTPSubRoute, SecondFactorTOTPSubRoute,
SecondFactorWebauthnSubRoute, SecondFactorWebauthnSubRoute,
SettingsRoute,
SettingsTwoFactorAuthenticationSubRoute,
LogoutRoute as SignOutRoute, LogoutRoute as SignOutRoute,
} from "@constants/Routes"; } from "@constants/Routes";
import { useNotifications } from "@hooks/NotificationsContext"; import { useNotifications } from "@hooks/NotificationsContext";
@ -16,7 +19,7 @@ import LoginLayout from "@layouts/LoginLayout";
import { Configuration } from "@models/Configuration"; import { Configuration } from "@models/Configuration";
import { SecondFactorMethod } from "@models/Methods"; import { SecondFactorMethod } from "@models/Methods";
import { UserInfo } from "@models/UserInfo"; import { UserInfo } from "@models/UserInfo";
import { initiateTOTPRegistrationProcess, initiateWebauthnRegistrationProcess } from "@services/RegisterDevice"; import { initiateTOTPRegistrationProcess } from "@services/RegisterDevice";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import { setPreferred2FAMethod } from "@services/UserInfo"; import { setPreferred2FAMethod } from "@services/UserInfo";
import { isWebauthnSupported } from "@services/Webauthn"; import { isWebauthnSupported } from "@services/Webauthn";
@ -48,8 +51,11 @@ const SecondFactorForm = function (props: Props) {
setWebauthnSupported(isWebauthnSupported()); setWebauthnSupported(isWebauthnSupported());
}, [setWebauthnSupported]); }, [setWebauthnSupported]);
const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>) => { const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>, redirectRoute: string) => {
return async () => { return async () => {
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
navigate(redirectRoute);
} else {
if (registrationInProgress) { if (registrationInProgress) {
return; return;
} }
@ -62,6 +68,7 @@ const SecondFactorForm = function (props: Props) {
createErrorNotification(translate("There was a problem initiating the registration process")); createErrorNotification(translate("There was a problem initiating the registration process"));
} }
setRegistrationInProgress(false); setRegistrationInProgress(false);
}
}; };
}; };
@ -122,7 +129,10 @@ const SecondFactorForm = function (props: Props) {
authenticationLevel={props.authenticationLevel} authenticationLevel={props.authenticationLevel}
// Whether the user has a TOTP secret registered already // Whether the user has a TOTP secret registered already
registered={props.userInfo.has_totp} registered={props.userInfo.has_totp}
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)} onRegisterClick={initiateRegistration(
initiateTOTPRegistrationProcess,
RegisterOneTimePasswordRoute,
)}
onSignInError={(err) => createErrorNotification(err.message)} onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} onSignInSuccess={props.onAuthenticationSuccess}
/> />
@ -136,7 +146,9 @@ const SecondFactorForm = function (props: Props) {
authenticationLevel={props.authenticationLevel} authenticationLevel={props.authenticationLevel}
// Whether the user has a Webauthn device registered already // Whether the user has a Webauthn device registered already
registered={props.userInfo.has_webauthn} registered={props.userInfo.has_webauthn}
onRegisterClick={initiateRegistration(initiateWebauthnRegistrationProcess)} onRegisterClick={() => {
navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
}}
onSignInError={(err) => createErrorNotification(err.message)} onSignInError={(err) => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} 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 WebauthnTryIcon from "@components/WebauthnTryIcon";
import makeStyles from "@mui/styles/makeStyles";
import FailureIcon from "@components/FailureIcon";
import FingerTouchIcon from "@components/FingerTouchIcon";
import LinearProgressBar from "@components/LinearProgressBar";
import { useIsMountedRef } from "@hooks/Mounted"; import { useIsMountedRef } from "@hooks/Mounted";
import { useRedirectionURL } from "@hooks/RedirectionURL"; import { useRedirectionURL } from "@hooks/RedirectionURL";
import { useTimer } from "@hooks/Timer";
import { useWorkflow } from "@hooks/Workflow"; import { useWorkflow } from "@hooks/Workflow";
import { AssertionResult } from "@models/Webauthn"; import { AssertionResult, WebauthnTouchState } from "@models/Webauthn";
import { AuthenticationLevel } from "@services/State"; import { AuthenticationLevel } from "@services/State";
import { import {
getAssertionPublicKeyCredentialResult, getAssertionPublicKeyCredentialResult,
getAssertionRequestOptions, getAssertionRequestOptions,
postAssertionPublicKeyCredentialResult, postAssertionPublicKeyCredentialResult,
} from "@services/Webauthn"; } from "@services/Webauthn";
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer"; import MethodContainer, { State as MethodContainerState } from "@views/LoginPortal/SecondFactor/MethodContainer";
export enum State {
WaitTouch = 1,
InProgress = 2,
Failure = 3,
}
export interface Props { export interface Props {
id: string; id: string;
authenticationLevel: AuthenticationLevel; authenticationLevel: AuthenticationLevel;
@ -37,13 +24,10 @@ export interface Props {
} }
const WebauthnMethod = function (props: Props) { const WebauthnMethod = function (props: Props) {
const signInTimeout = 30; const [state, setState] = useState(WebauthnTouchState.WaitTouch);
const [state, setState] = useState(State.WaitTouch);
const styles = useStyles();
const redirectionURL = useRedirectionURL(); const redirectionURL = useRedirectionURL();
const [workflow, workflowID] = useWorkflow(); const [workflow, workflowID] = useWorkflow();
const mounted = useIsMountedRef(); const mounted = useIsMountedRef();
const [timerPercent, triggerTimer] = useTimer(signInTimeout * 1000 - 500);
const { onSignInSuccess, onSignInError } = props; const { onSignInSuccess, onSignInError } = props;
const onSignInErrorCallback = useRef(onSignInError).current; const onSignInErrorCallback = useRef(onSignInError).current;
@ -56,12 +40,11 @@ const WebauthnMethod = function (props: Props) {
} }
try { try {
triggerTimer(); setState(WebauthnTouchState.WaitTouch);
setState(State.WaitTouch);
const assertionRequestResponse = await getAssertionRequestOptions(); const assertionRequestResponse = await getAssertionRequestOptions();
if (assertionRequestResponse.status !== 200 || assertionRequestResponse.options == null) { if (assertionRequestResponse.status !== 200 || assertionRequestResponse.options == null) {
setState(State.Failure); setState(WebauthnTouchState.Failure);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process")); onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
return; return;
@ -88,6 +71,9 @@ const WebauthnMethod = function (props: Props) {
case AssertionResult.FailureWebauthnNotSupported: case AssertionResult.FailureWebauthnNotSupported:
onSignInErrorCallback(new Error("Your browser does not support the WebAuthN protocol.")); onSignInErrorCallback(new Error("Your browser does not support the WebAuthN protocol."));
break; break;
case AssertionResult.FailureUnrecognized:
onSignInErrorCallback(new Error("This device is not registered."));
break;
case AssertionResult.FailureUnknownSecurity: case AssertionResult.FailureUnknownSecurity:
onSignInErrorCallback(new Error("An unknown security error occurred.")); onSignInErrorCallback(new Error("An unknown security error occurred."));
break; break;
@ -98,21 +84,21 @@ const WebauthnMethod = function (props: Props) {
onSignInErrorCallback(new Error("An unexpected error occurred.")); onSignInErrorCallback(new Error("An unexpected error occurred."));
break; break;
} }
setState(State.Failure); setState(WebauthnTouchState.Failure);
return; return;
} }
if (result.credential == null) { if (result.credential == null) {
onSignInErrorCallback(new Error("The browser did not respond with the expected attestation data.")); onSignInErrorCallback(new Error("The browser did not respond with the expected attestation data."));
setState(State.Failure); setState(WebauthnTouchState.Failure);
return; return;
} }
if (!mounted.current) return; if (!mounted.current) return;
setState(State.InProgress); setState(WebauthnTouchState.InProgress);
const response = await postAssertionPublicKeyCredentialResult( const response = await postAssertionPublicKeyCredentialResult(
result.credential, result.credential,
@ -129,14 +115,14 @@ const WebauthnMethod = function (props: Props) {
if (!mounted.current) return; if (!mounted.current) return;
onSignInErrorCallback(new Error("The server rejected the security key.")); onSignInErrorCallback(new Error("The server rejected the security key."));
setState(State.Failure); setState(WebauthnTouchState.Failure);
} catch (err) { } catch (err) {
// If the request was initiated and the user changed 2FA method in the meantime, // 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. // the process is interrupted to avoid updating state of unmounted component.
if (!mounted.current) return; if (!mounted.current) return;
console.error(err); console.error(err);
onSignInErrorCallback(new Error("Failed to initiate security key sign in process")); onSignInErrorCallback(new Error("Failed to initiate security key sign in process"));
setState(State.Failure); setState(WebauthnTouchState.Failure);
} }
}, [ }, [
onSignInErrorCallback, onSignInErrorCallback,
@ -145,7 +131,6 @@ const WebauthnMethod = function (props: Props) {
workflow, workflow,
workflowID, workflowID,
mounted, mounted,
triggerTimer,
props.authenticationLevel, props.authenticationLevel,
props.registered, props.registered,
]); ]);
@ -171,59 +156,9 @@ const WebauthnMethod = function (props: Props) {
state={methodState} state={methodState}
onRegisterClick={props.onRegisterClick} onRegisterClick={props.onRegisterClick}
> >
<div className={styles.icon}> <WebauthnTryIcon onRetryClick={doInitiateSignIn} webauthnTouchState={state} />
<Icon state={state} timer={timerPercent} onRetryClick={doInitiateSignIn} />
</div>
</MethodContainer> </MethodContainer>
); );
}; };
export default WebauthnMethod; 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 { Route, Routes } from "react-router-dom";
import { IndexRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; 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 SettingsView from "@views/Settings/SettingsView";
import TwoFactorAuthenticationView from "@views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView"; import TwoFactorAuthenticationView from "@views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView";
export interface Props {} export interface Props {}
const SettingsRouter = function (props: 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 ( return (
<SettingsLayout>
<Routes> <Routes>
<Route path={IndexRoute} element={<SettingsView />} /> <Route path={IndexRoute} element={<SettingsView />} />
<Route path={SettingsTwoFactorAuthenticationSubRoute} element={<TwoFactorAuthenticationView />} /> <Route
path={SettingsTwoFactorAuthenticationSubRoute}
element={<TwoFactorAuthenticationView state={state} />}
/>
</Routes> </Routes>
</SettingsLayout>
); );
}; };

View File

@ -1,16 +1,12 @@
import { Box, Typography } from "@mui/material"; import { Box, Typography } from "@mui/material";
import SettingsLayout from "@layouts/SettingsLayout";
export interface Props {} export interface Props {}
const SettingsView = function (props: Props) { const SettingsView = function (props: Props) {
return ( return (
<SettingsLayout>
<Box> <Box>
<Typography>Placeholder</Typography> <Typography>Placeholder</Typography>
</Box> </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 { Grid } from "@mui/material";
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 SettingsLayout from "@layouts/SettingsLayout"; import { AutheliaState } from "@services/State";
import { WebauthnDevice } from "@root/models/Webauthn";
import { getWebauthnDevices } from "@root/services/UserWebauthnDevices";
import AddSecurityKeyDialog from "./AddSecurityDialog"; import WebauthnDevices from "./WebauthnDevices";
export interface Props {} interface Props {
state: AutheliaState;
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);
};
export default function TwoFactorAuthSettings(props: Props) {
return ( return (
<SettingsLayout titlePrefix="Two Factor Authentication">
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12}> <Grid item xs={12}>
<Typography>{translate("Manage your security keys")}</Typography> <WebauthnDevices state={props.state} />
</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>
</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, port: 3000,
open: false, open: false,
}, },
plugins: [ plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
,
/* eslintPlugin({ cache: false }) */ htmlPlugin(),
istanbulPlugin,
react(),
svgr(),
tsconfigPaths(),
],
}; };
}); });