refactor: totp
parent
873749a28f
commit
bf5272dc61
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -154,6 +154,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",
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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() != ""),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ?;`
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
###############################################################
|
||||
|
||||
jwt_secret: unsecure_secret
|
||||
theme: auto
|
||||
theme: dark
|
||||
|
||||
server:
|
||||
port: 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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -78,6 +78,7 @@ const App: React.FC<Props> = (props: Props) => {
|
|||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CacheProvider value={cache}>
|
||||
<ThemeProvider theme={theme}>
|
||||
|
|
|
@ -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 (
|
||||
<Fragment>
|
||||
<div className={props.ready ? "hidden" : ""}>
|
||||
<LoadingPage />
|
||||
</div>
|
||||
{props.ready ? props.children : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComponentOrLoading;
|
|
@ -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, []);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import { useRemoteCall } from "@hooks/RemoteCall";
|
||||
import { getUserWebauthnDevices } from "@services/UserWebauthnDevices";
|
||||
|
||||
export function useUserWebauthnDevices() {
|
||||
return useRemoteCall(getUserWebauthnDevices, []);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export interface UserInfoTOTPConfiguration {
|
||||
period: number;
|
||||
digits: number;
|
||||
}
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -2,6 +2,16 @@ import axios from "axios";
|
|||
|
||||
import { ServiceResponse, hasServiceError, toData } from "@services/Api";
|
||||
|
||||
export async function PutWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
|
||||
const res = await axios.put<ServiceResponse<T>>(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<T>(res);
|
||||
}
|
||||
|
||||
export async function PostWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
|
||||
const res = await axios.post<ServiceResponse<T>>(path, body);
|
||||
|
||||
|
@ -12,6 +22,16 @@ export async function PostWithOptionalResponse<T = undefined>(path: string, body
|
|||
return toData<T>(res);
|
||||
}
|
||||
|
||||
export async function DeleteWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
|
||||
const res = await axios.delete<ServiceResponse<T>>(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<T>(res);
|
||||
}
|
||||
|
||||
export async function Post<T>(path: string, body?: any) {
|
||||
const res = await PostWithOptionalResponse<T>(path, body);
|
||||
if (!res) {
|
||||
|
@ -20,6 +40,14 @@ export async function Post<T>(path: string, body?: any) {
|
|||
return res;
|
||||
}
|
||||
|
||||
export async function Put<T>(path: string, body?: any) {
|
||||
const res = await PutWithOptionalResponse<T>(path, body);
|
||||
if (!res) {
|
||||
throw new Error("unexpected type of response");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function Get<T = undefined>(path: string): Promise<T> {
|
||||
const res = await axios.get<ServiceResponse<T>>(path);
|
||||
|
||||
|
|
|
@ -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<SignInResponse>(CompleteTOTPSignInPath, body);
|
||||
}
|
||||
|
||||
export function completeTOTPRegister(passcode: string) {
|
||||
const body: CompleteTOTPSignInBody = {
|
||||
token: `${passcode}`,
|
||||
};
|
||||
|
||||
return PostWithOptionalResponse(TOTPRegistrationPath, body);
|
||||
}
|
||||
|
||||
export function stopTOTPRegister() {
|
||||
return DeleteWithOptionalResponse(TOTPRegistrationPath);
|
||||
}
|
||||
|
|
|
@ -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<CompleteTOTPRegistrationResponse>(CompleteTOTPRegistrationPath, { token: processToken });
|
||||
}
|
||||
|
||||
export async function getTOTPSecret(algorithm: string, length: number, period: number) {
|
||||
return Put<CompleteTOTPRegistrationResponse>(TOTPRegistrationPath, {
|
||||
algorithm: algorithm,
|
||||
length: length,
|
||||
period: period,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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<UserInfoTOTPConfiguration> {
|
||||
const res = await Get<UserInfoTOTPConfigurationPayload>(UserInfoTOTPConfigurationPath);
|
||||
return { ...res };
|
||||
|
||||
return toUserInfoTOTPConfiguration(res);
|
||||
}
|
||||
|
||||
export async function getUserInfoTOTPConfigurationOptional(): Promise<UserInfoTOTPConfiguration | null> {
|
||||
const res = await axios.get<ServiceResponse<UserInfoTOTPConfigurationPayload>>(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<TOTPOptions> {
|
||||
const res = await Get<TOTPOptionsPayload>(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<AuthenticationOKResponse>({
|
||||
method: "DELETE",
|
||||
url: CompleteTOTPSignInPath,
|
||||
validateStatus: validateStatusAuthentication,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<LoginLayout title={translate("Scan QR Code")}>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.googleAuthenticator}>
|
||||
<Box className={styles.root}>
|
||||
<Box className={styles.googleAuthenticator}>
|
||||
<Typography className={styles.googleAuthenticatorText}>
|
||||
{translate("Need Google Authenticator?")}
|
||||
</Typography>
|
||||
|
@ -104,15 +104,15 @@ const RegisterOneTimePassword = function () {
|
|||
googlePlayLink={GoogleAuthenticator.googlePlay}
|
||||
appleStoreLink={GoogleAuthenticator.appleStore}
|
||||
/>
|
||||
</div>
|
||||
<div className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}>
|
||||
</Box>
|
||||
<Box className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}>
|
||||
<Link href={secretURL} underline="hover">
|
||||
<QRCodeSVG value={secretURL} className={styles.qrcode} size={256} />
|
||||
{!hasErrored && isLoading ? <CircularProgress className={styles.loader} size={128} /> : null}
|
||||
{hasErrored ? <FontAwesomeIcon className={styles.failureIcon} icon={faTimesCircle} /> : null}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
</Box>
|
||||
<Box>
|
||||
{secretURL !== "empty" ? (
|
||||
<TextField
|
||||
id="secret-url"
|
||||
|
@ -130,7 +130,7 @@ const RegisterOneTimePassword = function () {
|
|||
{secretURL !== "empty"
|
||||
? SecretButton(secretURL, translate("OTP URL copied to clipboard"), faCopy)
|
||||
: null}
|
||||
</div>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
|
@ -140,7 +140,7 @@ const RegisterOneTimePassword = function () {
|
|||
>
|
||||
{translate("Done")}
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</LoginLayout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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) {
|
|||
<Route path={IndexRoute} element={<SettingsView />} />
|
||||
<Route
|
||||
path={SettingsTwoFactorAuthenticationSubRoute}
|
||||
element={<TwoFactorAuthenticationView state={state} />}
|
||||
element={state ? <TwoFactorAuthenticationView /> : null}
|
||||
/>
|
||||
</Routes>
|
||||
</SettingsLayout>
|
||||
|
|
|
@ -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<boolean>(false);
|
||||
|
||||
const [loadingDelete, setLoadingDelete] = useState<boolean>(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 (
|
||||
<Fragment>
|
||||
<Paper variant="outlined">
|
||||
<Box sx={{ p: 3 }}>
|
||||
<DeleteDialog
|
||||
open={showDialogDelete}
|
||||
handleClose={handleDelete}
|
||||
title={translate("Remove One Time Password")}
|
||||
text={translate(
|
||||
"Are you sure you want to remove the Time-based One Time Password from from your account",
|
||||
)}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<QrCode2 fontSize="large" />
|
||||
<Stack spacing={0} sx={{ minWidth: 400 }}>
|
||||
<Box>
|
||||
<Typography display="inline" sx={{ fontWeight: "bold" }}>
|
||||
{props.config.issuer}
|
||||
</Typography>
|
||||
<Typography display="inline" variant="body2">
|
||||
{" (" +
|
||||
translate("{{algorithm}}, {{digits}} digits, {{seconds}} seconds", {
|
||||
algorithm: toAlgorithmString(props.config.algorithm),
|
||||
digits: props.config.digits,
|
||||
seconds: props.config.period,
|
||||
}) +
|
||||
")"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant={"caption"}>
|
||||
{translate("Added", {
|
||||
when: props.config.created_at,
|
||||
formatParams: {
|
||||
when: {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
},
|
||||
})}
|
||||
</Typography>
|
||||
<Typography variant={"caption"}>
|
||||
{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",
|
||||
},
|
||||
},
|
||||
})}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Tooltip title={translate("Remove the Time-based One Time Password configuration")}>
|
||||
<Button
|
||||
variant={"outlined"}
|
||||
color={"error"}
|
||||
startIcon={
|
||||
loadingDelete ? <CircularProgress color="inherit" size={20} /> : <DeleteIcon />
|
||||
}
|
||||
onClick={loadingDelete ? undefined : () => setShowDialogDelete(true)}
|
||||
>
|
||||
{translate("Remove")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
|
@ -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<boolean>(false);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TOTPRegisterDialogController
|
||||
open={showRegisterDialog}
|
||||
setClosed={() => {
|
||||
setShowRegisterDialog(false);
|
||||
props.handleRefreshState();
|
||||
}}
|
||||
/>
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">{translate("One Time Password")}</Typography>
|
||||
</Grid>
|
||||
{props.config === undefined || props.config === null ? (
|
||||
<Fragment>
|
||||
<Grid item xs={12}>
|
||||
<Tooltip
|
||||
title={translate("Click to add a Time-based One Time Password to your account")}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setShowRegisterDialog(true);
|
||||
}}
|
||||
>
|
||||
{translate("Add")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant={"subtitle2"}>
|
||||
{translate(
|
||||
"The One Time Password has not been registered. If you'd like to register it click add.",
|
||||
)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={2}>
|
||||
<TOTPDevice index={0} config={props.config} handleRefresh={props.handleRefreshState} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
|
@ -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<TOTPOptions | null>(null);
|
||||
const [optionAlgorithm, setOptionAlgorithm] = useState("");
|
||||
const [optionLength, setOptionLength] = useState(6);
|
||||
const [optionPeriod, setOptionPeriod] = useState(30);
|
||||
const [optionAlgorithms, setOptionAlgorithms] = useState<string[]>([]);
|
||||
const [optionLengths, setOptionLengths] = useState<string[]>([]);
|
||||
const [optionPeriods, setOptionPeriods] = useState<string[]>([]);
|
||||
const [totpSecretURL, setTOTPSecretURL] = useState("");
|
||||
const [totpSecretBase32, setTOTPSecretBase32] = useState<string | undefined>(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 (
|
||||
<IconButton
|
||||
className={styles.secretButtons}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${text}`);
|
||||
createSuccessNotification(`${action}`);
|
||||
}}
|
||||
size="large"
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep(step: number) {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<Fragment>
|
||||
{options === null ? (
|
||||
<Grid item xs={12}>
|
||||
<Typography>Loading...</Typography>
|
||||
</Grid>
|
||||
) : (
|
||||
<Fragment>
|
||||
<Grid item xs={12}>
|
||||
<Typography>{translate("To begin select next")}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} hidden={hideAdvanced}>
|
||||
<Button variant={"outlined"} color={"warning"} onClick={toggleAdvanced}>
|
||||
{showAdvanced ? translate("Hide Advanced") : translate("Show Advanced")}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
hidden={hideAdvanced || !showAdvanced}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
>
|
||||
<FormControl fullWidth>
|
||||
<FormLabel id={"lbl-adv-algorithms"} hidden={hideAlgorithms}>
|
||||
{translate("Algorithm")}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
aria-labelledby={"lbl-adv-algorithms"}
|
||||
value={optionAlgorithm}
|
||||
hidden={hideAlgorithms}
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onChange={(e, value) => {
|
||||
setOptionAlgorithm(value);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{optionAlgorithms.map((algorithm) => (
|
||||
<FormControlLabel
|
||||
key={algorithm}
|
||||
value={algorithm}
|
||||
control={<Radio />}
|
||||
label={algorithm}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<FormLabel id={"lbl-adv-lengths"} hidden={hideLengths}>
|
||||
{translate("Length")}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
aria-labelledby={"lbl-adv-lengths"}
|
||||
value={optionLength.toString()}
|
||||
hidden={hideLengths}
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onChange={(e, value) => {
|
||||
setOptionLength(parseInt(value));
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{optionLengths.map((length) => (
|
||||
<FormControlLabel
|
||||
key={length}
|
||||
value={length}
|
||||
control={<Radio />}
|
||||
label={length}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<FormLabel id={"lbl-adv-periods"} hidden={hidePeriods}>
|
||||
{translate("Seconds")}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
aria-labelledby={"lbl-adv-periods"}
|
||||
value={optionPeriod.toString()}
|
||||
hidden={hidePeriods}
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onChange={(e, value) => {
|
||||
setOptionPeriod(parseInt(value));
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{optionPeriods.map((period) => (
|
||||
<FormControlLabel
|
||||
key={period}
|
||||
value={period}
|
||||
control={<Radio />}
|
||||
label={period}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<Fragment>
|
||||
<Grid item xs={12}>
|
||||
<Box className={styles.googleAuthenticator}>
|
||||
<Typography className={styles.googleAuthenticatorText}>
|
||||
{translate("Need Google Authenticator?")}
|
||||
</Typography>
|
||||
<AppStoreBadges
|
||||
iconSize={128}
|
||||
targetBlank
|
||||
className={styles.googleAuthenticatorBadges}
|
||||
googlePlayLink={GoogleAuthenticator.googlePlay}
|
||||
appleStoreLink={GoogleAuthenticator.appleStore}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Box className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}>
|
||||
<Link href={totpSecretURL} underline="hover">
|
||||
<QRCodeSVG value={totpSecretURL} className={styles.qrcode} size={256} />
|
||||
{!hasErrored && totpIsLoading ? (
|
||||
<CircularProgress className={styles.loader} size={128} />
|
||||
) : null}
|
||||
{hasErrored ? (
|
||||
<FontAwesomeIcon className={styles.failureIcon} icon={faTimesCircle} />
|
||||
) : null}
|
||||
</Link>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid container spacing={2} justifyContent={"center"}>
|
||||
<Grid item xs={12}>
|
||||
{totpSecretURL !== "empty" ? (
|
||||
<TextField
|
||||
id="secret-url"
|
||||
label={translate("Secret")}
|
||||
className={styles.secret}
|
||||
value={totpSecretURL}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
{totpSecretBase32
|
||||
? SecretButton(
|
||||
totpSecretBase32,
|
||||
translate("OTP Secret copied to clipboard"),
|
||||
faKey,
|
||||
)
|
||||
: null}
|
||||
</Grid>
|
||||
<Grid item xs={2}>
|
||||
{totpSecretURL !== "empty"
|
||||
? SecretButton(totpSecretURL, translate("OTP URL copied to clipboard"), faCopy)
|
||||
: null}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Fragment>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Grid item xs={12}>
|
||||
<OTPDial
|
||||
passcode={dialValue}
|
||||
state={dialState}
|
||||
digits={optionLength}
|
||||
period={optionPeriod}
|
||||
onChange={setDialValue}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onClose={handleOnClose} maxWidth={"xs"} fullWidth={true}>
|
||||
<DialogTitle>{translate("Register One Time Password (TOTP)")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}>
|
||||
<Grid item xs={12}>
|
||||
<Stepper activeStep={activeStep}>
|
||||
{steps.map((label, index) => {
|
||||
const stepProps: { completed?: boolean } = {};
|
||||
const labelProps: {
|
||||
optional?: React.ReactNode;
|
||||
} = {};
|
||||
return (
|
||||
<Step key={label} {...stepProps}>
|
||||
<StepLabel {...labelProps}>{translate(label)}</StepLabel>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Grid container spacing={2} paddingY={3} justifyContent={"center"}>
|
||||
{renderStep(activeStep)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant={"outlined"}
|
||||
color={"primary"}
|
||||
onClick={handleSetStepPrevious}
|
||||
disabled={activeStep === 0}
|
||||
>
|
||||
{translate("Previous")}
|
||||
</Button>
|
||||
<Button variant={"outlined"} color={"primary"} onClick={handleClose}>
|
||||
{translate("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"outlined"}
|
||||
color={"primary"}
|
||||
onClick={handleSetStepNext}
|
||||
disabled={activeStep === steps.length - 1}
|
||||
>
|
||||
{translate("Next")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}));
|
|
@ -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 (
|
||||
<Grid container spacing={2}>
|
||||
<Grid xs={12}>
|
||||
<TOTPPanel config={userTOTPConfig} handleRefreshState={handleRefreshState} />
|
||||
</Grid>
|
||||
<Grid xs={12}>
|
||||
<WebAuthnDevicesPanel devices={userWebAuthnDevices} handleRefreshState={handleRefreshState} />
|
||||
</Grid>
|
||||
|
|
Loading…
Reference in New Issue