diff --git a/internal/commands/storage_run.go b/internal/commands/storage_run.go index 5aa5b6560..2e907176d 100644 --- a/internal/commands/storage_run.go +++ b/internal/commands/storage_run.go @@ -685,7 +685,7 @@ func (ctx *CmdCtx) StorageUserTOTPGenerateRunE(cmd *cobra.Command, args []string totpProvider := totp.NewTimeBasedProvider(ctx.config.TOTP) - if c, err = totpProvider.GenerateCustom(args[0], ctx.config.TOTP.Algorithm, secret, ctx.config.TOTP.Digits, ctx.config.TOTP.Period, ctx.config.TOTP.SecretSize); err != nil { + if c, err = totpProvider.GenerateCustom(args[0], ctx.config.TOTP.DefaultAlgorithm, secret, uint(ctx.config.TOTP.DefaultDigits), uint(ctx.config.TOTP.DefaultPeriod), uint(ctx.config.TOTP.SecretSize)); err != nil { return err } diff --git a/internal/configuration/schema/keys.go b/internal/configuration/schema/keys.go index e2ee63916..8ad3b4137 100644 --- a/internal/configuration/schema/keys.go +++ b/internal/configuration/schema/keys.go @@ -174,6 +174,9 @@ var Keys = []string{ "totp.period", "totp.skew", "totp.secret_size", + "totp.allowed_algorithms", + "totp.allowed_digits", + "totp.allowed_periods", "duo_api.disable", "duo_api.hostname", "duo_api.integration_key", diff --git a/internal/configuration/schema/totp.go b/internal/configuration/schema/totp.go index c6efbfed5..633577f5b 100644 --- a/internal/configuration/schema/totp.go +++ b/internal/configuration/schema/totp.go @@ -2,23 +2,27 @@ package schema // TOTPConfiguration represents the configuration related to TOTP options. type TOTPConfiguration struct { - Disable bool `koanf:"disable"` - Issuer string `koanf:"issuer"` - Algorithm string `koanf:"algorithm"` - Digits uint `koanf:"digits"` - Period uint `koanf:"period"` - Skew *uint `koanf:"skew"` - SecretSize uint `koanf:"secret_size"` + Disable bool `koanf:"disable"` + Issuer string `koanf:"issuer"` + DefaultAlgorithm string `koanf:"algorithm"` + DefaultDigits int `koanf:"digits"` + DefaultPeriod int `koanf:"period"` + Skew *int `koanf:"skew"` + SecretSize int `koanf:"secret_size"` + + AllowedAlgorithms []string `koanf:"allowed_algorithms"` + AllowedDigits []int `koanf:"allowed_digits"` + AllowedPeriods []int `koanf:"allowed_periods"` } -var defaultOtpSkew = uint(1) +var defaultTOTPSkew = 1 // DefaultTOTPConfiguration represents default configuration parameters for TOTP generation. var DefaultTOTPConfiguration = TOTPConfiguration{ - Issuer: "Authelia", - Algorithm: TOTPAlgorithmSHA1, - Digits: 6, - Period: 30, - Skew: &defaultOtpSkew, - SecretSize: TOTPSecretSizeDefault, + Issuer: "Authelia", + DefaultAlgorithm: TOTPAlgorithmSHA1, + DefaultDigits: 6, + DefaultPeriod: 30, + Skew: &defaultTOTPSkew, + SecretSize: TOTPSecretSizeDefault, } diff --git a/internal/configuration/validator/totp.go b/internal/configuration/validator/totp.go index 01a763f88..62cc4fce8 100644 --- a/internal/configuration/validator/totp.go +++ b/internal/configuration/validator/totp.go @@ -8,7 +8,7 @@ import ( "github.com/authelia/authelia/v4/internal/utils" ) -// ValidateTOTP validates and update TOTP configuration. +// ValidateTOTP validates and updates TOTP configuration. func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidator) { if config.TOTP.Disable { return @@ -18,26 +18,73 @@ func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidato config.TOTP.Issuer = schema.DefaultTOTPConfiguration.Issuer } - if config.TOTP.Algorithm == "" { - config.TOTP.Algorithm = schema.DefaultTOTPConfiguration.Algorithm + if config.TOTP.DefaultAlgorithm == "" { + config.TOTP.DefaultAlgorithm = schema.DefaultTOTPConfiguration.DefaultAlgorithm } else { - config.TOTP.Algorithm = strings.ToUpper(config.TOTP.Algorithm) + config.TOTP.DefaultAlgorithm = strings.ToUpper(config.TOTP.DefaultAlgorithm) - if !utils.IsStringInSlice(config.TOTP.Algorithm, schema.TOTPPossibleAlgorithms) { - validator.Push(fmt.Errorf(errFmtTOTPInvalidAlgorithm, strJoinOr(schema.TOTPPossibleAlgorithms), config.TOTP.Algorithm)) + if !utils.IsStringInSlice(config.TOTP.DefaultAlgorithm, schema.TOTPPossibleAlgorithms) { + validator.Push(fmt.Errorf(errFmtTOTPInvalidAlgorithm, strings.Join(schema.TOTPPossibleAlgorithms, "', '"), config.TOTP.DefaultAlgorithm)) } } - if config.TOTP.Period == 0 { - config.TOTP.Period = schema.DefaultTOTPConfiguration.Period - } else if config.TOTP.Period < 15 { - validator.Push(fmt.Errorf(errFmtTOTPInvalidPeriod, config.TOTP.Period)) + for i, algorithm := range config.TOTP.AllowedAlgorithms { + config.TOTP.AllowedAlgorithms[i] = strings.ToUpper(algorithm) + + // TODO: Customize this error. + if !utils.IsStringInSlice(config.TOTP.AllowedAlgorithms[i], schema.TOTPPossibleAlgorithms) { + validator.Push(fmt.Errorf(errFmtTOTPInvalidAlgorithm, strings.Join(schema.TOTPPossibleAlgorithms, "', '"), config.TOTP.AllowedAlgorithms[i])) + } } - if config.TOTP.Digits == 0 { - config.TOTP.Digits = schema.DefaultTOTPConfiguration.Digits - } else if config.TOTP.Digits != 6 && config.TOTP.Digits != 8 { - validator.Push(fmt.Errorf(errFmtTOTPInvalidDigits, config.TOTP.Digits)) + if !utils.IsStringInSlice(config.TOTP.DefaultAlgorithm, config.TOTP.AllowedAlgorithms) { + config.TOTP.AllowedAlgorithms = append(config.TOTP.AllowedAlgorithms, config.TOTP.DefaultAlgorithm) + } + + if config.TOTP.DefaultPeriod == 0 { + config.TOTP.DefaultPeriod = schema.DefaultTOTPConfiguration.DefaultPeriod + } else if config.TOTP.DefaultPeriod < 15 { + validator.Push(fmt.Errorf(errFmtTOTPInvalidPeriod, config.TOTP.DefaultPeriod)) + } + + var hasDefaultPeriod bool + + for _, period := range config.TOTP.AllowedPeriods { + // TODO: Customize this error. + if period < 15 { + validator.Push(fmt.Errorf(errFmtTOTPInvalidPeriod, period)) + } + + if period == config.TOTP.DefaultPeriod { + hasDefaultPeriod = true + } + } + + if !hasDefaultPeriod { + config.TOTP.AllowedPeriods = append(config.TOTP.AllowedPeriods, config.TOTP.DefaultPeriod) + } + + if config.TOTP.DefaultDigits == 0 { + config.TOTP.DefaultDigits = schema.DefaultTOTPConfiguration.DefaultDigits + } else if config.TOTP.DefaultDigits != 6 && config.TOTP.DefaultDigits != 8 { + validator.Push(fmt.Errorf(errFmtTOTPInvalidDigits, config.TOTP.DefaultDigits)) + } + + var hasDefaultDigits bool + + for _, digits := range config.TOTP.AllowedDigits { + // TODO: Customize this error. + if digits != 6 && digits != 8 { + validator.Push(fmt.Errorf(errFmtTOTPInvalidDigits, config.TOTP.DefaultDigits)) + } + + if digits == config.TOTP.DefaultDigits { + hasDefaultDigits = true + } + } + + if !hasDefaultDigits { + config.TOTP.AllowedDigits = append(config.TOTP.AllowedDigits, config.TOTP.DefaultDigits) } if config.TOTP.Skew == nil { diff --git a/internal/configuration/validator/totp_test.go b/internal/configuration/validator/totp_test.go index b94f7f00b..2bc160714 100644 --- a/internal/configuration/validator/totp_test.go +++ b/internal/configuration/validator/totp_test.go @@ -30,31 +30,31 @@ func TestValidateTOTP(t *testing.T) { { desc: "ShouldNormalizeTOTPAlgorithm", have: schema.TOTPConfiguration{ - Algorithm: digestSHA1, - Digits: 6, - Period: 30, - SecretSize: 32, - Skew: schema.DefaultTOTPConfiguration.Skew, - Issuer: "abc", + DefaultAlgorithm: digestSHA1, + DefaultDigits: 6, + DefaultPeriod: 30, + SecretSize: 32, + Skew: schema.DefaultTOTPConfiguration.Skew, + Issuer: "abc", }, expected: schema.TOTPConfiguration{ - Algorithm: "SHA1", - Digits: 6, - Period: 30, - SecretSize: 32, - Skew: schema.DefaultTOTPConfiguration.Skew, - Issuer: "abc", + DefaultAlgorithm: "SHA1", + DefaultDigits: 6, + DefaultPeriod: 30, + SecretSize: 32, + Skew: schema.DefaultTOTPConfiguration.Skew, + Issuer: "abc", }, }, { desc: "ShouldRaiseErrorWhenInvalidTOTPAlgorithm", have: schema.TOTPConfiguration{ - Algorithm: "sha3", - Digits: 6, - Period: 30, - SecretSize: 32, - Skew: schema.DefaultTOTPConfiguration.Skew, - Issuer: "abc", + DefaultAlgorithm: "sha3", + DefaultDigits: 6, + DefaultPeriod: 30, + SecretSize: 32, + Skew: schema.DefaultTOTPConfiguration.Skew, + Issuer: "abc", }, errs: []string{ "totp: option 'algorithm' must be one of 'SHA1', 'SHA256', or 'SHA512' but it's configured as 'SHA3'", @@ -63,12 +63,12 @@ func TestValidateTOTP(t *testing.T) { { desc: "ShouldRaiseErrorWhenInvalidTOTPValue", have: schema.TOTPConfiguration{ - Algorithm: "sha3", - Period: 5, - Digits: 20, - SecretSize: 10, - Skew: schema.DefaultTOTPConfiguration.Skew, - Issuer: "abc", + DefaultAlgorithm: "sha3", + DefaultPeriod: 5, + DefaultDigits: 20, + SecretSize: 10, + Skew: schema.DefaultTOTPConfiguration.Skew, + Issuer: "abc", }, errs: []string{ "totp: option 'algorithm' must be one of 'SHA1', 'SHA256', or 'SHA512' but it's configured as 'SHA3'", @@ -94,9 +94,9 @@ func TestValidateTOTP(t *testing.T) { assert.Len(t, warns, 0) assert.Equal(t, tc.expected.Disable, config.TOTP.Disable) assert.Equal(t, tc.expected.Issuer, config.TOTP.Issuer) - assert.Equal(t, tc.expected.Algorithm, config.TOTP.Algorithm) + assert.Equal(t, tc.expected.DefaultAlgorithm, config.TOTP.DefaultAlgorithm) assert.Equal(t, tc.expected.Skew, config.TOTP.Skew) - assert.Equal(t, tc.expected.Period, config.TOTP.Period) + assert.Equal(t, tc.expected.DefaultPeriod, config.TOTP.DefaultPeriod) assert.Equal(t, tc.expected.SecretSize, config.TOTP.SecretSize) } else { expectedErrs := len(tc.errs) diff --git a/internal/handlers/const.go b/internal/handlers/const.go index 701d00de9..8d4c83187 100644 --- a/internal/handlers/const.go +++ b/internal/handlers/const.go @@ -67,6 +67,7 @@ const ( messageOperationFailed = "Operation failed." messageAuthenticationFailed = "Authentication failed. Check your credentials." messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec + messageUnableToDeleteOneTimePassword = "Unable to delete one-time password." //nolint:gosec messageUnableToRegisterSecurityKey = "Unable to register your security key." messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name." messageUnableToResetPassword = "Unable to reset your password." diff --git a/internal/handlers/handler_register_totp.go b/internal/handlers/handler_register_totp.go index 91fd20072..7b67f94bf 100644 --- a/internal/handlers/handler_register_totp.go +++ b/internal/handlers/handler_register_totp.go @@ -1,77 +1,250 @@ package handlers import ( + "encoding/json" "fmt" + "time" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" + "github.com/authelia/authelia/v4/internal/regulation" "github.com/authelia/authelia/v4/internal/session" + "github.com/authelia/authelia/v4/internal/utils" ) -// identityRetrieverFromSession retriever computing the identity from the cookie session. -func identityRetrieverFromSession(ctx *middlewares.AutheliaCtx) (identity *session.Identity, err error) { - var userSession session.UserSession - - if userSession, err = ctx.GetSession(); err != nil { - return nil, fmt.Errorf("error retrieving user session for request: %w", err) +func TOTPRegisterOptionsGET(ctx *middlewares.AutheliaCtx) { + if err := ctx.SetJSONBody(ctx.Providers.TOTP.Options()); err != nil { + ctx.Logger.Errorf("Unable to set TOTP options response in body: %s", err) } - - if len(userSession.Emails) == 0 { - return nil, fmt.Errorf("user %s does not have any email address", userSession.Username) - } - - return &session.Identity{ - Username: userSession.Username, - DisplayName: userSession.DisplayName, - Email: userSession.Emails[0], - }, nil } -func isTokenUserValidFor2FARegistration(ctx *middlewares.AutheliaCtx, username string) bool { - userSession, err := ctx.GetSession() - - return err == nil && userSession.Username == username -} - -// TOTPIdentityStart the handler for initiating the identity validation. -var TOTPIdentityStart = middlewares.IdentityVerificationStart(middlewares.IdentityVerificationStartArgs{ - MailTitle: "Register your mobile", - MailButtonContent: "Register", - TargetEndpoint: "/one-time-password/register", - ActionClaim: ActionTOTPRegistration, - IdentityRetrieverFunc: identityRetrieverFromSession, -}, nil) - -func totpIdentityFinish(ctx *middlewares.AutheliaCtx, username string) { +func TOTPRegisterPUT(ctx *middlewares.AutheliaCtx) { var ( - config *model.TOTPConfiguration - err error + userSession session.UserSession + bodyJSON bodyRegisterTOTP + err error ) - if config, err = ctx.Providers.TOTP.Generate(username); err != nil { - ctx.Error(fmt.Errorf("unable to generate TOTP key: %s", err), messageUnableToRegisterOneTimePassword) + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return } - if err = ctx.Providers.StorageProvider.SaveTOTPConfiguration(ctx, *config); err != nil { - ctx.Error(fmt.Errorf("unable to save TOTP secret in DB: %s", err), messageUnableToRegisterOneTimePassword) + if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred unmarshaling body %s registration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + opts := ctx.Providers.TOTP.Options() + + var hasAlgorithm, hasLength, hasPeriod bool + + hasAlgorithm = utils.IsStringInSlice(bodyJSON.Algorithm, opts.Algorithms) + + for _, period := range opts.Periods { + if period == bodyJSON.Period { + hasPeriod = true + break + } + } + + for _, length := range opts.Lengths { + if length == bodyJSON.Length { + hasLength = true + break + } + } + + if !hasAlgorithm || !hasPeriod || !hasLength { + ctx.Logger.Errorf("Validation failed for %s registration because the input options were not permitted by the configuration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + var config *model.TOTPConfiguration + + if config, err = ctx.Providers.TOTP.GenerateCustom(userSession.Username, bodyJSON.Algorithm, "", uint(bodyJSON.Length), uint(bodyJSON.Period), 0); err != nil { + ctx.Error(fmt.Errorf("unable to generate TOTP key: %w", err), messageUnableToRegisterOneTimePassword) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + userSession.TOTP = &session.TOTP{ + Issuer: config.Issuer, + Algorithm: config.Algorithm, + Digits: config.Digits, + Period: config.Period, + Secret: string(config.Secret), + Expires: ctx.Clock.Now().Add(time.Minute * 10), + } + + if err = ctx.SaveSession(userSession); err != nil { + ctx.Error(err, messageUnableToRegisterOneTimePassword) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + return } response := TOTPKeyResponse{ OTPAuthURL: config.URI(), - Base32Secret: string(config.Secret), + Base32Secret: userSession.TOTP.Secret, } if err = ctx.SetJSONBody(response); err != nil { ctx.Logger.Errorf("Unable to set TOTP key response in body: %s", err) } - - ctxLogEvent(ctx, username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Time-based One Time Password"}) } -// TOTPIdentityFinish the handler for finishing the identity validation. -var TOTPIdentityFinish = middlewares.IdentityVerificationFinish( - middlewares.IdentityVerificationFinishArgs{ - ActionClaim: ActionTOTPRegistration, - IsTokenUserValidFunc: isTokenUserValidFor2FARegistration, - }, totpIdentityFinish) +func TOTPRegisterPOST(ctx *middlewares.AutheliaCtx) { + var ( + userSession session.UserSession + bodyJSON bodyRegisterFinishTOTP + valid bool + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + if userSession.TOTP == nil { + ctx.Logger.Errorf("Error occurred during %s registration: the user did not initiate a registration on their current session", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + if ctx.Clock.Now().After(userSession.TOTP.Expires) { + ctx.Logger.Errorf("Error occurred during %s registration: the registration is expired", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred unmarshaling body %s registration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + config := model.TOTPConfiguration{ + CreatedAt: ctx.Clock.Now(), + Username: userSession.Username, + Issuer: userSession.TOTP.Issuer, + Algorithm: userSession.TOTP.Algorithm, + Period: userSession.TOTP.Period, + Digits: userSession.TOTP.Digits, + Secret: []byte(userSession.TOTP.Secret), + } + + if valid, err = ctx.Providers.TOTP.Validate(bodyJSON.Token, &config); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred validating %s registration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } else if !valid { + ctx.Logger.Errorf("Error occurred validating %s registration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + if err = ctx.Providers.StorageProvider.SaveTOTPConfiguration(ctx, config); err != nil { + ctx.Logger.Errorf("Error occurred saving %s registration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + userSession.TOTP = nil + + if err = ctx.SaveSession(userSession); err != nil { + ctx.Logger.Errorf("Error occurred saving session during %s registration", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } +} + +func TOTPRegisterDELETE(ctx *middlewares.AutheliaCtx) { + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration cancel", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } + + if userSession.TOTP == nil { + return + } + + userSession.TOTP = nil + + if err = ctx.SaveSession(userSession); err != nil { + ctx.Logger.Errorf("Error occurred saving session during %s registration cancel", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword) + + return + } +} + +func TOTPConfigurationDELETE(ctx *middlewares.AutheliaCtx) { + var ( + userSession session.UserSession + err error + ) + + if userSession, err = ctx.GetSession(); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration cancel", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToDeleteOneTimePassword) + + return + } + + if _, err = ctx.Providers.StorageProvider.LoadTOTPConfiguration(ctx, userSession.Username); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration cancel", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToDeleteOneTimePassword) + + return + } + + if err = ctx.Providers.StorageProvider.DeleteTOTPConfiguration(ctx, userSession.Username); err != nil { + ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration cancel", regulation.AuthTypeTOTP) + + respondUnauthorized(ctx, messageUnableToDeleteOneTimePassword) + + return + } +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 1b8102759..21745b570 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -31,6 +31,16 @@ type bodySignTOTPRequest struct { WorkflowID string `json:"workflowID"` } +type bodyRegisterTOTP struct { + Algorithm string `json:"algorithm"` + Length int `json:"length"` + Period int `json:"period"` +} + +type bodyRegisterFinishTOTP struct { + Token string `json:"token" valid:"required"` +} + // bodySignWebAuthnRequest is the model of the request body of WebAuthn 2FA authentication endpoint. type bodySignWebAuthnRequest struct { TargetURL string `json:"targetURL"` diff --git a/internal/mocks/totp.go b/internal/mocks/totp.go index 8b029b6ea..ab61dfe30 100644 --- a/internal/mocks/totp.go +++ b/internal/mocks/totp.go @@ -64,6 +64,20 @@ func (mr *MockTOTPMockRecorder) GenerateCustom(arg0, arg1, arg2, arg3, arg4, arg return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateCustom", reflect.TypeOf((*MockTOTP)(nil).GenerateCustom), arg0, arg1, arg2, arg3, arg4, arg5) } +// Options mocks base method. +func (m *MockTOTP) Options() model.TOTPOptions { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Options") + ret0, _ := ret[0].(model.TOTPOptions) + return ret0 +} + +// Options indicates an expected call of Options. +func (mr *MockTOTPMockRecorder) Options() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Options", reflect.TypeOf((*MockTOTP)(nil).Options)) +} + // Validate mocks base method. func (m *MockTOTP) Validate(arg0 string, arg1 *model.TOTPConfiguration) (bool, error) { m.ctrl.T.Helper() diff --git a/internal/model/totp_configuration.go b/internal/model/totp_configuration.go index 0d325ce0a..735714e48 100644 --- a/internal/model/totp_configuration.go +++ b/internal/model/totp_configuration.go @@ -3,6 +3,7 @@ package model import ( "database/sql" "encoding/base64" + "encoding/json" "image" "net/url" "strconv" @@ -12,17 +13,54 @@ import ( "gopkg.in/yaml.v3" ) +type TOTPOptions struct { + Algorithm string `json:"algorithm"` + Algorithms []string `json:"algorithms"` + + Length int `json:"length"` + Lengths []int `json:"lengths"` + + Period int `json:"period"` + Periods []int `json:"periods"` +} + // TOTPConfiguration represents a users TOTP configuration row in the database. type TOTPConfiguration struct { - ID int `db:"id" json:"-"` - CreatedAt time.Time `db:"created_at" json:"-"` - LastUsedAt sql.NullTime `db:"last_used_at" json:"-"` - Username string `db:"username" json:"-"` - Issuer string `db:"issuer" json:"-"` - Algorithm string `db:"algorithm" json:"-"` - Digits uint `db:"digits" json:"digits"` - Period uint `db:"period" json:"period"` - Secret []byte `db:"secret" json:"-"` + ID int `db:"id"` + CreatedAt time.Time `db:"created_at"` + LastUsedAt sql.NullTime `db:"last_used_at"` + Username string `db:"username"` + Issuer string `db:"issuer"` + Algorithm string `db:"algorithm"` + Digits uint `db:"digits"` + Period uint `db:"period"` + Secret []byte `db:"secret"` +} + +type TOTPConfigurationJSON struct { + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + Issuer string `json:"issuer"` + Algorithm string `json:"algorithm"` + Digits int `json:"digits"` + Period int `json:"period"` +} + +// MarshalJSON returns the WebauthnDevice in a JSON friendly manner. +func (c TOTPConfiguration) MarshalJSON() (data []byte, err error) { + o := TOTPConfigurationJSON{ + CreatedAt: c.CreatedAt, + Issuer: c.Issuer, + Algorithm: c.Algorithm, + Digits: int(c.Digits), + Period: int(c.Period), + } + + if c.LastUsedAt.Valid { + o.LastUsedAt = &c.LastUsedAt.Time + } + + return json.Marshal(o) } // LastUsed provides LastUsedAt as a *time.Time instead of sql.NullTime. diff --git a/internal/model/totp_configuration_test.go b/internal/model/totp_configuration_test.go index 07c38a1f7..8dcca4b29 100644 --- a/internal/model/totp_configuration_test.go +++ b/internal/model/totp_configuration_test.go @@ -35,7 +35,7 @@ func TestShouldOnlyMarshalPeriodAndDigitsAndAbsolutelyNeverSecret(t *testing.T) data, err := json.Marshal(object) assert.NoError(t, err) - assert.Equal(t, "{\"digits\":6,\"period\":30}", string(data)) + assert.Equal(t, "{\"created_at\":\"0001-01-01T00:00:00Z\",\"issuer\":\"Authelia\",\"algorithm\":\"SHA1\",\"digits\":6,\"period\":30}", string(data)) // DO NOT REMOVE OR CHANGE THESE TESTS UNLESS YOU FULLY UNDERSTAND THE COMMENT AT THE TOP OF THIS TEST. require.NotContains(t, string(data), "secret") diff --git a/internal/server/handlers.go b/internal/server/handlers.go index c19e0acbd..fb7eaf81e 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -255,9 +255,12 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers) if !config.TOTP.Disable { // TOTP related endpoints. r.GET("/api/user/info/totp", middleware1FA(handlers.UserTOTPInfoGET)) - r.POST("/api/secondfactor/totp/identity/start", middleware1FA(handlers.TOTPIdentityStart)) - r.POST("/api/secondfactor/totp/identity/finish", middleware1FA(handlers.TOTPIdentityFinish)) r.POST("/api/secondfactor/totp", middleware1FA(handlers.TimeBasedOneTimePasswordPOST)) + r.DELETE("/api/secondfactor/totp", middleware2FA(handlers.TOTPConfigurationDELETE)) + r.GET("/api/secondfactor/totp/register/options", middleware1FA(handlers.TOTPRegisterOptionsGET)) + r.PUT("/api/secondfactor/totp/register", middleware1FA(handlers.TOTPRegisterPUT)) + r.POST("/api/secondfactor/totp/register", middleware1FA(handlers.TOTPRegisterPOST)) + r.DELETE("/api/secondfactor/totp/register", middleware1FA(handlers.TOTPRegisterDELETE)) } if !config.WebAuthn.Disable { diff --git a/internal/server/template.go b/internal/server/template.go index 91fcd2a80..af24de1dd 100644 --- a/internal/server/template.go +++ b/internal/server/template.go @@ -269,6 +269,8 @@ func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileO RememberMe: strconv.FormatBool(!config.Session.DisableRememberMe), ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable), ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(), + PrivacyPolicyURL: "", + PrivacyPolicyAccept: strFalse, Theme: config.Theme, EndpointsPasswordReset: !(config.AuthenticationBackend.PasswordReset.Disable || config.AuthenticationBackend.PasswordReset.CustomURL.String() != ""), diff --git a/internal/session/types.go b/internal/session/types.go index ebb992419..5fdfb8334 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -37,6 +37,7 @@ type UserSession struct { // WebAuthn holds the session registration data for this session. WebAuthn *WebAuthn + TOTP *TOTP // This boolean is set to true after identity verification and checked // while doing the query actually updating the password. @@ -45,7 +46,17 @@ type UserSession struct { RefreshTTL time.Time } -// WebAuthn holds the standard webauthn session data plus some extra. +// TOTP holds the TOTP registration session data. +type TOTP struct { + Issuer string + Algorithm string + Digits uint + Period uint + Secret string + Expires time.Time +} + +// WebAuthn holds the standard WebAuthn session data plus some extra. type WebAuthn struct { *webauthn.SessionData Description string diff --git a/internal/storage/sql_provider_queries.go b/internal/storage/sql_provider_queries.go index c7d62d280..941540904 100644 --- a/internal/storage/sql_provider_queries.go +++ b/internal/storage/sql_provider_queries.go @@ -73,12 +73,12 @@ const ( const ( queryFmtSelectTOTPConfiguration = ` - SELECT id, username, issuer, algorithm, digits, period, secret + SELECT id, created_at, last_used_at, username, issuer, algorithm, digits, period, secret FROM %s WHERE username = ?;` queryFmtSelectTOTPConfigurations = ` - SELECT id, username, issuer, algorithm, digits, period, secret + SELECT id, created_at, last_used_at, username, issuer, algorithm, digits, period, secret FROM %s LIMIT ? OFFSET ?;` diff --git a/internal/suites/MultiCookieDomain/configuration.yml b/internal/suites/MultiCookieDomain/configuration.yml index c4dd3fa1b..fb66b8338 100644 --- a/internal/suites/MultiCookieDomain/configuration.yml +++ b/internal/suites/MultiCookieDomain/configuration.yml @@ -4,7 +4,7 @@ ############################################################### jwt_secret: unsecure_secret -theme: auto +theme: dark server: address: 'tcp://:9091' @@ -48,6 +48,18 @@ storage: totp: issuer: example.com + allowed_algorithms: + - SHA1 + - SHA256 + - SHA512 + allowed_digits: + - 6 + - 8 + allowed_periods: + - 30 + - 60 + - 90 + - 120 access_control: default_policy: deny diff --git a/internal/suites/const.go b/internal/suites/const.go index 3deddf064..d5e5818fd 100644 --- a/internal/suites/const.go +++ b/internal/suites/const.go @@ -93,8 +93,8 @@ const ( var ( storageLocalTmpConfig = schema.Configuration{ TOTP: schema.TOTPConfiguration{ - Issuer: "Authelia", - Period: 6, + Issuer: "Authelia", + DefaultPeriod: 6, }, Storage: schema.StorageConfiguration{ EncryptionKey: "a_not_so_secure_encryption_key", diff --git a/internal/suites/scenario_multiple_cookie_domain_test.go b/internal/suites/scenario_multiple_cookie_domain_test.go index e8acb3c5e..2b9db7c27 100644 --- a/internal/suites/scenario_multiple_cookie_domain_test.go +++ b/internal/suites/scenario_multiple_cookie_domain_test.go @@ -37,8 +37,7 @@ func (s *MultiCookieDomainScenario) SetupSuite() { s.RodSession = browser - err = updateDevEnvFileForDomain(s.domain, false) - s.Require().NoError(err) + s.Require().NoError(updateDevEnvFileForDomain(s.domain, false)) } func (s *MultiCookieDomainScenario) TearDownSuite() { @@ -133,8 +132,7 @@ func (s *MultiCookieDomainScenario) TestShouldStayLoggedInOnNextDomainWhenLogged s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", s.remember, s.domain, firstDomainTargetURL) s.verifySecretAuthorized(s.T(), s.Page) - err := updateDevEnvFileForDomain(s.nextDomain, false) - s.Require().NoError(err) + s.Require().NoError(updateDevEnvFileForDomain(s.nextDomain, false)) s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", !s.remember, s.nextDomain, nextDomainTargetURL) s.verifySecretAuthorized(s.T(), s.Page) diff --git a/internal/totp/provider.go b/internal/totp/provider.go index 1280e4163..91ed49a10 100644 --- a/internal/totp/provider.go +++ b/internal/totp/provider.go @@ -9,4 +9,5 @@ type Provider interface { Generate(username string) (config *model.TOTPConfiguration, err error) GenerateCustom(username string, algorithm, secret string, digits, period, secretSize uint) (config *model.TOTPConfiguration, err error) Validate(token string, config *model.TOTPConfiguration) (valid bool, err error) + Options() model.TOTPOptions } diff --git a/internal/totp/totp.go b/internal/totp/totp.go index 30cfcecc0..fcee6675d 100644 --- a/internal/totp/totp.go +++ b/internal/totp/totp.go @@ -15,11 +15,23 @@ import ( // NewTimeBasedProvider creates a new totp.TimeBased which implements the totp.Provider. func NewTimeBasedProvider(config schema.TOTPConfiguration) (provider *TimeBased) { provider = &TimeBased{ - config: &config, + opts: &model.TOTPOptions{ + Algorithm: config.DefaultAlgorithm, + Algorithms: config.AllowedAlgorithms, + Period: config.DefaultPeriod, + Periods: config.AllowedPeriods, + Length: config.DefaultDigits, + Lengths: config.AllowedDigits, + }, + issuer: config.Issuer, + algorithm: config.DefaultAlgorithm, + digits: uint(config.DefaultDigits), + period: uint(config.DefaultPeriod), + size: uint(config.SecretSize), } if config.Skew != nil { - provider.skew = *config.Skew + provider.skew = uint(*config.Skew) } else { provider.skew = 1 } @@ -29,8 +41,14 @@ func NewTimeBasedProvider(config schema.TOTPConfiguration) (provider *TimeBased) // TimeBased totp.Provider for production use. type TimeBased struct { - config *schema.TOTPConfiguration - skew uint + opts *model.TOTPOptions + + issuer string + algorithm string + digits uint + period uint + skew uint + size uint } // GenerateCustom generates a TOTP with custom options. @@ -45,8 +63,12 @@ func (p TimeBased) GenerateCustom(username, algorithm, secret string, digits, pe } } + if secretSize == 0 { + secretSize = p.size + } + opts := totp.GenerateOpts{ - Issuer: p.config.Issuer, + Issuer: p.issuer, AccountName: username, Period: period, Secret: secretData, @@ -55,26 +77,32 @@ func (p TimeBased) GenerateCustom(username, algorithm, secret string, digits, pe Algorithm: otpStringToAlgo(algorithm), } + fmt.Println("secret before", opts.Secret) + if key, err = totp.Generate(opts); err != nil { return nil, err } + fmt.Println("secret key", key) + config = &model.TOTPConfiguration{ CreatedAt: time.Now(), Username: username, - Issuer: p.config.Issuer, + Issuer: p.issuer, Algorithm: algorithm, Digits: digits, Secret: []byte(key.Secret()), Period: period, } + fmt.Println("secret after", config.Secret) + return config, nil } // Generate generates a TOTP with default options. func (p TimeBased) Generate(username string) (config *model.TOTPConfiguration, err error) { - return p.GenerateCustom(username, p.config.Algorithm, "", p.config.Digits, p.config.Period, p.config.SecretSize) + return p.GenerateCustom(username, p.algorithm, "", p.digits, p.period, p.size) } // Validate the token against the given configuration. @@ -86,5 +114,17 @@ func (p TimeBased) Validate(token string, config *model.TOTPConfiguration) (vali Algorithm: otpStringToAlgo(config.Algorithm), } + fmt.Println("period", opts.Period) + fmt.Println("skew", opts.Skew) + fmt.Println("digits", opts.Digits) + fmt.Println("algorithm", opts.Algorithm) + fmt.Println("token", token) + fmt.Println("secret", config.Secret) + return totp.ValidateCustom(token, string(config.Secret), time.Now().UTC(), opts) } + +// Options returns the configured options for this provider. +func (p TimeBased) Options() model.TOTPOptions { + return *p.opts +} diff --git a/internal/totp/totp_test.go b/internal/totp/totp_test.go index 34b421eed..aa85e800d 100644 --- a/internal/totp/totp_test.go +++ b/internal/totp/totp_test.go @@ -81,11 +81,11 @@ func TestTOTPGenerateCustom(t *testing.T) { } totp := NewTimeBasedProvider(schema.TOTPConfiguration{ - Issuer: "Authelia", - Algorithm: "SHA1", - Digits: 6, - Period: 30, - SecretSize: 32, + Issuer: "Authelia", + DefaultAlgorithm: "SHA1", + DefaultDigits: 6, + DefaultPeriod: 30, + SecretSize: 32, }) for _, tc := range testCases { @@ -118,15 +118,15 @@ func TestTOTPGenerateCustom(t *testing.T) { } func TestTOTPGenerate(t *testing.T) { - skew := uint(2) + skew := 2 totp := NewTimeBasedProvider(schema.TOTPConfiguration{ - Issuer: "Authelia", - Algorithm: "SHA256", - Digits: 8, - Period: 60, - Skew: &skew, - SecretSize: 32, + Issuer: "Authelia", + DefaultAlgorithm: "SHA256", + DefaultDigits: 8, + DefaultPeriod: 60, + Skew: &skew, + SecretSize: 32, }) assert.Equal(t, uint(2), totp.skew) diff --git a/web/src/App.tsx b/web/src/App.tsx index 21c99a540..f0cabee9b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -78,6 +78,7 @@ const App: React.FC = (props: Props) => { } } }, []); + return ( diff --git a/web/src/components/ComponentOrLoading.tsx b/web/src/components/ComponentOrLoading.tsx new file mode 100644 index 000000000..13e62647f --- /dev/null +++ b/web/src/components/ComponentOrLoading.tsx @@ -0,0 +1,22 @@ +import React, { Fragment, ReactNode } from "react"; + +import LoadingPage from "@views/LoadingPage/LoadingPage"; + +export interface Props { + ready: boolean; + + children: ReactNode; +} + +function ComponentOrLoading(props: Props) { + return ( + +
+ +
+ {props.ready ? props.children : null} +
+ ); +} + +export default ComponentOrLoading; diff --git a/web/src/hooks/UserInfoTOTPConfiguration.ts b/web/src/hooks/UserInfoTOTPConfiguration.ts index dba45ab0b..38bebe814 100644 --- a/web/src/hooks/UserInfoTOTPConfiguration.ts +++ b/web/src/hooks/UserInfoTOTPConfiguration.ts @@ -1,6 +1,13 @@ import { useRemoteCall } from "@hooks/RemoteCall"; -import { getUserInfoTOTPConfiguration } from "@services/UserInfoTOTPConfiguration"; +import { + getUserInfoTOTPConfiguration, + getUserInfoTOTPConfigurationOptional, +} from "@services/UserInfoTOTPConfiguration"; export function useUserInfoTOTPConfiguration() { return useRemoteCall(getUserInfoTOTPConfiguration, []); } + +export function useUserInfoTOTPConfigurationOptional() { + return useRemoteCall(getUserInfoTOTPConfigurationOptional, []); +} diff --git a/web/src/hooks/WebauthnDevices.ts b/web/src/hooks/WebauthnDevices.ts new file mode 100644 index 000000000..f9bdbd2a5 --- /dev/null +++ b/web/src/hooks/WebauthnDevices.ts @@ -0,0 +1,6 @@ +import { useRemoteCall } from "@hooks/RemoteCall"; +import { getUserWebauthnDevices } from "@services/UserWebauthnDevices"; + +export function useUserWebauthnDevices() { + return useRemoteCall(getUserWebauthnDevices, []); +} diff --git a/web/src/models/TOTPConfiguration.ts b/web/src/models/TOTPConfiguration.ts new file mode 100644 index 000000000..aba289312 --- /dev/null +++ b/web/src/models/TOTPConfiguration.ts @@ -0,0 +1,48 @@ +export interface UserInfoTOTPConfiguration { + created_at: Date; + last_used_at?: Date; + issuer: string; + algorithm: TOTPAlgorithm; + digits: TOTPDigits; + period: number; +} + +export interface TOTPOptions { + algorithm: TOTPAlgorithm; + algorithms: TOTPAlgorithm[]; + length: TOTPDigits; + lengths: TOTPDigits[]; + period: number; + periods: number[]; +} + +export enum TOTPAlgorithm { + SHA1 = 0, + SHA256, + SHA512, +} + +export type TOTPDigits = 6 | 8; +export type TOTPAlgorithmPayload = "SHA1" | "SHA256" | "SHA512"; + +export function toAlgorithmString(alg: TOTPAlgorithm): TOTPAlgorithmPayload { + switch (alg) { + case TOTPAlgorithm.SHA1: + return "SHA1"; + case TOTPAlgorithm.SHA256: + return "SHA256"; + case TOTPAlgorithm.SHA512: + return "SHA512"; + } +} + +export function toEnum(alg: TOTPAlgorithmPayload): TOTPAlgorithm { + switch (alg) { + case "SHA1": + return TOTPAlgorithm.SHA1; + case "SHA256": + return TOTPAlgorithm.SHA256; + case "SHA512": + return TOTPAlgorithm.SHA512; + } +} diff --git a/web/src/models/UserInfoTOTPConfiguration.ts b/web/src/models/UserInfoTOTPConfiguration.ts deleted file mode 100644 index adbcea5e9..000000000 --- a/web/src/models/UserInfoTOTPConfiguration.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface UserInfoTOTPConfiguration { - period: number; - digits: number; -} diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index 745c5a326..d0a618881 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -11,10 +11,11 @@ export const FirstFactorPath = basePath + "/api/firstfactor"; export const InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/start"; export const CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish"; +export const TOTPRegistrationOptionsPath = basePath + "/api/secondfactor/totp/register/options"; +export const TOTPRegistrationPath = basePath + "/api/secondfactor/totp/register"; + export const WebAuthnRegistrationPath = basePath + "/api/secondfactor/webauthn/credential/register"; - export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn"; - export const WebAuthnDevicesPath = basePath + "/api/secondfactor/webauthn/credentials"; export const WebAuthnDevicePath = basePath + "/api/secondfactor/webauthn/credential"; diff --git a/web/src/services/Client.ts b/web/src/services/Client.ts index cffc08d2a..83d82f9ac 100644 --- a/web/src/services/Client.ts +++ b/web/src/services/Client.ts @@ -2,6 +2,16 @@ import axios from "axios"; import { ServiceResponse, hasServiceError, toData } from "@services/Api"; +export async function PutWithOptionalResponse(path: string, body?: any): Promise { + const res = await axios.put>(path, body); + + if (res.status !== 200 || hasServiceError(res).errored) { + throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`); + } + + return toData(res); +} + export async function PostWithOptionalResponse(path: string, body?: any): Promise { const res = await axios.post>(path, body); @@ -12,6 +22,16 @@ export async function PostWithOptionalResponse(path: string, body return toData(res); } +export async function DeleteWithOptionalResponse(path: string, body?: any): Promise { + const res = await axios.delete>(path, body); + + if (res.status !== 200 || hasServiceError(res).errored) { + throw new Error(`Failed DELETE to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`); + } + + return toData(res); +} + export async function Post(path: string, body?: any) { const res = await PostWithOptionalResponse(path, body); if (!res) { @@ -20,6 +40,14 @@ export async function Post(path: string, body?: any) { return res; } +export async function Put(path: string, body?: any) { + const res = await PutWithOptionalResponse(path, body); + if (!res) { + throw new Error("unexpected type of response"); + } + return res; +} + export async function Get(path: string): Promise { const res = await axios.get>(path); diff --git a/web/src/services/OneTimePassword.ts b/web/src/services/OneTimePassword.ts index ec66e9e83..110821aba 100644 --- a/web/src/services/OneTimePassword.ts +++ b/web/src/services/OneTimePassword.ts @@ -1,5 +1,5 @@ -import { CompleteTOTPSignInPath } from "@services/Api"; -import { PostWithOptionalResponse } from "@services/Client"; +import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api"; +import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client"; import { SignInResponse } from "@services/SignIn"; interface CompleteTOTPSignInBody { @@ -19,3 +19,15 @@ export function completeTOTPSignIn(passcode: string, targetURL?: string, workflo return PostWithOptionalResponse(CompleteTOTPSignInPath, body); } + +export function completeTOTPRegister(passcode: string) { + const body: CompleteTOTPSignInBody = { + token: `${passcode}`, + }; + + return PostWithOptionalResponse(TOTPRegistrationPath, body); +} + +export function stopTOTPRegister() { + return DeleteWithOptionalResponse(TOTPRegistrationPath); +} diff --git a/web/src/services/RegisterDevice.ts b/web/src/services/RegisterDevice.ts index 0524f6235..6631e8673 100644 --- a/web/src/services/RegisterDevice.ts +++ b/web/src/services/RegisterDevice.ts @@ -1,5 +1,5 @@ -import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath } from "@services/Api"; -import { Post, PostWithOptionalResponse } from "@services/Client"; +import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath, TOTPRegistrationPath } from "@services/Api"; +import { Post, PostWithOptionalResponse, Put } from "@services/Client"; export async function initiateTOTPRegistrationProcess() { await PostWithOptionalResponse(InitiateTOTPRegistrationPath); @@ -13,3 +13,11 @@ interface CompleteTOTPRegistrationResponse { export async function completeTOTPRegistrationProcess(processToken: string) { return Post(CompleteTOTPRegistrationPath, { token: processToken }); } + +export async function getTOTPSecret(algorithm: string, length: number, period: number) { + return Put(TOTPRegistrationPath, { + algorithm: algorithm, + length: length, + period: period, + }); +} diff --git a/web/src/services/UserInfoTOTPConfiguration.ts b/web/src/services/UserInfoTOTPConfiguration.ts index f32a431d5..55829c7a5 100644 --- a/web/src/services/UserInfoTOTPConfiguration.ts +++ b/web/src/services/UserInfoTOTPConfiguration.ts @@ -1,15 +1,88 @@ -import { UserInfoTOTPConfiguration } from "@models/UserInfoTOTPConfiguration"; -import { UserInfoTOTPConfigurationPath } from "@services/Api"; +import axios from "axios"; + +import { + TOTPAlgorithmPayload, + TOTPDigits, + TOTPOptions, + UserInfoTOTPConfiguration, + toEnum, +} from "@models/TOTPConfiguration"; +import { + AuthenticationOKResponse, + CompleteTOTPSignInPath, + ServiceResponse, + TOTPRegistrationOptionsPath, + UserInfoTOTPConfigurationPath, + validateStatusAuthentication, +} from "@services/Api"; import { Get } from "@services/Client"; -export type TOTPDigits = 6 | 8; - export interface UserInfoTOTPConfigurationPayload { - period: number; + created_at: string; + last_used_at?: string; + issuer: string; + algorithm: TOTPAlgorithmPayload; digits: TOTPDigits; + period: number; +} + +function toUserInfoTOTPConfiguration(payload: UserInfoTOTPConfigurationPayload): UserInfoTOTPConfiguration { + return { + created_at: new Date(payload.created_at), + last_used_at: payload.last_used_at ? new Date(payload.last_used_at) : undefined, + issuer: payload.issuer, + algorithm: toEnum(payload.algorithm), + digits: payload.digits, + period: payload.period, + }; } export async function getUserInfoTOTPConfiguration(): Promise { const res = await Get(UserInfoTOTPConfigurationPath); - return { ...res }; + + return toUserInfoTOTPConfiguration(res); +} + +export async function getUserInfoTOTPConfigurationOptional(): Promise { + const res = await axios.get>(UserInfoTOTPConfigurationPath, { + validateStatus: function (status) { + return status < 300 || status === 404; + }, + }); + + if (res === null || res.status === 404 || res.data.status === "KO") { + return null; + } + + return toUserInfoTOTPConfiguration(res.data.data); +} + +export interface TOTPOptionsPayload { + algorithm: TOTPAlgorithmPayload; + algorithms: TOTPAlgorithmPayload[]; + length: TOTPDigits; + lengths: TOTPDigits[]; + period: number; + periods: number[]; +} + +export async function getTOTPOptions(): Promise { + const res = await Get(TOTPRegistrationOptionsPath); + + return { + algorithm: toEnum(res.algorithm), + algorithms: res.algorithms.map((alg) => toEnum(alg)), + length: res.length, + lengths: res.lengths, + period: res.period, + periods: res.periods, + }; +} + +export async function deleteUserTOTPConfiguration() { + return await axios({ + method: "DELETE", + url: CompleteTOTPSignInPath, + validateStatus: validateStatusAuthentication, + }); } diff --git a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx index 9fbe349f2..059553325 100644 --- a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx +++ b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { IconDefinition, faCopy, faKey, faTimesCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Button, CircularProgress, IconButton, Link, TextField, Theme, Typography } from "@mui/material"; +import { Box, Button, CircularProgress, IconButton, Link, TextField, Theme, Typography } from "@mui/material"; import { red } from "@mui/material/colors"; import makeStyles from "@mui/styles/makeStyles"; import classnames from "classnames"; @@ -92,8 +92,8 @@ const RegisterOneTimePassword = function () { return ( -
-
+ + {translate("Need Google Authenticator?")} @@ -104,15 +104,15 @@ const RegisterOneTimePassword = function () { googlePlayLink={GoogleAuthenticator.googlePlay} appleStoreLink={GoogleAuthenticator.appleStore} /> -
-
+ + {!hasErrored && isLoading ? : null} {hasErrored ? : null} -
-
+ + {secretURL !== "empty" ? ( + -
+ ); }; diff --git a/web/src/views/Settings/SettingsRouter.tsx b/web/src/views/Settings/SettingsRouter.tsx index d0e32bc76..af304e8d5 100644 --- a/web/src/views/Settings/SettingsRouter.tsx +++ b/web/src/views/Settings/SettingsRouter.tsx @@ -14,9 +14,9 @@ export interface Props {} const SettingsRouter = function (props: Props) { const navigate = useRouterNavigate(); + const [state, fetchState, , fetchStateError] = useAutheliaState(); - // Fetch the state on page load useEffect(() => { fetchState(); }, [fetchState]); @@ -24,6 +24,8 @@ const SettingsRouter = function (props: Props) { useEffect(() => { if (fetchStateError || (state && state.authentication_level < AuthenticationLevel.OneFactor)) { navigate(IndexRoute); + + return; } }, [state, fetchStateError, navigate]); @@ -33,7 +35,7 @@ const SettingsRouter = function (props: Props) { } /> } + element={state ? : null} /> diff --git a/web/src/views/Settings/TwoFactorAuthentication/TOTPDevice.tsx b/web/src/views/Settings/TwoFactorAuthentication/TOTPDevice.tsx new file mode 100644 index 000000000..00e28516f --- /dev/null +++ b/web/src/views/Settings/TwoFactorAuthentication/TOTPDevice.tsx @@ -0,0 +1,138 @@ +import React, { Fragment, useState } from "react"; + +import { QrCode2 } from "@mui/icons-material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { Box, Button, CircularProgress, Paper, Stack, Tooltip, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { useNotifications } from "@hooks/NotificationsContext"; +import { UserInfoTOTPConfiguration, toAlgorithmString } from "@models/TOTPConfiguration"; +import { deleteUserTOTPConfiguration } from "@services/UserInfoTOTPConfiguration"; +import DeleteDialog from "@views/Settings/TwoFactorAuthentication/DeleteDialog"; + +interface Props { + index: number; + config: UserInfoTOTPConfiguration; + handleRefresh: () => void; +} + +export default function TOTPDevice(props: Props) { + const { t: translate } = useTranslation("settings"); + + const { createSuccessNotification, createErrorNotification } = useNotifications(); + + const [showDialogDelete, setShowDialogDelete] = useState(false); + + const [loadingDelete, setLoadingDelete] = useState(false); + + const handleDelete = async (ok: boolean) => { + setShowDialogDelete(false); + + if (!ok) { + return; + } + + setLoadingDelete(true); + + const response = await deleteUserTOTPConfiguration(); + + setLoadingDelete(false); + + if (response.data.status === "KO") { + if (response.data.elevation) { + createErrorNotification(translate("You must be elevated to delete the One-Time Password")); + } else if (response.data.authentication) { + createErrorNotification( + translate("You must have a higher authentication level to delete the One-Time Password"), + ); + } else { + createErrorNotification(translate("There was a problem deleting the One-Time Password")); + } + + return; + } + + createSuccessNotification(translate("Successfully deleted the One Time Password configuration")); + + props.handleRefresh(); + }; + + return ( + + + + + + + + + + {props.config.issuer} + + + {" (" + + translate("{{algorithm}}, {{digits}} digits, {{seconds}} seconds", { + algorithm: toAlgorithmString(props.config.algorithm), + digits: props.config.digits, + seconds: props.config.period, + }) + + ")"} + + + + {translate("Added", { + when: props.config.created_at, + formatParams: { + when: { + hour: "numeric", + minute: "numeric", + year: "numeric", + month: "long", + day: "numeric", + }, + }, + })} + + + {props.config.last_used_at === undefined + ? translate("Never used") + : translate("Last Used", { + when: props.config.last_used_at, + formatParams: { + when: { + hour: "numeric", + minute: "numeric", + year: "numeric", + month: "long", + day: "numeric", + }, + }, + })} + + + + + + + + + + + ); +} diff --git a/web/src/views/Settings/TwoFactorAuthentication/TOTPPanel.tsx b/web/src/views/Settings/TwoFactorAuthentication/TOTPPanel.tsx new file mode 100644 index 000000000..a9269ea3d --- /dev/null +++ b/web/src/views/Settings/TwoFactorAuthentication/TOTPPanel.tsx @@ -0,0 +1,70 @@ +import React, { Fragment, useState } from "react"; + +import { Button, Grid, Paper, Stack, Tooltip, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { UserInfoTOTPConfiguration } from "@models/TOTPConfiguration"; +import TOTPDevice from "@views/Settings/TwoFactorAuthentication/TOTPDevice"; +import TOTPRegisterDialogController from "@views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController"; + +interface Props { + config: UserInfoTOTPConfiguration | undefined | null; + handleRefreshState: () => void; +} + +export default function TOTPPanel(props: Props) { + const { t: translate } = useTranslation("settings"); + + const [showRegisterDialog, setShowRegisterDialog] = useState(false); + + return ( + + { + setShowRegisterDialog(false); + props.handleRefreshState(); + }} + /> + + + + {translate("One Time Password")} + + {props.config === undefined || props.config === null ? ( + + + + + + + + + {translate( + "The One Time Password has not been registered. If you'd like to register it click add.", + )} + + + + ) : ( + + + + + + )} + + + + ); +} diff --git a/web/src/views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController.tsx b/web/src/views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController.tsx new file mode 100644 index 000000000..b6fdb21fa --- /dev/null +++ b/web/src/views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController.tsx @@ -0,0 +1,512 @@ +import React, { Fragment, useCallback, useEffect, useState } from "react"; + +import { IconDefinition, faCopy, faKey, faTimesCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormLabel, + Grid, + IconButton, + Link, + Radio, + RadioGroup, + Step, + StepLabel, + Stepper, + TextField, + Theme, + Typography, +} from "@mui/material"; +import { red } from "@mui/material/colors"; +import makeStyles from "@mui/styles/makeStyles"; +import classnames from "classnames"; +import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; + +import AppStoreBadges from "@components/AppStoreBadges"; +import { GoogleAuthenticator } from "@constants/constants"; +import { useNotifications } from "@hooks/NotificationsContext"; +import { TOTPOptions, toAlgorithmString } from "@models/TOTPConfiguration"; +import { completeTOTPRegister, stopTOTPRegister } from "@services/OneTimePassword"; +import { getTOTPSecret } from "@services/RegisterDevice"; +import { getTOTPOptions } from "@services/UserInfoTOTPConfiguration"; +import { State } from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod"; +import OTPDial from "@views/LoginPortal/SecondFactor/OTPDial"; + +const steps = ["Start", "Scan QR Code", "Confirmation"]; + +interface Props { + open: boolean; + setClosed: () => void; +} + +export default function TOTPRegisterDialogController(props: Props) { + const { t: translate } = useTranslation("settings"); + + const styles = useStyles(); + const { createErrorNotification, createSuccessNotification } = useNotifications(); + + const [activeStep, setActiveStep] = useState(0); + const [options, setOptions] = useState(null); + const [optionAlgorithm, setOptionAlgorithm] = useState(""); + const [optionLength, setOptionLength] = useState(6); + const [optionPeriod, setOptionPeriod] = useState(30); + const [optionAlgorithms, setOptionAlgorithms] = useState([]); + const [optionLengths, setOptionLengths] = useState([]); + const [optionPeriods, setOptionPeriods] = useState([]); + const [totpSecretURL, setTOTPSecretURL] = useState(""); + const [totpSecretBase32, setTOTPSecretBase32] = useState(undefined); + const [totpIsLoading, setTOTPIsLoading] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [hasErrored, setHasErrored] = useState(false); + const [dialValue, setDialValue] = useState(""); + const [dialState, setDialState] = useState(State.Idle); + + const resetStates = () => { + setOptions(null); + setOptionAlgorithm(""); + setOptionLength(6); + setOptionPeriod(30); + setOptionAlgorithms([]); + setOptionLengths([]); + setOptionPeriods([]); + setTOTPSecretURL(""); + setTOTPSecretBase32(undefined); + setTOTPIsLoading(false); + setShowAdvanced(false); + setActiveStep(0); + setDialValue(""); + setDialState(State.Idle); + }; + + const handleClose = useCallback(() => { + (async () => { + props.setClosed(); + + if (totpSecretURL !== "") { + try { + await stopTOTPRegister(); + } catch (err) { + console.error(err); + } + } + + resetStates(); + })(); + }, [totpSecretURL, props]); + + const handleOnClose = () => { + if (!props.open) { + return; + } + + handleClose(); + }; + + useEffect(() => { + if (!props.open || activeStep !== 0) { + return; + } + + (async () => { + const opts = await getTOTPOptions(); + setOptions(opts); + setOptionAlgorithm(toAlgorithmString(opts.algorithm)); + setOptionAlgorithms(opts.algorithms.map((algorithm) => toAlgorithmString(algorithm))); + setOptionLength(opts.length); + setOptionLengths(opts.lengths.map((length) => length.toString())); + setOptionPeriod(opts.period); + setOptionPeriods(opts.periods.map((period) => period.toString())); + })(); + }, [props.open, activeStep]); + + const handleSetStepPrevious = useCallback(() => { + if (activeStep === 0) { + return; + } + + setActiveStep((prevState) => (prevState -= 1)); + }, [activeStep]); + + const handleSetStepNext = useCallback(() => { + if (activeStep === steps.length - 1) { + return; + } + + setActiveStep((prevState) => (prevState += 1)); + }, [activeStep]); + + useEffect(() => { + if (!props.open || activeStep !== 1) { + return; + } + + (async () => { + setTOTPIsLoading(true); + + try { + const secret = await getTOTPSecret(optionAlgorithm, optionLength, optionPeriod); + setTOTPSecretURL(secret.otpauth_url); + setTOTPSecretBase32(secret.base32_secret); + } catch (err) { + console.error(err); + if ((err as Error).message.includes("Request failed with status code 403")) { + createErrorNotification( + translate( + "You must open the link from the same device and browser that initiated the registration process", + ), + ); + } else { + createErrorNotification( + translate("Failed to register device, the provided link is expired or has already been used"), + ); + } + setHasErrored(true); + } + + setTOTPIsLoading(false); + })(); + }, [activeStep, createErrorNotification, optionAlgorithm, optionLength, optionPeriod, props.open, translate]); + + useEffect(() => { + if (!props.open || activeStep !== 2 || dialValue.length !== optionLength) { + return; + } + + (async () => { + setDialState(State.InProgress); + + try { + await completeTOTPRegister(dialValue); + setDialState(State.Success); + } catch (err) { + console.error(err); + setDialState(State.Failure); + } + })(); + }, [activeStep, dialValue, dialValue.length, optionLength, props.open]); + + const toggleAdvanced = () => { + setShowAdvanced((prevState) => !prevState); + }; + + const advanced = + options !== null && + (optionAlgorithms.length !== 1 || optionAlgorithms.length !== 1 || optionPeriods.length !== 1); + + const hideAdvanced = + options === null || (optionAlgorithms.length <= 1 && optionPeriods.length <= 1 && optionLengths.length <= 1); + + const hideAlgorithms = advanced && optionAlgorithms.length <= 1; + const hideLengths = advanced && optionLengths.length <= 1; + const hidePeriods = advanced && optionPeriods.length <= 1; + const qrcodeFuzzyStyle = totpIsLoading || hasErrored ? styles.fuzzy : undefined; + + function SecretButton(text: string | undefined, action: string, icon: IconDefinition) { + return ( + { + navigator.clipboard.writeText(`${text}`); + createSuccessNotification(`${action}`); + }} + size="large" + > + + + ); + } + + function renderStep(step: number) { + switch (step) { + case 0: + return ( + + {options === null ? ( + + Loading... + + ) : ( + + + {translate("To begin select next")} + + + + + )} + + ); + case 1: + return ( + + + + + {translate("Need Google Authenticator?")} + + + + + + + + + {!hasErrored && totpIsLoading ? ( + + ) : null} + {hasErrored ? ( + + ) : null} + + + + + + + {totpSecretURL !== "empty" ? ( + + ) : null} + + + {totpSecretBase32 + ? SecretButton( + totpSecretBase32, + translate("OTP Secret copied to clipboard"), + faKey, + ) + : null} + + + {totpSecretURL !== "empty" + ? SecretButton(totpSecretURL, translate("OTP URL copied to clipboard"), faCopy) + : null} + + + + + ); + case 2: + return ( + + + + ); + } + } + + return ( + + {translate("Register One Time Password (TOTP)")} + + + + + {steps.map((label, index) => { + const stepProps: { completed?: boolean } = {}; + const labelProps: { + optional?: React.ReactNode; + } = {}; + return ( + + {translate(label)} + + ); + })} + + + + + {renderStep(activeStep)} + + + + + + + + + + + ); +} + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + paddingTop: theme.spacing(4), + paddingBottom: theme.spacing(4), + }, + qrcode: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + padding: theme.spacing(), + backgroundColor: "white", + }, + fuzzy: { + filter: "blur(10px)", + }, + secret: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + width: "256px", + }, + googleAuthenticator: {}, + googleAuthenticatorText: { + fontSize: theme.typography.fontSize * 0.8, + }, + googleAuthenticatorBadges: {}, + secretButtons: {}, + doneButton: { + width: "256px", + }, + qrcodeContainer: { + position: "relative", + display: "inline-block", + }, + loader: { + position: "absolute", + top: "calc(128px - 64px)", + left: "calc(128px - 64px)", + color: "rgba(255, 255, 255, 0.5)", + }, + failureIcon: { + position: "absolute", + top: "calc(128px - 64px)", + left: "calc(128px - 64px)", + color: red[400], + fontSize: "128px", + }, +})); diff --git a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx index fafd150de..839dcc51c 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx @@ -4,7 +4,9 @@ import Grid from "@mui/material/Unstable_Grid2"; import { useNotifications } from "@hooks/NotificationsContext"; import { useUserInfoPOST } from "@hooks/UserInfo"; +import { useUserInfoTOTPConfiguration, useUserInfoTOTPConfigurationOptional } from "@hooks/UserInfoTOTPConfiguration"; import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices"; +import TOTPPanel from "@views/Settings/TwoFactorAuthentication/TOTPPanel"; import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel"; interface Props {} @@ -13,6 +15,7 @@ export default function TwoFactorAuthSettings(props: Props) { const [refreshState, setRefreshState] = useState(0); const { createErrorNotification } = useNotifications(); const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST(); + const [userTOTPConfig, fetchUserTOTPConfig, , fetchUserTOTPConfigError] = useUserInfoTOTPConfigurationOptional(); const [userWebAuthnDevices, fetchUserWebAuthnDevices, , fetchUserWebAuthnDevicesError] = useUserWebAuthnDevices(); const [hasTOTP, setHasTOTP] = useState(false); const [hasWebAuthn, setHasWebAuthn] = useState(false); @@ -39,6 +42,10 @@ export default function TwoFactorAuthSettings(props: Props) { } }, [hasTOTP, hasWebAuthn, userInfo]); + useEffect(() => { + fetchUserTOTPConfig(); + }, [fetchUserTOTPConfig, hasTOTP]); + useEffect(() => { fetchUserWebAuthnDevices(); }, [fetchUserWebAuthnDevices, hasWebAuthn]); @@ -49,6 +56,12 @@ export default function TwoFactorAuthSettings(props: Props) { } }, [fetchUserInfoError, createErrorNotification]); + useEffect(() => { + if (fetchUserTOTPConfigError) { + createErrorNotification("There was an issue retrieving One Time Password Configuration"); + } + }, [fetchUserTOTPConfigError, createErrorNotification]); + useEffect(() => { if (fetchUserWebAuthnDevicesError) { createErrorNotification("There was an issue retrieving One Time Password Configuration"); @@ -57,6 +70,9 @@ export default function TwoFactorAuthSettings(props: Props) { return ( + + +