refactor: totp

pull/5053/head
James Elliott 2023-02-14 07:39:46 +11:00
parent 53d3cdb271
commit b03c0b7ace
No known key found for this signature in database
GPG Key ID: 0F1C4A096E857E49
38 changed files with 1468 additions and 171 deletions

View File

@ -685,7 +685,7 @@ func (ctx *CmdCtx) StorageUserTOTPGenerateRunE(cmd *cobra.Command, args []string
totpProvider := totp.NewTimeBasedProvider(ctx.config.TOTP)
if c, err = totpProvider.GenerateCustom(args[0], ctx.config.TOTP.Algorithm, secret, ctx.config.TOTP.Digits, ctx.config.TOTP.Period, ctx.config.TOTP.SecretSize); err != nil {
if c, err = totpProvider.GenerateCustom(args[0], ctx.config.TOTP.DefaultAlgorithm, secret, uint(ctx.config.TOTP.DefaultDigits), uint(ctx.config.TOTP.DefaultPeriod), uint(ctx.config.TOTP.SecretSize)); err != nil {
return err
}

View File

@ -174,6 +174,9 @@ var Keys = []string{
"totp.period",
"totp.skew",
"totp.secret_size",
"totp.allowed_algorithms",
"totp.allowed_digits",
"totp.allowed_periods",
"duo_api.disable",
"duo_api.hostname",
"duo_api.integration_key",

View File

@ -2,23 +2,27 @@ package schema
// TOTPConfiguration represents the configuration related to TOTP options.
type TOTPConfiguration struct {
Disable bool `koanf:"disable"`
Issuer string `koanf:"issuer"`
Algorithm string `koanf:"algorithm"`
Digits uint `koanf:"digits"`
Period uint `koanf:"period"`
Skew *uint `koanf:"skew"`
SecretSize uint `koanf:"secret_size"`
Disable bool `koanf:"disable"`
Issuer string `koanf:"issuer"`
DefaultAlgorithm string `koanf:"algorithm"`
DefaultDigits int `koanf:"digits"`
DefaultPeriod int `koanf:"period"`
Skew *int `koanf:"skew"`
SecretSize int `koanf:"secret_size"`
AllowedAlgorithms []string `koanf:"allowed_algorithms"`
AllowedDigits []int `koanf:"allowed_digits"`
AllowedPeriods []int `koanf:"allowed_periods"`
}
var defaultOtpSkew = uint(1)
var defaultTOTPSkew = 1
// DefaultTOTPConfiguration represents default configuration parameters for TOTP generation.
var DefaultTOTPConfiguration = TOTPConfiguration{
Issuer: "Authelia",
Algorithm: TOTPAlgorithmSHA1,
Digits: 6,
Period: 30,
Skew: &defaultOtpSkew,
SecretSize: TOTPSecretSizeDefault,
Issuer: "Authelia",
DefaultAlgorithm: TOTPAlgorithmSHA1,
DefaultDigits: 6,
DefaultPeriod: 30,
Skew: &defaultTOTPSkew,
SecretSize: TOTPSecretSizeDefault,
}

View File

@ -8,7 +8,7 @@ import (
"github.com/authelia/authelia/v4/internal/utils"
)
// ValidateTOTP validates and update TOTP configuration.
// ValidateTOTP validates and updates TOTP configuration.
func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidator) {
if config.TOTP.Disable {
return
@ -18,26 +18,73 @@ func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidato
config.TOTP.Issuer = schema.DefaultTOTPConfiguration.Issuer
}
if config.TOTP.Algorithm == "" {
config.TOTP.Algorithm = schema.DefaultTOTPConfiguration.Algorithm
if config.TOTP.DefaultAlgorithm == "" {
config.TOTP.DefaultAlgorithm = schema.DefaultTOTPConfiguration.DefaultAlgorithm
} else {
config.TOTP.Algorithm = strings.ToUpper(config.TOTP.Algorithm)
config.TOTP.DefaultAlgorithm = strings.ToUpper(config.TOTP.DefaultAlgorithm)
if !utils.IsStringInSlice(config.TOTP.Algorithm, schema.TOTPPossibleAlgorithms) {
validator.Push(fmt.Errorf(errFmtTOTPInvalidAlgorithm, strJoinOr(schema.TOTPPossibleAlgorithms), config.TOTP.Algorithm))
if !utils.IsStringInSlice(config.TOTP.DefaultAlgorithm, schema.TOTPPossibleAlgorithms) {
validator.Push(fmt.Errorf(errFmtTOTPInvalidAlgorithm, strings.Join(schema.TOTPPossibleAlgorithms, "', '"), config.TOTP.DefaultAlgorithm))
}
}
if config.TOTP.Period == 0 {
config.TOTP.Period = schema.DefaultTOTPConfiguration.Period
} else if config.TOTP.Period < 15 {
validator.Push(fmt.Errorf(errFmtTOTPInvalidPeriod, config.TOTP.Period))
for i, algorithm := range config.TOTP.AllowedAlgorithms {
config.TOTP.AllowedAlgorithms[i] = strings.ToUpper(algorithm)
// TODO: Customize this error.
if !utils.IsStringInSlice(config.TOTP.AllowedAlgorithms[i], schema.TOTPPossibleAlgorithms) {
validator.Push(fmt.Errorf(errFmtTOTPInvalidAlgorithm, strings.Join(schema.TOTPPossibleAlgorithms, "', '"), config.TOTP.AllowedAlgorithms[i]))
}
}
if config.TOTP.Digits == 0 {
config.TOTP.Digits = schema.DefaultTOTPConfiguration.Digits
} else if config.TOTP.Digits != 6 && config.TOTP.Digits != 8 {
validator.Push(fmt.Errorf(errFmtTOTPInvalidDigits, config.TOTP.Digits))
if !utils.IsStringInSlice(config.TOTP.DefaultAlgorithm, config.TOTP.AllowedAlgorithms) {
config.TOTP.AllowedAlgorithms = append(config.TOTP.AllowedAlgorithms, config.TOTP.DefaultAlgorithm)
}
if config.TOTP.DefaultPeriod == 0 {
config.TOTP.DefaultPeriod = schema.DefaultTOTPConfiguration.DefaultPeriod
} else if config.TOTP.DefaultPeriod < 15 {
validator.Push(fmt.Errorf(errFmtTOTPInvalidPeriod, config.TOTP.DefaultPeriod))
}
var hasDefaultPeriod bool
for _, period := range config.TOTP.AllowedPeriods {
// TODO: Customize this error.
if period < 15 {
validator.Push(fmt.Errorf(errFmtTOTPInvalidPeriod, period))
}
if period == config.TOTP.DefaultPeriod {
hasDefaultPeriod = true
}
}
if !hasDefaultPeriod {
config.TOTP.AllowedPeriods = append(config.TOTP.AllowedPeriods, config.TOTP.DefaultPeriod)
}
if config.TOTP.DefaultDigits == 0 {
config.TOTP.DefaultDigits = schema.DefaultTOTPConfiguration.DefaultDigits
} else if config.TOTP.DefaultDigits != 6 && config.TOTP.DefaultDigits != 8 {
validator.Push(fmt.Errorf(errFmtTOTPInvalidDigits, config.TOTP.DefaultDigits))
}
var hasDefaultDigits bool
for _, digits := range config.TOTP.AllowedDigits {
// TODO: Customize this error.
if digits != 6 && digits != 8 {
validator.Push(fmt.Errorf(errFmtTOTPInvalidDigits, config.TOTP.DefaultDigits))
}
if digits == config.TOTP.DefaultDigits {
hasDefaultDigits = true
}
}
if !hasDefaultDigits {
config.TOTP.AllowedDigits = append(config.TOTP.AllowedDigits, config.TOTP.DefaultDigits)
}
if config.TOTP.Skew == nil {

View File

@ -30,31 +30,31 @@ func TestValidateTOTP(t *testing.T) {
{
desc: "ShouldNormalizeTOTPAlgorithm",
have: schema.TOTPConfiguration{
Algorithm: digestSHA1,
Digits: 6,
Period: 30,
SecretSize: 32,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
DefaultAlgorithm: digestSHA1,
DefaultDigits: 6,
DefaultPeriod: 30,
SecretSize: 32,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
},
expected: schema.TOTPConfiguration{
Algorithm: "SHA1",
Digits: 6,
Period: 30,
SecretSize: 32,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
DefaultAlgorithm: "SHA1",
DefaultDigits: 6,
DefaultPeriod: 30,
SecretSize: 32,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
},
},
{
desc: "ShouldRaiseErrorWhenInvalidTOTPAlgorithm",
have: schema.TOTPConfiguration{
Algorithm: "sha3",
Digits: 6,
Period: 30,
SecretSize: 32,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
DefaultAlgorithm: "sha3",
DefaultDigits: 6,
DefaultPeriod: 30,
SecretSize: 32,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
},
errs: []string{
"totp: option 'algorithm' must be one of 'SHA1', 'SHA256', or 'SHA512' but it's configured as 'SHA3'",
@ -63,12 +63,12 @@ func TestValidateTOTP(t *testing.T) {
{
desc: "ShouldRaiseErrorWhenInvalidTOTPValue",
have: schema.TOTPConfiguration{
Algorithm: "sha3",
Period: 5,
Digits: 20,
SecretSize: 10,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
DefaultAlgorithm: "sha3",
DefaultPeriod: 5,
DefaultDigits: 20,
SecretSize: 10,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
},
errs: []string{
"totp: option 'algorithm' must be one of 'SHA1', 'SHA256', or 'SHA512' but it's configured as 'SHA3'",
@ -94,9 +94,9 @@ func TestValidateTOTP(t *testing.T) {
assert.Len(t, warns, 0)
assert.Equal(t, tc.expected.Disable, config.TOTP.Disable)
assert.Equal(t, tc.expected.Issuer, config.TOTP.Issuer)
assert.Equal(t, tc.expected.Algorithm, config.TOTP.Algorithm)
assert.Equal(t, tc.expected.DefaultAlgorithm, config.TOTP.DefaultAlgorithm)
assert.Equal(t, tc.expected.Skew, config.TOTP.Skew)
assert.Equal(t, tc.expected.Period, config.TOTP.Period)
assert.Equal(t, tc.expected.DefaultPeriod, config.TOTP.DefaultPeriod)
assert.Equal(t, tc.expected.SecretSize, config.TOTP.SecretSize)
} else {
expectedErrs := len(tc.errs)

View File

@ -67,6 +67,7 @@ const (
messageOperationFailed = "Operation failed."
messageAuthenticationFailed = "Authentication failed. Check your credentials."
messageUnableToRegisterOneTimePassword = "Unable to set up one-time passwords." //nolint:gosec
messageUnableToDeleteOneTimePassword = "Unable to delete one-time password." //nolint:gosec
messageUnableToRegisterSecurityKey = "Unable to register your security key."
messageSecurityKeyDuplicateName = "Another one of your security keys is already registered with that display name."
messageUnableToResetPassword = "Unable to reset your password."

View File

@ -1,77 +1,250 @@
package handlers
import (
"encoding/json"
"fmt"
"time"
"github.com/authelia/authelia/v4/internal/middlewares"
"github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/utils"
)
// identityRetrieverFromSession retriever computing the identity from the cookie session.
func identityRetrieverFromSession(ctx *middlewares.AutheliaCtx) (identity *session.Identity, err error) {
var userSession session.UserSession
if userSession, err = ctx.GetSession(); err != nil {
return nil, fmt.Errorf("error retrieving user session for request: %w", err)
func TOTPRegisterOptionsGET(ctx *middlewares.AutheliaCtx) {
if err := ctx.SetJSONBody(ctx.Providers.TOTP.Options()); err != nil {
ctx.Logger.Errorf("Unable to set TOTP options response in body: %s", err)
}
if len(userSession.Emails) == 0 {
return nil, fmt.Errorf("user %s does not have any email address", userSession.Username)
}
return &session.Identity{
Username: userSession.Username,
DisplayName: userSession.DisplayName,
Email: userSession.Emails[0],
}, nil
}
func isTokenUserValidFor2FARegistration(ctx *middlewares.AutheliaCtx, username string) bool {
userSession, err := ctx.GetSession()
return err == nil && userSession.Username == username
}
// TOTPIdentityStart the handler for initiating the identity validation.
var TOTPIdentityStart = middlewares.IdentityVerificationStart(middlewares.IdentityVerificationStartArgs{
MailTitle: "Register your mobile",
MailButtonContent: "Register",
TargetEndpoint: "/one-time-password/register",
ActionClaim: ActionTOTPRegistration,
IdentityRetrieverFunc: identityRetrieverFromSession,
}, nil)
func totpIdentityFinish(ctx *middlewares.AutheliaCtx, username string) {
func TOTPRegisterPUT(ctx *middlewares.AutheliaCtx) {
var (
config *model.TOTPConfiguration
err error
userSession session.UserSession
bodyJSON bodyRegisterTOTP
err error
)
if config, err = ctx.Providers.TOTP.Generate(username); err != nil {
ctx.Error(fmt.Errorf("unable to generate TOTP key: %s", err), messageUnableToRegisterOneTimePassword)
if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
if err = ctx.Providers.StorageProvider.SaveTOTPConfiguration(ctx, *config); err != nil {
ctx.Error(fmt.Errorf("unable to save TOTP secret in DB: %s", err), messageUnableToRegisterOneTimePassword)
if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred unmarshaling body %s registration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
opts := ctx.Providers.TOTP.Options()
var hasAlgorithm, hasLength, hasPeriod bool
hasAlgorithm = utils.IsStringInSlice(bodyJSON.Algorithm, opts.Algorithms)
for _, period := range opts.Periods {
if period == bodyJSON.Period {
hasPeriod = true
break
}
}
for _, length := range opts.Lengths {
if length == bodyJSON.Length {
hasLength = true
break
}
}
if !hasAlgorithm || !hasPeriod || !hasLength {
ctx.Logger.Errorf("Validation failed for %s registration because the input options were not permitted by the configuration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
var config *model.TOTPConfiguration
if config, err = ctx.Providers.TOTP.GenerateCustom(userSession.Username, bodyJSON.Algorithm, "", uint(bodyJSON.Length), uint(bodyJSON.Period), 0); err != nil {
ctx.Error(fmt.Errorf("unable to generate TOTP key: %w", err), messageUnableToRegisterOneTimePassword)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
userSession.TOTP = &session.TOTP{
Issuer: config.Issuer,
Algorithm: config.Algorithm,
Digits: config.Digits,
Period: config.Period,
Secret: string(config.Secret),
Expires: ctx.Clock.Now().Add(time.Minute * 10),
}
if err = ctx.SaveSession(userSession); err != nil {
ctx.Error(err, messageUnableToRegisterOneTimePassword)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
response := TOTPKeyResponse{
OTPAuthURL: config.URI(),
Base32Secret: string(config.Secret),
Base32Secret: userSession.TOTP.Secret,
}
if err = ctx.SetJSONBody(response); err != nil {
ctx.Logger.Errorf("Unable to set TOTP key response in body: %s", err)
}
ctxLogEvent(ctx, username, "Second Factor Method Added", map[string]any{"Action": "Second Factor Method Added", "Category": "Time-based One Time Password"})
}
// TOTPIdentityFinish the handler for finishing the identity validation.
var TOTPIdentityFinish = middlewares.IdentityVerificationFinish(
middlewares.IdentityVerificationFinishArgs{
ActionClaim: ActionTOTPRegistration,
IsTokenUserValidFunc: isTokenUserValidFor2FARegistration,
}, totpIdentityFinish)
func TOTPRegisterPOST(ctx *middlewares.AutheliaCtx) {
var (
userSession session.UserSession
bodyJSON bodyRegisterFinishTOTP
valid bool
err error
)
if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
if userSession.TOTP == nil {
ctx.Logger.Errorf("Error occurred during %s registration: the user did not initiate a registration on their current session", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
if ctx.Clock.Now().After(userSession.TOTP.Expires) {
ctx.Logger.Errorf("Error occurred during %s registration: the registration is expired", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
if err = json.Unmarshal(ctx.PostBody(), &bodyJSON); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred unmarshaling body %s registration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
config := model.TOTPConfiguration{
CreatedAt: ctx.Clock.Now(),
Username: userSession.Username,
Issuer: userSession.TOTP.Issuer,
Algorithm: userSession.TOTP.Algorithm,
Period: userSession.TOTP.Period,
Digits: userSession.TOTP.Digits,
Secret: []byte(userSession.TOTP.Secret),
}
if valid, err = ctx.Providers.TOTP.Validate(bodyJSON.Token, &config); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred validating %s registration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
} else if !valid {
ctx.Logger.Errorf("Error occurred validating %s registration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
if err = ctx.Providers.StorageProvider.SaveTOTPConfiguration(ctx, config); err != nil {
ctx.Logger.Errorf("Error occurred saving %s registration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
userSession.TOTP = nil
if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf("Error occurred saving session during %s registration", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
}
func TOTPRegisterDELETE(ctx *middlewares.AutheliaCtx) {
var (
userSession session.UserSession
err error
)
if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration cancel", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
if userSession.TOTP == nil {
return
}
userSession.TOTP = nil
if err = ctx.SaveSession(userSession); err != nil {
ctx.Logger.Errorf("Error occurred saving session during %s registration cancel", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToRegisterOneTimePassword)
return
}
}
func TOTPConfigurationDELETE(ctx *middlewares.AutheliaCtx) {
var (
userSession session.UserSession
err error
)
if userSession, err = ctx.GetSession(); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration cancel", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToDeleteOneTimePassword)
return
}
if _, err = ctx.Providers.StorageProvider.LoadTOTPConfiguration(ctx, userSession.Username); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration cancel", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToDeleteOneTimePassword)
return
}
if err = ctx.Providers.StorageProvider.DeleteTOTPConfiguration(ctx, userSession.Username); err != nil {
ctx.Logger.WithError(err).Errorf("Error occurred retrieving session for %s registration cancel", regulation.AuthTypeTOTP)
respondUnauthorized(ctx, messageUnableToDeleteOneTimePassword)
return
}
}

View File

@ -31,6 +31,16 @@ type bodySignTOTPRequest struct {
WorkflowID string `json:"workflowID"`
}
type bodyRegisterTOTP struct {
Algorithm string `json:"algorithm"`
Length int `json:"length"`
Period int `json:"period"`
}
type bodyRegisterFinishTOTP struct {
Token string `json:"token" valid:"required"`
}
// bodySignWebAuthnRequest is the model of the request body of WebAuthn 2FA authentication endpoint.
type bodySignWebAuthnRequest struct {
TargetURL string `json:"targetURL"`

View File

@ -64,6 +64,20 @@ func (mr *MockTOTPMockRecorder) GenerateCustom(arg0, arg1, arg2, arg3, arg4, arg
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateCustom", reflect.TypeOf((*MockTOTP)(nil).GenerateCustom), arg0, arg1, arg2, arg3, arg4, arg5)
}
// Options mocks base method.
func (m *MockTOTP) Options() model.TOTPOptions {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Options")
ret0, _ := ret[0].(model.TOTPOptions)
return ret0
}
// Options indicates an expected call of Options.
func (mr *MockTOTPMockRecorder) Options() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Options", reflect.TypeOf((*MockTOTP)(nil).Options))
}
// Validate mocks base method.
func (m *MockTOTP) Validate(arg0 string, arg1 *model.TOTPConfiguration) (bool, error) {
m.ctrl.T.Helper()

View File

@ -3,6 +3,7 @@ package model
import (
"database/sql"
"encoding/base64"
"encoding/json"
"image"
"net/url"
"strconv"
@ -12,17 +13,54 @@ import (
"gopkg.in/yaml.v3"
)
type TOTPOptions struct {
Algorithm string `json:"algorithm"`
Algorithms []string `json:"algorithms"`
Length int `json:"length"`
Lengths []int `json:"lengths"`
Period int `json:"period"`
Periods []int `json:"periods"`
}
// TOTPConfiguration represents a users TOTP configuration row in the database.
type TOTPConfiguration struct {
ID int `db:"id" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
LastUsedAt sql.NullTime `db:"last_used_at" json:"-"`
Username string `db:"username" json:"-"`
Issuer string `db:"issuer" json:"-"`
Algorithm string `db:"algorithm" json:"-"`
Digits uint `db:"digits" json:"digits"`
Period uint `db:"period" json:"period"`
Secret []byte `db:"secret" json:"-"`
ID int `db:"id"`
CreatedAt time.Time `db:"created_at"`
LastUsedAt sql.NullTime `db:"last_used_at"`
Username string `db:"username"`
Issuer string `db:"issuer"`
Algorithm string `db:"algorithm"`
Digits uint `db:"digits"`
Period uint `db:"period"`
Secret []byte `db:"secret"`
}
type TOTPConfigurationJSON struct {
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Issuer string `json:"issuer"`
Algorithm string `json:"algorithm"`
Digits int `json:"digits"`
Period int `json:"period"`
}
// MarshalJSON returns the WebauthnDevice in a JSON friendly manner.
func (c TOTPConfiguration) MarshalJSON() (data []byte, err error) {
o := TOTPConfigurationJSON{
CreatedAt: c.CreatedAt,
Issuer: c.Issuer,
Algorithm: c.Algorithm,
Digits: int(c.Digits),
Period: int(c.Period),
}
if c.LastUsedAt.Valid {
o.LastUsedAt = &c.LastUsedAt.Time
}
return json.Marshal(o)
}
// LastUsed provides LastUsedAt as a *time.Time instead of sql.NullTime.

View File

@ -35,7 +35,7 @@ func TestShouldOnlyMarshalPeriodAndDigitsAndAbsolutelyNeverSecret(t *testing.T)
data, err := json.Marshal(object)
assert.NoError(t, err)
assert.Equal(t, "{\"digits\":6,\"period\":30}", string(data))
assert.Equal(t, "{\"created_at\":\"0001-01-01T00:00:00Z\",\"issuer\":\"Authelia\",\"algorithm\":\"SHA1\",\"digits\":6,\"period\":30}", string(data))
// DO NOT REMOVE OR CHANGE THESE TESTS UNLESS YOU FULLY UNDERSTAND THE COMMENT AT THE TOP OF THIS TEST.
require.NotContains(t, string(data), "secret")

View File

@ -255,9 +255,12 @@ func handleRouter(config *schema.Configuration, providers middlewares.Providers)
if !config.TOTP.Disable {
// TOTP related endpoints.
r.GET("/api/user/info/totp", middleware1FA(handlers.UserTOTPInfoGET))
r.POST("/api/secondfactor/totp/identity/start", middleware1FA(handlers.TOTPIdentityStart))
r.POST("/api/secondfactor/totp/identity/finish", middleware1FA(handlers.TOTPIdentityFinish))
r.POST("/api/secondfactor/totp", middleware1FA(handlers.TimeBasedOneTimePasswordPOST))
r.DELETE("/api/secondfactor/totp", middleware2FA(handlers.TOTPConfigurationDELETE))
r.GET("/api/secondfactor/totp/register/options", middleware1FA(handlers.TOTPRegisterOptionsGET))
r.PUT("/api/secondfactor/totp/register", middleware1FA(handlers.TOTPRegisterPUT))
r.POST("/api/secondfactor/totp/register", middleware1FA(handlers.TOTPRegisterPOST))
r.DELETE("/api/secondfactor/totp/register", middleware1FA(handlers.TOTPRegisterDELETE))
}
if !config.WebAuthn.Disable {

View File

@ -269,6 +269,8 @@ func NewTemplatedFileOptions(config *schema.Configuration) (opts *TemplatedFileO
RememberMe: strconv.FormatBool(!config.Session.DisableRememberMe),
ResetPassword: strconv.FormatBool(!config.AuthenticationBackend.PasswordReset.Disable),
ResetPasswordCustomURL: config.AuthenticationBackend.PasswordReset.CustomURL.String(),
PrivacyPolicyURL: "",
PrivacyPolicyAccept: strFalse,
Theme: config.Theme,
EndpointsPasswordReset: !(config.AuthenticationBackend.PasswordReset.Disable || config.AuthenticationBackend.PasswordReset.CustomURL.String() != ""),

View File

@ -37,6 +37,7 @@ type UserSession struct {
// WebAuthn holds the session registration data for this session.
WebAuthn *WebAuthn
TOTP *TOTP
// This boolean is set to true after identity verification and checked
// while doing the query actually updating the password.
@ -45,7 +46,17 @@ type UserSession struct {
RefreshTTL time.Time
}
// WebAuthn holds the standard webauthn session data plus some extra.
// TOTP holds the TOTP registration session data.
type TOTP struct {
Issuer string
Algorithm string
Digits uint
Period uint
Secret string
Expires time.Time
}
// WebAuthn holds the standard WebAuthn session data plus some extra.
type WebAuthn struct {
*webauthn.SessionData
Description string

View File

@ -73,12 +73,12 @@ const (
const (
queryFmtSelectTOTPConfiguration = `
SELECT id, username, issuer, algorithm, digits, period, secret
SELECT id, created_at, last_used_at, username, issuer, algorithm, digits, period, secret
FROM %s
WHERE username = ?;`
queryFmtSelectTOTPConfigurations = `
SELECT id, username, issuer, algorithm, digits, period, secret
SELECT id, created_at, last_used_at, username, issuer, algorithm, digits, period, secret
FROM %s
LIMIT ?
OFFSET ?;`

View File

@ -4,7 +4,7 @@
###############################################################
jwt_secret: unsecure_secret
theme: auto
theme: dark
server:
address: 'tcp://:9091'
@ -48,6 +48,18 @@ storage:
totp:
issuer: example.com
allowed_algorithms:
- SHA1
- SHA256
- SHA512
allowed_digits:
- 6
- 8
allowed_periods:
- 30
- 60
- 90
- 120
access_control:
default_policy: deny

View File

@ -93,8 +93,8 @@ const (
var (
storageLocalTmpConfig = schema.Configuration{
TOTP: schema.TOTPConfiguration{
Issuer: "Authelia",
Period: 6,
Issuer: "Authelia",
DefaultPeriod: 6,
},
Storage: schema.StorageConfiguration{
EncryptionKey: "a_not_so_secure_encryption_key",

View File

@ -37,8 +37,7 @@ func (s *MultiCookieDomainScenario) SetupSuite() {
s.RodSession = browser
err = updateDevEnvFileForDomain(s.domain, false)
s.Require().NoError(err)
s.Require().NoError(updateDevEnvFileForDomain(s.domain, false))
}
func (s *MultiCookieDomainScenario) TearDownSuite() {
@ -133,8 +132,7 @@ func (s *MultiCookieDomainScenario) TestShouldStayLoggedInOnNextDomainWhenLogged
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", s.remember, s.domain, firstDomainTargetURL)
s.verifySecretAuthorized(s.T(), s.Page)
err := updateDevEnvFileForDomain(s.nextDomain, false)
s.Require().NoError(err)
s.Require().NoError(updateDevEnvFileForDomain(s.nextDomain, false))
s.doLoginOneFactor(s.T(), s.Context(ctx), "john", "password", !s.remember, s.nextDomain, nextDomainTargetURL)
s.verifySecretAuthorized(s.T(), s.Page)

View File

@ -9,4 +9,5 @@ type Provider interface {
Generate(username string) (config *model.TOTPConfiguration, err error)
GenerateCustom(username string, algorithm, secret string, digits, period, secretSize uint) (config *model.TOTPConfiguration, err error)
Validate(token string, config *model.TOTPConfiguration) (valid bool, err error)
Options() model.TOTPOptions
}

View File

@ -15,11 +15,23 @@ import (
// NewTimeBasedProvider creates a new totp.TimeBased which implements the totp.Provider.
func NewTimeBasedProvider(config schema.TOTPConfiguration) (provider *TimeBased) {
provider = &TimeBased{
config: &config,
opts: &model.TOTPOptions{
Algorithm: config.DefaultAlgorithm,
Algorithms: config.AllowedAlgorithms,
Period: config.DefaultPeriod,
Periods: config.AllowedPeriods,
Length: config.DefaultDigits,
Lengths: config.AllowedDigits,
},
issuer: config.Issuer,
algorithm: config.DefaultAlgorithm,
digits: uint(config.DefaultDigits),
period: uint(config.DefaultPeriod),
size: uint(config.SecretSize),
}
if config.Skew != nil {
provider.skew = *config.Skew
provider.skew = uint(*config.Skew)
} else {
provider.skew = 1
}
@ -29,8 +41,14 @@ func NewTimeBasedProvider(config schema.TOTPConfiguration) (provider *TimeBased)
// TimeBased totp.Provider for production use.
type TimeBased struct {
config *schema.TOTPConfiguration
skew uint
opts *model.TOTPOptions
issuer string
algorithm string
digits uint
period uint
skew uint
size uint
}
// GenerateCustom generates a TOTP with custom options.
@ -45,8 +63,12 @@ func (p TimeBased) GenerateCustom(username, algorithm, secret string, digits, pe
}
}
if secretSize == 0 {
secretSize = p.size
}
opts := totp.GenerateOpts{
Issuer: p.config.Issuer,
Issuer: p.issuer,
AccountName: username,
Period: period,
Secret: secretData,
@ -55,26 +77,32 @@ func (p TimeBased) GenerateCustom(username, algorithm, secret string, digits, pe
Algorithm: otpStringToAlgo(algorithm),
}
fmt.Println("secret before", opts.Secret)
if key, err = totp.Generate(opts); err != nil {
return nil, err
}
fmt.Println("secret key", key)
config = &model.TOTPConfiguration{
CreatedAt: time.Now(),
Username: username,
Issuer: p.config.Issuer,
Issuer: p.issuer,
Algorithm: algorithm,
Digits: digits,
Secret: []byte(key.Secret()),
Period: period,
}
fmt.Println("secret after", config.Secret)
return config, nil
}
// Generate generates a TOTP with default options.
func (p TimeBased) Generate(username string) (config *model.TOTPConfiguration, err error) {
return p.GenerateCustom(username, p.config.Algorithm, "", p.config.Digits, p.config.Period, p.config.SecretSize)
return p.GenerateCustom(username, p.algorithm, "", p.digits, p.period, p.size)
}
// Validate the token against the given configuration.
@ -86,5 +114,17 @@ func (p TimeBased) Validate(token string, config *model.TOTPConfiguration) (vali
Algorithm: otpStringToAlgo(config.Algorithm),
}
fmt.Println("period", opts.Period)
fmt.Println("skew", opts.Skew)
fmt.Println("digits", opts.Digits)
fmt.Println("algorithm", opts.Algorithm)
fmt.Println("token", token)
fmt.Println("secret", config.Secret)
return totp.ValidateCustom(token, string(config.Secret), time.Now().UTC(), opts)
}
// Options returns the configured options for this provider.
func (p TimeBased) Options() model.TOTPOptions {
return *p.opts
}

View File

@ -81,11 +81,11 @@ func TestTOTPGenerateCustom(t *testing.T) {
}
totp := NewTimeBasedProvider(schema.TOTPConfiguration{
Issuer: "Authelia",
Algorithm: "SHA1",
Digits: 6,
Period: 30,
SecretSize: 32,
Issuer: "Authelia",
DefaultAlgorithm: "SHA1",
DefaultDigits: 6,
DefaultPeriod: 30,
SecretSize: 32,
})
for _, tc := range testCases {
@ -118,15 +118,15 @@ func TestTOTPGenerateCustom(t *testing.T) {
}
func TestTOTPGenerate(t *testing.T) {
skew := uint(2)
skew := 2
totp := NewTimeBasedProvider(schema.TOTPConfiguration{
Issuer: "Authelia",
Algorithm: "SHA256",
Digits: 8,
Period: 60,
Skew: &skew,
SecretSize: 32,
Issuer: "Authelia",
DefaultAlgorithm: "SHA256",
DefaultDigits: 8,
DefaultPeriod: 60,
Skew: &skew,
SecretSize: 32,
})
assert.Equal(t, uint(2), totp.skew)

View File

@ -78,6 +78,7 @@ const App: React.FC<Props> = (props: Props) => {
}
}
}, []);
return (
<CacheProvider value={cache}>
<ThemeProvider theme={theme}>

View File

@ -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;

View File

@ -1,6 +1,13 @@
import { useRemoteCall } from "@hooks/RemoteCall";
import { getUserInfoTOTPConfiguration } from "@services/UserInfoTOTPConfiguration";
import {
getUserInfoTOTPConfiguration,
getUserInfoTOTPConfigurationOptional,
} from "@services/UserInfoTOTPConfiguration";
export function useUserInfoTOTPConfiguration() {
return useRemoteCall(getUserInfoTOTPConfiguration, []);
}
export function useUserInfoTOTPConfigurationOptional() {
return useRemoteCall(getUserInfoTOTPConfigurationOptional, []);
}

View File

@ -0,0 +1,6 @@
import { useRemoteCall } from "@hooks/RemoteCall";
import { getUserWebauthnDevices } from "@services/UserWebauthnDevices";
export function useUserWebauthnDevices() {
return useRemoteCall(getUserWebauthnDevices, []);
}

View File

@ -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;
}
}

View File

@ -1,4 +0,0 @@
export interface UserInfoTOTPConfiguration {
period: number;
digits: number;
}

View File

@ -11,10 +11,11 @@ export const FirstFactorPath = basePath + "/api/firstfactor";
export const InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/start";
export const CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish";
export const TOTPRegistrationOptionsPath = basePath + "/api/secondfactor/totp/register/options";
export const TOTPRegistrationPath = basePath + "/api/secondfactor/totp/register";
export const WebAuthnRegistrationPath = basePath + "/api/secondfactor/webauthn/credential/register";
export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn";
export const WebAuthnDevicesPath = basePath + "/api/secondfactor/webauthn/credentials";
export const WebAuthnDevicePath = basePath + "/api/secondfactor/webauthn/credential";

View File

@ -2,6 +2,16 @@ import axios from "axios";
import { ServiceResponse, hasServiceError, toData } from "@services/Api";
export async function PutWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
const res = await axios.put<ServiceResponse<T>>(path, body);
if (res.status !== 200 || hasServiceError(res).errored) {
throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
}
return toData<T>(res);
}
export async function PostWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
const res = await axios.post<ServiceResponse<T>>(path, body);
@ -12,6 +22,16 @@ export async function PostWithOptionalResponse<T = undefined>(path: string, body
return toData<T>(res);
}
export async function DeleteWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> {
const res = await axios.delete<ServiceResponse<T>>(path, body);
if (res.status !== 200 || hasServiceError(res).errored) {
throw new Error(`Failed DELETE to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`);
}
return toData<T>(res);
}
export async function Post<T>(path: string, body?: any) {
const res = await PostWithOptionalResponse<T>(path, body);
if (!res) {
@ -20,6 +40,14 @@ export async function Post<T>(path: string, body?: any) {
return res;
}
export async function Put<T>(path: string, body?: any) {
const res = await PutWithOptionalResponse<T>(path, body);
if (!res) {
throw new Error("unexpected type of response");
}
return res;
}
export async function Get<T = undefined>(path: string): Promise<T> {
const res = await axios.get<ServiceResponse<T>>(path);

View File

@ -1,5 +1,5 @@
import { CompleteTOTPSignInPath } from "@services/Api";
import { PostWithOptionalResponse } from "@services/Client";
import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api";
import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client";
import { SignInResponse } from "@services/SignIn";
interface CompleteTOTPSignInBody {
@ -19,3 +19,15 @@ export function completeTOTPSignIn(passcode: string, targetURL?: string, workflo
return PostWithOptionalResponse<SignInResponse>(CompleteTOTPSignInPath, body);
}
export function completeTOTPRegister(passcode: string) {
const body: CompleteTOTPSignInBody = {
token: `${passcode}`,
};
return PostWithOptionalResponse(TOTPRegistrationPath, body);
}
export function stopTOTPRegister() {
return DeleteWithOptionalResponse(TOTPRegistrationPath);
}

View File

@ -1,5 +1,5 @@
import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath } from "@services/Api";
import { Post, PostWithOptionalResponse } from "@services/Client";
import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath, TOTPRegistrationPath } from "@services/Api";
import { Post, PostWithOptionalResponse, Put } from "@services/Client";
export async function initiateTOTPRegistrationProcess() {
await PostWithOptionalResponse(InitiateTOTPRegistrationPath);
@ -13,3 +13,11 @@ interface CompleteTOTPRegistrationResponse {
export async function completeTOTPRegistrationProcess(processToken: string) {
return Post<CompleteTOTPRegistrationResponse>(CompleteTOTPRegistrationPath, { token: processToken });
}
export async function getTOTPSecret(algorithm: string, length: number, period: number) {
return Put<CompleteTOTPRegistrationResponse>(TOTPRegistrationPath, {
algorithm: algorithm,
length: length,
period: period,
});
}

View File

@ -1,15 +1,88 @@
import { UserInfoTOTPConfiguration } from "@models/UserInfoTOTPConfiguration";
import { UserInfoTOTPConfigurationPath } from "@services/Api";
import axios from "axios";
import {
TOTPAlgorithmPayload,
TOTPDigits,
TOTPOptions,
UserInfoTOTPConfiguration,
toEnum,
} from "@models/TOTPConfiguration";
import {
AuthenticationOKResponse,
CompleteTOTPSignInPath,
ServiceResponse,
TOTPRegistrationOptionsPath,
UserInfoTOTPConfigurationPath,
validateStatusAuthentication,
} from "@services/Api";
import { Get } from "@services/Client";
export type TOTPDigits = 6 | 8;
export interface UserInfoTOTPConfigurationPayload {
period: number;
created_at: string;
last_used_at?: string;
issuer: string;
algorithm: TOTPAlgorithmPayload;
digits: TOTPDigits;
period: number;
}
function toUserInfoTOTPConfiguration(payload: UserInfoTOTPConfigurationPayload): UserInfoTOTPConfiguration {
return {
created_at: new Date(payload.created_at),
last_used_at: payload.last_used_at ? new Date(payload.last_used_at) : undefined,
issuer: payload.issuer,
algorithm: toEnum(payload.algorithm),
digits: payload.digits,
period: payload.period,
};
}
export async function getUserInfoTOTPConfiguration(): Promise<UserInfoTOTPConfiguration> {
const res = await Get<UserInfoTOTPConfigurationPayload>(UserInfoTOTPConfigurationPath);
return { ...res };
return toUserInfoTOTPConfiguration(res);
}
export async function getUserInfoTOTPConfigurationOptional(): Promise<UserInfoTOTPConfiguration | null> {
const res = await axios.get<ServiceResponse<UserInfoTOTPConfigurationPayload>>(UserInfoTOTPConfigurationPath, {
validateStatus: function (status) {
return status < 300 || status === 404;
},
});
if (res === null || res.status === 404 || res.data.status === "KO") {
return null;
}
return toUserInfoTOTPConfiguration(res.data.data);
}
export interface TOTPOptionsPayload {
algorithm: TOTPAlgorithmPayload;
algorithms: TOTPAlgorithmPayload[];
length: TOTPDigits;
lengths: TOTPDigits[];
period: number;
periods: number[];
}
export async function getTOTPOptions(): Promise<TOTPOptions> {
const res = await Get<TOTPOptionsPayload>(TOTPRegistrationOptionsPath);
return {
algorithm: toEnum(res.algorithm),
algorithms: res.algorithms.map((alg) => toEnum(alg)),
length: res.length,
lengths: res.lengths,
period: res.period,
periods: res.periods,
};
}
export async function deleteUserTOTPConfiguration() {
return await axios<AuthenticationOKResponse>({
method: "DELETE",
url: CompleteTOTPSignInPath,
validateStatus: validateStatusAuthentication,
});
}

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { IconDefinition, faCopy, faKey, faTimesCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, CircularProgress, IconButton, Link, TextField, Theme, Typography } from "@mui/material";
import { Box, Button, CircularProgress, IconButton, Link, TextField, Theme, Typography } from "@mui/material";
import { red } from "@mui/material/colors";
import makeStyles from "@mui/styles/makeStyles";
import classnames from "classnames";
@ -92,8 +92,8 @@ const RegisterOneTimePassword = function () {
return (
<LoginLayout title={translate("Scan QR Code")}>
<div className={styles.root}>
<div className={styles.googleAuthenticator}>
<Box className={styles.root}>
<Box className={styles.googleAuthenticator}>
<Typography className={styles.googleAuthenticatorText}>
{translate("Need Google Authenticator?")}
</Typography>
@ -104,15 +104,15 @@ const RegisterOneTimePassword = function () {
googlePlayLink={GoogleAuthenticator.googlePlay}
appleStoreLink={GoogleAuthenticator.appleStore}
/>
</div>
<div className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}>
</Box>
<Box className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}>
<Link href={secretURL} underline="hover">
<QRCodeSVG value={secretURL} className={styles.qrcode} size={256} />
{!hasErrored && isLoading ? <CircularProgress className={styles.loader} size={128} /> : null}
{hasErrored ? <FontAwesomeIcon className={styles.failureIcon} icon={faTimesCircle} /> : null}
</Link>
</div>
<div>
</Box>
<Box>
{secretURL !== "empty" ? (
<TextField
id="secret-url"
@ -130,7 +130,7 @@ const RegisterOneTimePassword = function () {
{secretURL !== "empty"
? SecretButton(secretURL, translate("OTP URL copied to clipboard"), faCopy)
: null}
</div>
</Box>
<Button
variant="contained"
color="primary"
@ -140,7 +140,7 @@ const RegisterOneTimePassword = function () {
>
{translate("Done")}
</Button>
</div>
</Box>
</LoginLayout>
);
};

View File

@ -14,9 +14,9 @@ export interface Props {}
const SettingsRouter = function (props: Props) {
const navigate = useRouterNavigate();
const [state, fetchState, , fetchStateError] = useAutheliaState();
// Fetch the state on page load
useEffect(() => {
fetchState();
}, [fetchState]);
@ -24,6 +24,8 @@ const SettingsRouter = function (props: Props) {
useEffect(() => {
if (fetchStateError || (state && state.authentication_level < AuthenticationLevel.OneFactor)) {
navigate(IndexRoute);
return;
}
}, [state, fetchStateError, navigate]);
@ -33,7 +35,7 @@ const SettingsRouter = function (props: Props) {
<Route path={IndexRoute} element={<SettingsView />} />
<Route
path={SettingsTwoFactorAuthenticationSubRoute}
element={<TwoFactorAuthenticationView state={state} />}
element={state ? <TwoFactorAuthenticationView /> : null}
/>
</Routes>
</SettingsLayout>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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",
},
}));

View File

@ -4,7 +4,9 @@ import Grid from "@mui/material/Unstable_Grid2";
import { useNotifications } from "@hooks/NotificationsContext";
import { useUserInfoPOST } from "@hooks/UserInfo";
import { useUserInfoTOTPConfiguration, useUserInfoTOTPConfigurationOptional } from "@hooks/UserInfoTOTPConfiguration";
import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices";
import TOTPPanel from "@views/Settings/TwoFactorAuthentication/TOTPPanel";
import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel";
interface Props {}
@ -13,6 +15,7 @@ export default function TwoFactorAuthSettings(props: Props) {
const [refreshState, setRefreshState] = useState(0);
const { createErrorNotification } = useNotifications();
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST();
const [userTOTPConfig, fetchUserTOTPConfig, , fetchUserTOTPConfigError] = useUserInfoTOTPConfigurationOptional();
const [userWebAuthnDevices, fetchUserWebAuthnDevices, , fetchUserWebAuthnDevicesError] = useUserWebAuthnDevices();
const [hasTOTP, setHasTOTP] = useState(false);
const [hasWebAuthn, setHasWebAuthn] = useState(false);
@ -39,6 +42,10 @@ export default function TwoFactorAuthSettings(props: Props) {
}
}, [hasTOTP, hasWebAuthn, userInfo]);
useEffect(() => {
fetchUserTOTPConfig();
}, [fetchUserTOTPConfig, hasTOTP]);
useEffect(() => {
fetchUserWebAuthnDevices();
}, [fetchUserWebAuthnDevices, hasWebAuthn]);
@ -49,6 +56,12 @@ export default function TwoFactorAuthSettings(props: Props) {
}
}, [fetchUserInfoError, createErrorNotification]);
useEffect(() => {
if (fetchUserTOTPConfigError) {
createErrorNotification("There was an issue retrieving One Time Password Configuration");
}
}, [fetchUserTOTPConfigError, createErrorNotification]);
useEffect(() => {
if (fetchUserWebAuthnDevicesError) {
createErrorNotification("There was an issue retrieving One Time Password Configuration");
@ -57,6 +70,9 @@ export default function TwoFactorAuthSettings(props: Props) {
return (
<Grid container spacing={2}>
<Grid xs={12}>
<TOTPPanel config={userTOTPConfig} handleRefreshState={handleRefreshState} />
</Grid>
<Grid xs={12}>
<WebAuthnDevicesPanel devices={userWebAuthnDevices} handleRefreshState={handleRefreshState} />
</Grid>