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)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -154,6 +154,9 @@ var Keys = []string{
|
||||||
"totp.period",
|
"totp.period",
|
||||||
"totp.skew",
|
"totp.skew",
|
||||||
"totp.secret_size",
|
"totp.secret_size",
|
||||||
|
"totp.allowed_algorithms",
|
||||||
|
"totp.allowed_digits",
|
||||||
|
"totp.allowed_periods",
|
||||||
"duo_api.disable",
|
"duo_api.disable",
|
||||||
"duo_api.hostname",
|
"duo_api.hostname",
|
||||||
"duo_api.integration_key",
|
"duo_api.integration_key",
|
||||||
|
|
|
@ -2,23 +2,27 @@ package schema
|
||||||
|
|
||||||
// TOTPConfiguration represents the configuration related to TOTP options.
|
// TOTPConfiguration represents the configuration related to TOTP options.
|
||||||
type TOTPConfiguration struct {
|
type TOTPConfiguration struct {
|
||||||
Disable bool `koanf:"disable"`
|
Disable bool `koanf:"disable"`
|
||||||
Issuer string `koanf:"issuer"`
|
Issuer string `koanf:"issuer"`
|
||||||
Algorithm string `koanf:"algorithm"`
|
DefaultAlgorithm string `koanf:"algorithm"`
|
||||||
Digits uint `koanf:"digits"`
|
DefaultDigits int `koanf:"digits"`
|
||||||
Period uint `koanf:"period"`
|
DefaultPeriod int `koanf:"period"`
|
||||||
Skew *uint `koanf:"skew"`
|
Skew *int `koanf:"skew"`
|
||||||
SecretSize uint `koanf:"secret_size"`
|
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.
|
// DefaultTOTPConfiguration represents default configuration parameters for TOTP generation.
|
||||||
var DefaultTOTPConfiguration = TOTPConfiguration{
|
var DefaultTOTPConfiguration = TOTPConfiguration{
|
||||||
Issuer: "Authelia",
|
Issuer: "Authelia",
|
||||||
Algorithm: TOTPAlgorithmSHA1,
|
DefaultAlgorithm: TOTPAlgorithmSHA1,
|
||||||
Digits: 6,
|
DefaultDigits: 6,
|
||||||
Period: 30,
|
DefaultPeriod: 30,
|
||||||
Skew: &defaultOtpSkew,
|
Skew: &defaultTOTPSkew,
|
||||||
SecretSize: TOTPSecretSizeDefault,
|
SecretSize: TOTPSecretSizeDefault,
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"github.com/authelia/authelia/v4/internal/utils"
|
"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) {
|
func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidator) {
|
||||||
if config.TOTP.Disable {
|
if config.TOTP.Disable {
|
||||||
return
|
return
|
||||||
|
@ -18,26 +18,73 @@ func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidato
|
||||||
config.TOTP.Issuer = schema.DefaultTOTPConfiguration.Issuer
|
config.TOTP.Issuer = schema.DefaultTOTPConfiguration.Issuer
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.TOTP.Algorithm == "" {
|
if config.TOTP.DefaultAlgorithm == "" {
|
||||||
config.TOTP.Algorithm = schema.DefaultTOTPConfiguration.Algorithm
|
config.TOTP.DefaultAlgorithm = schema.DefaultTOTPConfiguration.DefaultAlgorithm
|
||||||
} else {
|
} 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) {
|
if !utils.IsStringInSlice(config.TOTP.DefaultAlgorithm, schema.TOTPPossibleAlgorithms) {
|
||||||
validator.Push(fmt.Errorf(errFmtTOTPInvalidAlgorithm, strJoinOr(schema.TOTPPossibleAlgorithms), config.TOTP.Algorithm))
|
validator.Push(fmt.Errorf(errFmtTOTPInvalidAlgorithm, strings.Join(schema.TOTPPossibleAlgorithms, "', '"), config.TOTP.DefaultAlgorithm))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.TOTP.Period == 0 {
|
for i, algorithm := range config.TOTP.AllowedAlgorithms {
|
||||||
config.TOTP.Period = schema.DefaultTOTPConfiguration.Period
|
config.TOTP.AllowedAlgorithms[i] = strings.ToUpper(algorithm)
|
||||||
} else if config.TOTP.Period < 15 {
|
|
||||||
validator.Push(fmt.Errorf(errFmtTOTPInvalidPeriod, config.TOTP.Period))
|
// 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 {
|
if !utils.IsStringInSlice(config.TOTP.DefaultAlgorithm, config.TOTP.AllowedAlgorithms) {
|
||||||
config.TOTP.Digits = schema.DefaultTOTPConfiguration.Digits
|
config.TOTP.AllowedAlgorithms = append(config.TOTP.AllowedAlgorithms, config.TOTP.DefaultAlgorithm)
|
||||||
} else if config.TOTP.Digits != 6 && config.TOTP.Digits != 8 {
|
}
|
||||||
validator.Push(fmt.Errorf(errFmtTOTPInvalidDigits, config.TOTP.Digits))
|
|
||||||
|
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 {
|
if config.TOTP.Skew == nil {
|
||||||
|
|
|
@ -30,31 +30,31 @@ func TestValidateTOTP(t *testing.T) {
|
||||||
{
|
{
|
||||||
desc: "ShouldNormalizeTOTPAlgorithm",
|
desc: "ShouldNormalizeTOTPAlgorithm",
|
||||||
have: schema.TOTPConfiguration{
|
have: schema.TOTPConfiguration{
|
||||||
Algorithm: digestSHA1,
|
DefaultAlgorithm: digestSHA1,
|
||||||
Digits: 6,
|
DefaultDigits: 6,
|
||||||
Period: 30,
|
DefaultPeriod: 30,
|
||||||
SecretSize: 32,
|
SecretSize: 32,
|
||||||
Skew: schema.DefaultTOTPConfiguration.Skew,
|
Skew: schema.DefaultTOTPConfiguration.Skew,
|
||||||
Issuer: "abc",
|
Issuer: "abc",
|
||||||
},
|
},
|
||||||
expected: schema.TOTPConfiguration{
|
expected: schema.TOTPConfiguration{
|
||||||
Algorithm: "SHA1",
|
DefaultAlgorithm: "SHA1",
|
||||||
Digits: 6,
|
DefaultDigits: 6,
|
||||||
Period: 30,
|
DefaultPeriod: 30,
|
||||||
SecretSize: 32,
|
SecretSize: 32,
|
||||||
Skew: schema.DefaultTOTPConfiguration.Skew,
|
Skew: schema.DefaultTOTPConfiguration.Skew,
|
||||||
Issuer: "abc",
|
Issuer: "abc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
desc: "ShouldRaiseErrorWhenInvalidTOTPAlgorithm",
|
desc: "ShouldRaiseErrorWhenInvalidTOTPAlgorithm",
|
||||||
have: schema.TOTPConfiguration{
|
have: schema.TOTPConfiguration{
|
||||||
Algorithm: "sha3",
|
DefaultAlgorithm: "sha3",
|
||||||
Digits: 6,
|
DefaultDigits: 6,
|
||||||
Period: 30,
|
DefaultPeriod: 30,
|
||||||
SecretSize: 32,
|
SecretSize: 32,
|
||||||
Skew: schema.DefaultTOTPConfiguration.Skew,
|
Skew: schema.DefaultTOTPConfiguration.Skew,
|
||||||
Issuer: "abc",
|
Issuer: "abc",
|
||||||
},
|
},
|
||||||
errs: []string{
|
errs: []string{
|
||||||
"totp: option 'algorithm' must be one of 'SHA1', 'SHA256', or 'SHA512' but it's configured as 'SHA3'",
|
"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",
|
desc: "ShouldRaiseErrorWhenInvalidTOTPValue",
|
||||||
have: schema.TOTPConfiguration{
|
have: schema.TOTPConfiguration{
|
||||||
Algorithm: "sha3",
|
DefaultAlgorithm: "sha3",
|
||||||
Period: 5,
|
DefaultPeriod: 5,
|
||||||
Digits: 20,
|
DefaultDigits: 20,
|
||||||
SecretSize: 10,
|
SecretSize: 10,
|
||||||
Skew: schema.DefaultTOTPConfiguration.Skew,
|
Skew: schema.DefaultTOTPConfiguration.Skew,
|
||||||
Issuer: "abc",
|
Issuer: "abc",
|
||||||
},
|
},
|
||||||
errs: []string{
|
errs: []string{
|
||||||
"totp: option 'algorithm' must be one of 'SHA1', 'SHA256', or 'SHA512' but it's configured as 'SHA3'",
|
"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.Len(t, warns, 0)
|
||||||
assert.Equal(t, tc.expected.Disable, config.TOTP.Disable)
|
assert.Equal(t, tc.expected.Disable, config.TOTP.Disable)
|
||||||
assert.Equal(t, tc.expected.Issuer, config.TOTP.Issuer)
|
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.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)
|
assert.Equal(t, tc.expected.SecretSize, config.TOTP.SecretSize)
|
||||||
} else {
|
} else {
|
||||||
expectedErrs := len(tc.errs)
|
expectedErrs := len(tc.errs)
|
||||||
|
|
|
@ -67,6 +67,7 @@ const (
|
||||||
messageOperationFailed = "Operation failed."
|
messageOperationFailed = "Operation failed."
|
||||||
messageAuthenticationFailed = "Authentication failed. Check your credentials."
|
messageAuthenticationFailed = "Authentication failed. Check your credentials."
|
||||||
messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec
|
messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec
|
||||||
|
messageUnableToDeleteOneTimePassword = "Unable to delete one-time password." //nolint:gosec
|
||||||
messageUnableToRegisterSecurityKey = "Unable to register your security key."
|
messageUnableToRegisterSecurityKey = "Unable to register your security key."
|
||||||
messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name."
|
messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name."
|
||||||
messageUnableToResetPassword = "Unable to reset your password."
|
messageUnableToResetPassword = "Unable to reset your password."
|
||||||
|
|
|
@ -1,77 +1,250 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/authelia/authelia/v4/internal/middlewares"
|
"github.com/authelia/authelia/v4/internal/middlewares"
|
||||||
"github.com/authelia/authelia/v4/internal/model"
|
"github.com/authelia/authelia/v4/internal/model"
|
||||||
|
"github.com/authelia/authelia/v4/internal/regulation"
|
||||||
"github.com/authelia/authelia/v4/internal/session"
|
"github.com/authelia/authelia/v4/internal/session"
|
||||||
|
"github.com/authelia/authelia/v4/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// identityRetrieverFromSession retriever computing the identity from the cookie session.
|
func TOTPRegisterOptionsGET(ctx *middlewares.AutheliaCtx) {
|
||||||
func identityRetrieverFromSession(ctx *middlewares.AutheliaCtx) (identity *session.Identity, err error) {
|
if err := ctx.SetJSONBody(ctx.Providers.TOTP.Options()); err != nil {
|
||||||
var userSession session.UserSession
|
ctx.Logger.Errorf("Unable to set TOTP options response in body: %s", err)
|
||||||
|
|
||||||
if userSession, err = ctx.GetSession(); err != nil {
|
|
||||||
return nil, fmt.Errorf("error retrieving user session for request: %w", 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 {
|
func TOTPRegisterPUT(ctx *middlewares.AutheliaCtx) {
|
||||||
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) {
|
|
||||||
var (
|
var (
|
||||||
config *model.TOTPConfiguration
|
userSession session.UserSession
|
||||||
err error
|
bodyJSON bodyRegisterTOTP
|
||||||
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
if config, err = ctx.Providers.TOTP.Generate(username); err != nil {
|
if userSession, err = ctx.GetSession(); err != nil {
|
||||||
ctx.Error(fmt.Errorf("unable to generate TOTP key: %s", err), messageUnableToRegisterOneTimePassword)
|
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 {
|
if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
|
||||||
ctx.Error(fmt.Errorf("unable to save TOTP secret in DB: %s", err), messageUnableToRegisterOneTimePassword)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response := TOTPKeyResponse{
|
response := TOTPKeyResponse{
|
||||||
OTPAuthURL: config.URI(),
|
OTPAuthURL: config.URI(),
|
||||||
Base32Secret: string(config.Secret),
|
Base32Secret: userSession.TOTP.Secret,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = ctx.SetJSONBody(response); err != nil {
|
if err = ctx.SetJSONBody(response); err != nil {
|
||||||
ctx.Logger.Errorf("Unable to set TOTP key response in body: %s", err)
|
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.
|
func TOTPRegisterPOST(ctx *middlewares.AutheliaCtx) {
|
||||||
var TOTPIdentityFinish = middlewares.IdentityVerificationFinish(
|
var (
|
||||||
middlewares.IdentityVerificationFinishArgs{
|
userSession session.UserSession
|
||||||
ActionClaim: ActionTOTPRegistration,
|
bodyJSON bodyRegisterFinishTOTP
|
||||||
IsTokenUserValidFunc: isTokenUserValidFor2FARegistration,
|
valid bool
|
||||||
}, totpIdentityFinish)
|
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"`
|
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.
|
// bodySignWebAuthnRequest is the model of the request body of WebAuthn 2FA authentication endpoint.
|
||||||
type bodySignWebAuthnRequest struct {
|
type bodySignWebAuthnRequest struct {
|
||||||
TargetURL string `json:"targetURL"`
|
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)
|
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.
|
// Validate mocks base method.
|
||||||
func (m *MockTOTP) Validate(arg0 string, arg1 *model.TOTPConfiguration) (bool, error) {
|
func (m *MockTOTP) Validate(arg0 string, arg1 *model.TOTPConfiguration) (bool, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|
|
@ -3,6 +3,7 @@ package model
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"image"
|
"image"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -12,17 +13,54 @@ import (
|
||||||
"gopkg.in/yaml.v3"
|
"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.
|
// TOTPConfiguration represents a users TOTP configuration row in the database.
|
||||||
type TOTPConfiguration struct {
|
type TOTPConfiguration struct {
|
||||||
ID int `db:"id" json:"-"`
|
ID int `db:"id"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"-"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
LastUsedAt sql.NullTime `db:"last_used_at" json:"-"`
|
LastUsedAt sql.NullTime `db:"last_used_at"`
|
||||||
Username string `db:"username" json:"-"`
|
Username string `db:"username"`
|
||||||
Issuer string `db:"issuer" json:"-"`
|
Issuer string `db:"issuer"`
|
||||||
Algorithm string `db:"algorithm" json:"-"`
|
Algorithm string `db:"algorithm"`
|
||||||
Digits uint `db:"digits" json:"digits"`
|
Digits uint `db:"digits"`
|
||||||
Period uint `db:"period" json:"period"`
|
Period uint `db:"period"`
|
||||||
Secret []byte `db:"secret" json:"-"`
|
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.
|
// 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)
|
data, err := json.Marshal(object)
|
||||||
assert.NoError(t, err)
|
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.
|
// 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")
|
require.NotContains(t, string(data), "secret")
|
||||||
|
|
|
@ -255,9 +255,12 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
|
||||||
if !config.TOTP.Disable {
|
if !config.TOTP.Disable {
|
||||||
// TOTP related endpoints.
|
// TOTP related endpoints.
|
||||||
r.GET("/api/user/info/totp", middleware1FA(handlers.UserTOTPInfoGET))
|
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.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 {
|
if !config.WebAuthn.Disable {
|
||||||
|
|
|
@ -269,6 +269,8 @@ func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileO
|
||||||
RememberMe: strconv.FormatBool(!config.Session.DisableRememberMe),
|
RememberMe: strconv.FormatBool(!config.Session.DisableRememberMe),
|
||||||
ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable),
|
ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable),
|
||||||
ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(),
|
ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(),
|
||||||
|
PrivacyPolicyURL: "",
|
||||||
|
PrivacyPolicyAccept: strFalse,
|
||||||
Theme: config.Theme,
|
Theme: config.Theme,
|
||||||
|
|
||||||
EndpointsPasswordReset: !(config.AuthenticationBackend.PasswordReset.Disable || config.AuthenticationBackend.PasswordReset.CustomURL.String() != ""),
|
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 holds the session registration data for this session.
|
||||||
WebAuthn *WebAuthn
|
WebAuthn *WebAuthn
|
||||||
|
TOTP *TOTP
|
||||||
|
|
||||||
// This boolean is set to true after identity verification and checked
|
// This boolean is set to true after identity verification and checked
|
||||||
// while doing the query actually updating the password.
|
// while doing the query actually updating the password.
|
||||||
|
@ -45,7 +46,17 @@ type UserSession struct {
|
||||||
RefreshTTL time.Time
|
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 {
|
type WebAuthn struct {
|
||||||
*webauthn.SessionData
|
*webauthn.SessionData
|
||||||
Description string
|
Description string
|
||||||
|
|
|
@ -73,12 +73,12 @@ const (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
queryFmtSelectTOTPConfiguration = `
|
queryFmtSelectTOTPConfiguration = `
|
||||||
SELECT id, username, issuer, algorithm, digits, period, secret
|
SELECT id, created_at, last_used_at, username, issuer, algorithm, digits, period, secret
|
||||||
FROM %s
|
FROM %s
|
||||||
WHERE username = ?;`
|
WHERE username = ?;`
|
||||||
|
|
||||||
queryFmtSelectTOTPConfigurations = `
|
queryFmtSelectTOTPConfigurations = `
|
||||||
SELECT id, username, issuer, algorithm, digits, period, secret
|
SELECT id, created_at, last_used_at, username, issuer, algorithm, digits, period, secret
|
||||||
FROM %s
|
FROM %s
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
OFFSET ?;`
|
OFFSET ?;`
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
###############################################################
|
###############################################################
|
||||||
|
|
||||||
jwt_secret: unsecure_secret
|
jwt_secret: unsecure_secret
|
||||||
theme: auto
|
theme: dark
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 9091
|
port: 9091
|
||||||
|
@ -48,6 +48,18 @@ storage:
|
||||||
|
|
||||||
totp:
|
totp:
|
||||||
issuer: example.com
|
issuer: example.com
|
||||||
|
allowed_algorithms:
|
||||||
|
- SHA1
|
||||||
|
- SHA256
|
||||||
|
- SHA512
|
||||||
|
allowed_digits:
|
||||||
|
- 6
|
||||||
|
- 8
|
||||||
|
allowed_periods:
|
||||||
|
- 30
|
||||||
|
- 60
|
||||||
|
- 90
|
||||||
|
- 120
|
||||||
|
|
||||||
access_control:
|
access_control:
|
||||||
default_policy: deny
|
default_policy: deny
|
||||||
|
|
|
@ -93,8 +93,8 @@ const (
|
||||||
var (
|
var (
|
||||||
storageLocalTmpConfig = schema.Configuration{
|
storageLocalTmpConfig = schema.Configuration{
|
||||||
TOTP: schema.TOTPConfiguration{
|
TOTP: schema.TOTPConfiguration{
|
||||||
Issuer: "Authelia",
|
Issuer: "Authelia",
|
||||||
Period: 6,
|
DefaultPeriod: 6,
|
||||||
},
|
},
|
||||||
Storage: schema.StorageConfiguration{
|
Storage: schema.StorageConfiguration{
|
||||||
EncryptionKey: "a_not_so_secure_encryption_key",
|
EncryptionKey: "a_not_so_secure_encryption_key",
|
||||||
|
|
|
@ -37,8 +37,7 @@ func (s *MultiCookieDomainScenario) SetupSuite() {
|
||||||
|
|
||||||
s.RodSession = browser
|
s.RodSession = browser
|
||||||
|
|
||||||
err = updateDevEnvFileForDomain(s.domain, false)
|
s.Require().NoError(updateDevEnvFileForDomain(s.domain, false))
|
||||||
s.Require().NoError(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MultiCookieDomainScenario) TearDownSuite() {
|
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.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", s.remember, s.domain, firstDomainTargetURL)
|
||||||
s.verifySecretAuthorized(s.T(), s.Page)
|
s.verifySecretAuthorized(s.T(), s.Page)
|
||||||
|
|
||||||
err := updateDevEnvFileForDomain(s.nextDomain, false)
|
s.Require().NoError(updateDevEnvFileForDomain(s.nextDomain, false))
|
||||||
s.Require().NoError(err)
|
|
||||||
|
|
||||||
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", !s.remember, s.nextDomain, nextDomainTargetURL)
|
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", !s.remember, s.nextDomain, nextDomainTargetURL)
|
||||||
s.verifySecretAuthorized(s.T(), s.Page)
|
s.verifySecretAuthorized(s.T(), s.Page)
|
||||||
|
|
|
@ -9,4 +9,5 @@ type Provider interface {
|
||||||
Generate(username string) (config *model.TOTPConfiguration, err error)
|
Generate(username string) (config *model.TOTPConfiguration, err error)
|
||||||
GenerateCustom(username string, algorithm, secret string, digits, period, secretSize uint) (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)
|
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.
|
// NewTimeBasedProvider creates a new totp.TimeBased which implements the totp.Provider.
|
||||||
func NewTimeBasedProvider(config schema.TOTPConfiguration) (provider *TimeBased) {
|
func NewTimeBasedProvider(config schema.TOTPConfiguration) (provider *TimeBased) {
|
||||||
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 {
|
if config.Skew != nil {
|
||||||
provider.skew = *config.Skew
|
provider.skew = uint(*config.Skew)
|
||||||
} else {
|
} else {
|
||||||
provider.skew = 1
|
provider.skew = 1
|
||||||
}
|
}
|
||||||
|
@ -29,8 +41,14 @@ func NewTimeBasedProvider(config schema.TOTPConfiguration) (provider *TimeBased)
|
||||||
|
|
||||||
// TimeBased totp.Provider for production use.
|
// TimeBased totp.Provider for production use.
|
||||||
type TimeBased struct {
|
type TimeBased struct {
|
||||||
config *schema.TOTPConfiguration
|
opts *model.TOTPOptions
|
||||||
skew uint
|
|
||||||
|
issuer string
|
||||||
|
algorithm string
|
||||||
|
digits uint
|
||||||
|
period uint
|
||||||
|
skew uint
|
||||||
|
size uint
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateCustom generates a TOTP with custom options.
|
// 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{
|
opts := totp.GenerateOpts{
|
||||||
Issuer: p.config.Issuer,
|
Issuer: p.issuer,
|
||||||
AccountName: username,
|
AccountName: username,
|
||||||
Period: period,
|
Period: period,
|
||||||
Secret: secretData,
|
Secret: secretData,
|
||||||
|
@ -55,26 +77,32 @@ func (p TimeBased) GenerateCustom(username, algorithm, secret string, digits, pe
|
||||||
Algorithm: otpStringToAlgo(algorithm),
|
Algorithm: otpStringToAlgo(algorithm),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("secret before", opts.Secret)
|
||||||
|
|
||||||
if key, err = totp.Generate(opts); err != nil {
|
if key, err = totp.Generate(opts); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("secret key", key)
|
||||||
|
|
||||||
config = &model.TOTPConfiguration{
|
config = &model.TOTPConfiguration{
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Username: username,
|
Username: username,
|
||||||
Issuer: p.config.Issuer,
|
Issuer: p.issuer,
|
||||||
Algorithm: algorithm,
|
Algorithm: algorithm,
|
||||||
Digits: digits,
|
Digits: digits,
|
||||||
Secret: []byte(key.Secret()),
|
Secret: []byte(key.Secret()),
|
||||||
Period: period,
|
Period: period,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("secret after", config.Secret)
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate generates a TOTP with default options.
|
// Generate generates a TOTP with default options.
|
||||||
func (p TimeBased) Generate(username string) (config *model.TOTPConfiguration, err error) {
|
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.
|
// 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),
|
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)
|
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{
|
totp := NewTimeBasedProvider(schema.TOTPConfiguration{
|
||||||
Issuer: "Authelia",
|
Issuer: "Authelia",
|
||||||
Algorithm: "SHA1",
|
DefaultAlgorithm: "SHA1",
|
||||||
Digits: 6,
|
DefaultDigits: 6,
|
||||||
Period: 30,
|
DefaultPeriod: 30,
|
||||||
SecretSize: 32,
|
SecretSize: 32,
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
|
@ -118,15 +118,15 @@ func TestTOTPGenerateCustom(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTOTPGenerate(t *testing.T) {
|
func TestTOTPGenerate(t *testing.T) {
|
||||||
skew := uint(2)
|
skew := 2
|
||||||
|
|
||||||
totp := NewTimeBasedProvider(schema.TOTPConfiguration{
|
totp := NewTimeBasedProvider(schema.TOTPConfiguration{
|
||||||
Issuer: "Authelia",
|
Issuer: "Authelia",
|
||||||
Algorithm: "SHA256",
|
DefaultAlgorithm: "SHA256",
|
||||||
Digits: 8,
|
DefaultDigits: 8,
|
||||||
Period: 60,
|
DefaultPeriod: 60,
|
||||||
Skew: &skew,
|
Skew: &skew,
|
||||||
SecretSize: 32,
|
SecretSize: 32,
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.Equal(t, uint(2), totp.skew)
|
assert.Equal(t, uint(2), totp.skew)
|
||||||
|
|
|
@ -78,6 +78,7 @@ const App: React.FC<Props> = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CacheProvider value={cache}>
|
<CacheProvider value={cache}>
|
||||||
<ThemeProvider theme={theme}>
|
<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 { useRemoteCall } from "@hooks/RemoteCall";
|
||||||
import { getUserInfoTOTPConfiguration } from "@services/UserInfoTOTPConfiguration";
|
import {
|
||||||
|
getUserInfoTOTPConfiguration,
|
||||||
|
getUserInfoTOTPConfigurationOptional,
|
||||||
|
} from "@services/UserInfoTOTPConfiguration";
|
||||||
|
|
||||||
export function useUserInfoTOTPConfiguration() {
|
export function useUserInfoTOTPConfiguration() {
|
||||||
return useRemoteCall(getUserInfoTOTPConfiguration, []);
|
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 InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/start";
|
||||||
export const CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish";
|
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 WebAuthnRegistrationPath = basePath + "/api/secondfactor/webauthn/credential/register";
|
||||||
|
|
||||||
export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn";
|
export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn";
|
||||||
|
|
||||||
export const WebAuthnDevicesPath = basePath + "/api/secondfactor/webauthn/credentials";
|
export const WebAuthnDevicesPath = basePath + "/api/secondfactor/webauthn/credentials";
|
||||||
export const WebAuthnDevicePath = basePath + "/api/secondfactor/webauthn/credential";
|
export const WebAuthnDevicePath = basePath + "/api/secondfactor/webauthn/credential";
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,16 @@ import axios from "axios";
|
||||||
|
|
||||||
import { ServiceResponse, hasServiceError, toData } from "@services/Api";
|
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> {
|
export async function PostWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
|
||||||
const res = await axios.post<ServiceResponse<T>>(path, body);
|
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);
|
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) {
|
export async function Post<T>(path: string, body?: any) {
|
||||||
const res = await PostWithOptionalResponse<T>(path, body);
|
const res = await PostWithOptionalResponse<T>(path, body);
|
||||||
if (!res) {
|
if (!res) {
|
||||||
|
@ -20,6 +40,14 @@ export async function Post<T>(path: string, body?: any) {
|
||||||
return res;
|
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> {
|
export async function Get<T = undefined>(path: string): Promise<T> {
|
||||||
const res = await axios.get<ServiceResponse<T>>(path);
|
const res = await axios.get<ServiceResponse<T>>(path);
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { CompleteTOTPSignInPath } from "@services/Api";
|
import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api";
|
||||||
import { PostWithOptionalResponse } from "@services/Client";
|
import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
|
||||||
import { SignInResponse } from "@services/SignIn";
|
import { SignInResponse } from "@services/SignIn";
|
||||||
|
|
||||||
interface CompleteTOTPSignInBody {
|
interface CompleteTOTPSignInBody {
|
||||||
|
@ -19,3 +19,15 @@ export function completeTOTPSignIn(passcode: string, targetURL?: string, workflo
|
||||||
|
|
||||||
return PostWithOptionalResponse<SignInResponse>(CompleteTOTPSignInPath, body);
|
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 { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath, TOTPRegistrationPath } from "@services/Api";
|
||||||
import { Post, PostWithOptionalResponse } from "@services/Client";
|
import { Post, PostWithOptionalResponse, Put } from "@services/Client";
|
||||||
|
|
||||||
export async function initiateTOTPRegistrationProcess() {
|
export async function initiateTOTPRegistrationProcess() {
|
||||||
await PostWithOptionalResponse(InitiateTOTPRegistrationPath);
|
await PostWithOptionalResponse(InitiateTOTPRegistrationPath);
|
||||||
|
@ -13,3 +13,11 @@ interface CompleteTOTPRegistrationResponse {
|
||||||
export async function completeTOTPRegistrationProcess(processToken: string) {
|
export async function completeTOTPRegistrationProcess(processToken: string) {
|
||||||
return Post<CompleteTOTPRegistrationResponse>(CompleteTOTPRegistrationPath, { token: processToken });
|
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 axios from "axios";
|
||||||
import { UserInfoTOTPConfigurationPath } from "@services/Api";
|
|
||||||
|
import {
|
||||||
|
TOTPAlgorithmPayload,
|
||||||
|
TOTPDigits,
|
||||||
|
TOTPOptions,
|
||||||
|
UserInfoTOTPConfiguration,
|
||||||
|
toEnum,
|
||||||
|
} from "@models/TOTPConfiguration";
|
||||||
|
import {
|
||||||
|
AuthenticationOKResponse,
|
||||||
|
CompleteTOTPSignInPath,
|
||||||
|
ServiceResponse,
|
||||||
|
TOTPRegistrationOptionsPath,
|
||||||
|
UserInfoTOTPConfigurationPath,
|
||||||
|
validateStatusAuthentication,
|
||||||
|
} from "@services/Api";
|
||||||
import { Get } from "@services/Client";
|
import { Get } from "@services/Client";
|
||||||
|
|
||||||
export type TOTPDigits = 6 | 8;
|
|
||||||
|
|
||||||
export interface UserInfoTOTPConfigurationPayload {
|
export interface UserInfoTOTPConfigurationPayload {
|
||||||
period: number;
|
created_at: string;
|
||||||
|
last_used_at?: string;
|
||||||
|
issuer: string;
|
||||||
|
algorithm: TOTPAlgorithmPayload;
|
||||||
digits: TOTPDigits;
|
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> {
|
export async function getUserInfoTOTPConfiguration(): Promise<UserInfoTOTPConfiguration> {
|
||||||
const res = await Get<UserInfoTOTPConfigurationPayload>(UserInfoTOTPConfigurationPath);
|
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 { IconDefinition, faCopy, faKey, faTimesCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { red } from "@mui/material/colors";
|
||||||
import makeStyles from "@mui/styles/makeStyles";
|
import makeStyles from "@mui/styles/makeStyles";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
|
@ -92,8 +92,8 @@ const RegisterOneTimePassword = function () {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginLayout title={translate("Scan QR Code")}>
|
<LoginLayout title={translate("Scan QR Code")}>
|
||||||
<div className={styles.root}>
|
<Box className={styles.root}>
|
||||||
<div className={styles.googleAuthenticator}>
|
<Box className={styles.googleAuthenticator}>
|
||||||
<Typography className={styles.googleAuthenticatorText}>
|
<Typography className={styles.googleAuthenticatorText}>
|
||||||
{translate("Need Google Authenticator?")}
|
{translate("Need Google Authenticator?")}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -104,15 +104,15 @@ const RegisterOneTimePassword = function () {
|
||||||
googlePlayLink={GoogleAuthenticator.googlePlay}
|
googlePlayLink={GoogleAuthenticator.googlePlay}
|
||||||
appleStoreLink={GoogleAuthenticator.appleStore}
|
appleStoreLink={GoogleAuthenticator.appleStore}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
<div className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}>
|
<Box className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}>
|
||||||
<Link href={secretURL} underline="hover">
|
<Link href={secretURL} underline="hover">
|
||||||
<QRCodeSVG value={secretURL} className={styles.qrcode} size={256} />
|
<QRCodeSVG value={secretURL} className={styles.qrcode} size={256} />
|
||||||
{!hasErrored && isLoading ? <CircularProgress className={styles.loader} size={128} /> : null}
|
{!hasErrored && isLoading ? <CircularProgress className={styles.loader} size={128} /> : null}
|
||||||
{hasErrored ? <FontAwesomeIcon className={styles.failureIcon} icon={faTimesCircle} /> : null}
|
{hasErrored ? <FontAwesomeIcon className={styles.failureIcon} icon={faTimesCircle} /> : null}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</Box>
|
||||||
<div>
|
<Box>
|
||||||
{secretURL !== "empty" ? (
|
{secretURL !== "empty" ? (
|
||||||
<TextField
|
<TextField
|
||||||
id="secret-url"
|
id="secret-url"
|
||||||
|
@ -130,7 +130,7 @@ const RegisterOneTimePassword = function () {
|
||||||
{secretURL !== "empty"
|
{secretURL !== "empty"
|
||||||
? SecretButton(secretURL, translate("OTP URL copied to clipboard"), faCopy)
|
? SecretButton(secretURL, translate("OTP URL copied to clipboard"), faCopy)
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
@ -140,7 +140,7 @@ const RegisterOneTimePassword = function () {
|
||||||
>
|
>
|
||||||
{translate("Done")}
|
{translate("Done")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</LoginLayout>
|
</LoginLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,9 +14,9 @@ export interface Props {}
|
||||||
|
|
||||||
const SettingsRouter = function (props: Props) {
|
const SettingsRouter = function (props: Props) {
|
||||||
const navigate = useRouterNavigate();
|
const navigate = useRouterNavigate();
|
||||||
|
|
||||||
const [state, fetchState, , fetchStateError] = useAutheliaState();
|
const [state, fetchState, , fetchStateError] = useAutheliaState();
|
||||||
|
|
||||||
// Fetch the state on page load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchState();
|
fetchState();
|
||||||
}, [fetchState]);
|
}, [fetchState]);
|
||||||
|
@ -24,6 +24,8 @@ const SettingsRouter = function (props: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetchStateError || (state && state.authentication_level < AuthenticationLevel.OneFactor)) {
|
if (fetchStateError || (state && state.authentication_level < AuthenticationLevel.OneFactor)) {
|
||||||
navigate(IndexRoute);
|
navigate(IndexRoute);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [state, fetchStateError, navigate]);
|
}, [state, fetchStateError, navigate]);
|
||||||
|
|
||||||
|
@ -33,7 +35,7 @@ const SettingsRouter = function (props: Props) {
|
||||||
<Route path={IndexRoute} element={<SettingsView />} />
|
<Route path={IndexRoute} element={<SettingsView />} />
|
||||||
<Route
|
<Route
|
||||||
path={SettingsTwoFactorAuthenticationSubRoute}
|
path={SettingsTwoFactorAuthenticationSubRoute}
|
||||||
element={<TwoFactorAuthenticationView state={state} />}
|
element={state ? <TwoFactorAuthenticationView /> : null}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</SettingsLayout>
|
</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 { useNotifications } from "@hooks/NotificationsContext";
|
||||||
import { useUserInfoPOST } from "@hooks/UserInfo";
|
import { useUserInfoPOST } from "@hooks/UserInfo";
|
||||||
|
import { useUserInfoTOTPConfiguration, useUserInfoTOTPConfigurationOptional } from "@hooks/UserInfoTOTPConfiguration";
|
||||||
import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices";
|
import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices";
|
||||||
|
import TOTPPanel from "@views/Settings/TwoFactorAuthentication/TOTPPanel";
|
||||||
import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel";
|
import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel";
|
||||||
|
|
||||||
interface Props {}
|
interface Props {}
|
||||||
|
@ -13,6 +15,7 @@ export default function TwoFactorAuthSettings(props: Props) {
|
||||||
const [refreshState, setRefreshState] = useState(0);
|
const [refreshState, setRefreshState] = useState(0);
|
||||||
const { createErrorNotification } = useNotifications();
|
const { createErrorNotification } = useNotifications();
|
||||||
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST();
|
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST();
|
||||||
|
const [userTOTPConfig, fetchUserTOTPConfig, , fetchUserTOTPConfigError] = useUserInfoTOTPConfigurationOptional();
|
||||||
const [userWebAuthnDevices, fetchUserWebAuthnDevices, , fetchUserWebAuthnDevicesError] = useUserWebAuthnDevices();
|
const [userWebAuthnDevices, fetchUserWebAuthnDevices, , fetchUserWebAuthnDevicesError] = useUserWebAuthnDevices();
|
||||||
const [hasTOTP, setHasTOTP] = useState(false);
|
const [hasTOTP, setHasTOTP] = useState(false);
|
||||||
const [hasWebAuthn, setHasWebAuthn] = useState(false);
|
const [hasWebAuthn, setHasWebAuthn] = useState(false);
|
||||||
|
@ -39,6 +42,10 @@ export default function TwoFactorAuthSettings(props: Props) {
|
||||||
}
|
}
|
||||||
}, [hasTOTP, hasWebAuthn, userInfo]);
|
}, [hasTOTP, hasWebAuthn, userInfo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserTOTPConfig();
|
||||||
|
}, [fetchUserTOTPConfig, hasTOTP]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserWebAuthnDevices();
|
fetchUserWebAuthnDevices();
|
||||||
}, [fetchUserWebAuthnDevices, hasWebAuthn]);
|
}, [fetchUserWebAuthnDevices, hasWebAuthn]);
|
||||||
|
@ -49,6 +56,12 @@ export default function TwoFactorAuthSettings(props: Props) {
|
||||||
}
|
}
|
||||||
}, [fetchUserInfoError, createErrorNotification]);
|
}, [fetchUserInfoError, createErrorNotification]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetchUserTOTPConfigError) {
|
||||||
|
createErrorNotification("There was an issue retrieving One Time Password Configuration");
|
||||||
|
}
|
||||||
|
}, [fetchUserTOTPConfigError, createErrorNotification]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetchUserWebAuthnDevicesError) {
|
if (fetchUserWebAuthnDevicesError) {
|
||||||
createErrorNotification("There was an issue retrieving One Time Password Configuration");
|
createErrorNotification("There was an issue retrieving One Time Password Configuration");
|
||||||
|
@ -57,6 +70,9 @@ export default function TwoFactorAuthSettings(props: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
|
<Grid xs={12}>
|
||||||
|
<TOTPPanel config={userTOTPConfig} handleRefreshState={handleRefreshState} />
|
||||||
|
</Grid>
|
||||||
<Grid xs={12}>
|
<Grid xs={12}>
|
||||||
<WebAuthnDevicesPanel devices={userWebAuthnDevices} handleRefreshState={handleRefreshState} />
|
<WebAuthnDevicesPanel devices={userWebAuthnDevices} handleRefreshState={handleRefreshState} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
Loading…
Reference in New Issue