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'
|
$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:
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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(
|
||||||
MailTitle: "Register your key",
|
middlewares.IdentityVerificationStartArgs{
|
||||||
MailButtonContent: "Register",
|
IdentityVerificationCommonArgs: middlewares.IdentityVerificationCommonArgs{
|
||||||
TargetEndpoint: "/webauthn/register",
|
SkipIfAuthLevelTwoFactor: true,
|
||||||
ActionClaim: ActionWebauthnRegistration,
|
},
|
||||||
IdentityRetrieverFunc: identityRetrieverFromSession,
|
MailTitle: "Register your key",
|
||||||
}, nil)
|
MailButtonContent: "Register",
|
||||||
|
TargetEndpoint: "/webauthn/register",
|
||||||
|
ActionClaim: ActionWebauthnRegistration,
|
||||||
|
IdentityRetrieverFunc: identityRetrieverFromSession,
|
||||||
|
}, 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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,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.
|
// IdentityVerificationFinish the middleware for finishing the identity validation process.
|
||||||
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
|
func IdentityVerificationFinish(args IdentityVerificationFinishArgs, next func(ctx *AutheliaCtx, username string)) RequestHandler {
|
||||||
return func(ctx *AutheliaCtx) {
|
return func(ctx *AutheliaCtx) {
|
||||||
var finishBody IdentityVerificationFinishBody
|
if shouldSkipIdentityVerification(args.IdentityVerificationCommonArgs, ctx) {
|
||||||
|
next(ctx, "")
|
||||||
b := ctx.PostBody()
|
|
||||||
|
|
||||||
err := json.Unmarshal(b, &finishBody)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(err, messageOperationFailed)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if finishBody.Token == "" {
|
token, err := identityVerificationValidateToken(ctx)
|
||||||
ctx.Error(fmt.Errorf("No token provided"), messageOperationFailed)
|
if token == nil || err != nil {
|
||||||
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)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
// 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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
||||||
sqlUpdateWebauthnDevicePublicKey: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, tableWebauthnDevices),
|
sqlUpdateWebauthnDeviceDescriptionByUsernameAndID: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDeviceDescriptionByUsernameAndID, tableWebauthnDevices),
|
||||||
sqlUpdateWebauthnDevicePublicKeyByUsername: fmt.Sprintf(queryFmtUpdateUpdateWebauthnDevicePublicKeyByUsername, tableWebauthnDevices),
|
|
||||||
|
sqlUpdateWebauthnDevicePublicKey: fmt.Sprintf(queryFmtUpdateWebauthnDevicePublicKey, 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)
|
||||||
|
|
|
@ -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 = ?;`
|
||||||
|
|
|
@ -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 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";
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
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;
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
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 {
|
try {
|
||||||
setRegistrationInProgress(true);
|
setState(WebauthnTouchState.WaitTouch);
|
||||||
|
setActiveStep(0);
|
||||||
|
|
||||||
const result = await performAttestationCeremony(processToken, description);
|
const startResult = await getAttestationPublicKeyCredentialResult(creationOptions);
|
||||||
|
|
||||||
setRegistrationInProgress(false);
|
switch (startResult.result) {
|
||||||
|
|
||||||
switch (result) {
|
|
||||||
case AttestationResult.Success:
|
case AttestationResult.Success:
|
||||||
navigate(FirstFactorPath);
|
if (startResult.credential == null) {
|
||||||
break;
|
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 (
|
||||||
|
<>
|
||||||
|
<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 (
|
return (
|
||||||
<LoginLayout title="Touch Security Key">
|
<LoginLayout title="Register Security Key">
|
||||||
<div className={styles.icon}>
|
<Grid container>
|
||||||
<FingerTouchIcon size={64} animated />
|
<Grid item xs={12} className={styles.methodContainer}>
|
||||||
</div>
|
<Box sx={{ width: "100%" }}>
|
||||||
<Typography className={styles.instruction}>Touch the token on your security key</Typography>
|
<Stepper activeStep={activeStep}>
|
||||||
<Button color="primary" onClick={handleBackClick}>
|
{steps.map((label, index) => {
|
||||||
Retry
|
const stepProps: { completed?: boolean } = {};
|
||||||
</Button>
|
const labelProps: {
|
||||||
<Button color="primary" onClick={handleBackClick}>
|
optional?: React.ReactNode;
|
||||||
Cancel
|
} = {};
|
||||||
</Button>
|
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),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
@ -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,20 +51,24 @@ 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 (registrationInProgress) {
|
if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) {
|
||||||
return;
|
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}
|
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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 (
|
||||||
<Routes>
|
<SettingsLayout>
|
||||||
<Route path={IndexRoute} element={<SettingsView />} />
|
<Routes>
|
||||||
<Route path={SettingsTwoFactorAuthenticationSubRoute} element={<TwoFactorAuthenticationView />} />
|
<Route path={IndexRoute} element={<SettingsView />} />
|
||||||
</Routes>
|
<Route
|
||||||
|
path={SettingsTwoFactorAuthenticationSubRoute}
|
||||||
|
element={<TwoFactorAuthenticationView state={state} />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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 { 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}>
|
<WebauthnDevices state={props.state} />
|
||||||
<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>
|
</Grid>
|
||||||
<AddSecurityKeyDialog open={addKeyOpen} onClose={handleKeyClose} />
|
</Grid>
|
||||||
</SettingsLayout>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
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,
|
port: 3000,
|
||||||
open: false,
|
open: false,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
|
||||||
,
|
|
||||||
/* eslintPlugin({ cache: false }) */ htmlPlugin(),
|
|
||||||
istanbulPlugin,
|
|
||||||
react(),
|
|
||||||
svgr(),
|
|
||||||
tsconfigPaths(),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue