feat(totp): secret customization (#2681)

Allow customizing the shared secrets size specifically for apps which don't support 256bit shared secrets.
pull/3134/head^2
James Elliott 2022-04-08 09:01:01 +10:00 committed by GitHub
parent fe08bf56b0
commit 9b6bcca1ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 333 additions and 143 deletions

View File

@ -127,6 +127,9 @@ totp:
skew: 1 skew: 1
## See: https://www.authelia.com/docs/configuration/one-time-password.html#input-validation to read the documentation. ## See: https://www.authelia.com/docs/configuration/one-time-password.html#input-validation to read the documentation.
## The size of the generated shared secrets. Default is 32 and is sufficient in most use cases, minimum is 20.
secret_size: 32
## ##
## WebAuthn Configuration ## WebAuthn Configuration
## ##

View File

@ -7,9 +7,9 @@ nav_order: 16
# Time-based One-Time Password # Time-based One-Time Password
Authelia uses time-based one-time passwords as the OTP method. You have The OTP method _Authelia_ uses is the Time-Based One-Time Password Algorithm (TOTP) [RFC6238] which is an extension of
the option to tune the settings of the TOTP generation, and you can see a HMAC-Based One-Time Password Algorithm (HOTP) [RFC4226]. You have the option to tune the settings of theTOTP generation,
full example of TOTP configuration below, as well as sections describing them. and you can see a full example of TOTP configuration below, as well as sections describing them.
## Configuration ## Configuration
```yaml ```yaml
@ -20,6 +20,7 @@ totp:
digits: 6 digits: 6
period: 30 period: 30
skew: 1 skew: 1
secret_size: 32
``` ```
## Options ## Options
@ -139,6 +140,21 @@ other.
Changing this value affects all TOTP validations, not just newly registered ones. Changing this value affects all TOTP validations, not just newly registered ones.
### secret_size
<div markdown="1">
type: integer
{: .label .label-config .label-purple }
default: 32
{: .label .label-config .label-blue }
required: no
{: .label .label-config .label-green }
</div>
The length in bytes of generated shared secrets. The minimum is 20 (or 160 bits), and the default is 32 (or 256 bits).
In most use cases 32 is sufficient. Though some authenticators may have issues with more than the minimum. Our minimum
is the recommended value in [RFC4226], though technically according to the specification 16 bytes (or 128 bits) is the
minimum.
## Registration ## Registration
When users register their TOTP device for the first time, the current [issuer](#issuer), [algorithm](#algorithm), and When users register their TOTP device for the first time, the current [issuer](#issuer), [algorithm](#algorithm), and
[period](#period) are used to generate the TOTP link and QR code. These values are saved to the database for future [period](#period) are used to generate the TOTP link and QR code. These values are saved to the database for future
@ -153,9 +169,8 @@ users to register a new device, you can delete the old device for a particular u
The period and skew configuration parameters affect each other. The default values are a period of 30 and a skew of 1. The period and skew configuration parameters affect each other. The default values are a period of 30 and a skew of 1.
It is highly recommended you do not change these unless you wish to set skew to 0. It is highly recommended you do not change these unless you wish to set skew to 0.
The way you configure these affects security by changing the length of time a one-time These options affect security by changing the length of time a one-time password is valid for. The formula to calculate
password is valid for. The formula to calculate the effective validity period is the effective validity period is `period + (period * skew * 2)`. For example period 30 and skew 1 would result in 90
`period + (period * skew * 2)`. For example period 30 and skew 1 would result in 90
seconds of validity, and period 30 and skew 2 would result in 150 seconds of validity. seconds of validity, and period 30 and skew 2 would result in 150 seconds of validity.
## System time accuracy ## System time accuracy
@ -192,3 +207,6 @@ Help:
```shell ```shell
$ authelia storage totp export --help $ authelia storage totp export --help
``` ```
[RFC4226]: https://datatracker.ietf.org/doc/html/rfc4226
[RFC6238]: https://datatracker.ietf.org/doc/html/rfc6238

View File

@ -2,6 +2,8 @@ package commands
import ( import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/authelia/authelia/v4/internal/configuration/schema"
) )
// NewStorageCmd returns a new storage *cobra.Command. // NewStorageCmd returns a new storage *cobra.Command.
@ -107,6 +109,8 @@ func newStorageTOTPGenerateCmd() (cmd *cobra.Command) {
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
} }
cmd.Flags().String("secret", "", "Optionally set the TOTP shared secret as base32 encoded bytes (no padding), it's recommended to not set this option unless you're restoring an TOTP config")
cmd.Flags().Uint("secret-size", schema.TOTPSecretSizeDefault, "set the TOTP secret size")
cmd.Flags().Uint("period", 30, "set the TOTP period") cmd.Flags().Uint("period", 30, "set the TOTP period")
cmd.Flags().Uint("digits", 6, "set the TOTP digits") cmd.Flags().Uint("digits", 6, "set the TOTP digits")
cmd.Flags().String("algorithm", "SHA1", "set the TOTP algorithm") cmd.Flags().String("algorithm", "SHA1", "set the TOTP algorithm")

View File

@ -2,6 +2,7 @@ package commands
import ( import (
"context" "context"
"encoding/base32"
"errors" "errors"
"fmt" "fmt"
"image" "image"
@ -11,6 +12,7 @@ import (
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/authelia/authelia/v4/internal/configuration" "github.com/authelia/authelia/v4/internal/configuration"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
@ -63,10 +65,11 @@ func storagePersistentPreRunE(cmd *cobra.Command, _ []string) (err error) {
"postgres.ssl.certificate": "storage.postgres.ssl.certificate", "postgres.ssl.certificate": "storage.postgres.ssl.certificate",
"postgres.ssl.key": "storage.postgres.ssl.key", "postgres.ssl.key": "storage.postgres.ssl.key",
"period": "totp.period", "period": "totp.period",
"digits": "totp.digits", "digits": "totp.digits",
"algorithm": "totp.algorithm", "algorithm": "totp.algorithm",
"issuer": "totp.issuer", "issuer": "totp.issuer",
"secret-size": "totp.secret_size",
} }
sources = append(sources, configuration.NewEnvironmentSource(configuration.DefaultEnvPrefix, configuration.DefaultEnvDelimiter)) sources = append(sources, configuration.NewEnvironmentSource(configuration.DefaultEnvPrefix, configuration.DefaultEnvDelimiter))
@ -201,13 +204,13 @@ func storageSchemaEncryptionChangeKeyRunE(cmd *cobra.Command, args []string) (er
func storageTOTPGenerateRunE(cmd *cobra.Command, args []string) (err error) { func storageTOTPGenerateRunE(cmd *cobra.Command, args []string) (err error) {
var ( var (
provider storage.Provider provider storage.Provider
ctx = context.Background() ctx = context.Background()
c *model.TOTPConfiguration c *model.TOTPConfiguration
force bool force bool
filename string filename, secret string
file *os.File file *os.File
img image.Image img image.Image
) )
provider = getStorageProvider() provider = getStorageProvider()
@ -216,25 +219,19 @@ func storageTOTPGenerateRunE(cmd *cobra.Command, args []string) (err error) {
_ = provider.Close() _ = provider.Close()
}() }()
if force, err = cmd.Flags().GetBool("force"); err != nil { if force, filename, secret, err = storageTOTPGenerateRunEOptsFromFlags(cmd.Flags()); err != nil {
return err
}
if filename, err = cmd.Flags().GetString("path"); err != nil {
return err return err
} }
if _, err = provider.LoadTOTPConfiguration(ctx, args[0]); err == nil && !force { if _, err = provider.LoadTOTPConfiguration(ctx, args[0]); err == nil && !force {
return fmt.Errorf("%s already has a TOTP configuration, use --force to overwrite", args[0]) return fmt.Errorf("%s already has a TOTP configuration, use --force to overwrite", args[0])
} } else if err != nil && !errors.Is(err, storage.ErrNoTOTPConfiguration) {
if err != nil && !errors.Is(err, storage.ErrNoTOTPConfiguration) {
return err return err
} }
totpProvider := totp.NewTimeBasedProvider(config.TOTP) totpProvider := totp.NewTimeBasedProvider(config.TOTP)
if c, err = totpProvider.Generate(args[0]); err != nil { if c, err = totpProvider.GenerateCustom(args[0], config.TOTP.Algorithm, secret, config.TOTP.Digits, config.TOTP.Period, config.TOTP.SecretSize); err != nil {
return err return err
} }
@ -271,6 +268,28 @@ func storageTOTPGenerateRunE(cmd *cobra.Command, args []string) (err error) {
return nil return nil
} }
func storageTOTPGenerateRunEOptsFromFlags(flags *pflag.FlagSet) (force bool, filename, secret string, err error) {
if force, err = flags.GetBool("force"); err != nil {
return force, filename, secret, err
}
if filename, err = flags.GetString("path"); err != nil {
return force, filename, secret, err
}
if secret, err = flags.GetString("secret"); err != nil {
return force, filename, secret, err
}
secretLength := base32.StdEncoding.WithPadding(base32.NoPadding).DecodedLen(len(secret))
if secret != "" && secretLength < schema.TOTPSecretSizeMinimum {
return force, filename, secret, fmt.Errorf("decoded length of the base32 secret must have "+
"a length of more than %d but '%s' has a decoded length of %d", schema.TOTPSecretSizeMinimum, secret, secretLength)
}
return force, filename, secret, nil
}
func storageTOTPDeleteRunE(cmd *cobra.Command, args []string) (err error) { func storageTOTPDeleteRunE(cmd *cobra.Command, args []string) (err error) {
var ( var (
provider storage.Provider provider storage.Provider

View File

@ -127,6 +127,9 @@ totp:
skew: 1 skew: 1
## See: https://www.authelia.com/docs/configuration/one-time-password.html#input-validation to read the documentation. ## See: https://www.authelia.com/docs/configuration/one-time-password.html#input-validation to read the documentation.
## The size of the generated shared secrets. Default is 32 and is sufficient in most use cases, minimum is 20.
secret_size: 32
## ##
## WebAuthn Configuration ## WebAuthn Configuration
## ##

View File

@ -44,3 +44,11 @@ var (
// TOTPPossibleAlgorithms is a list of valid TOTP Algorithms. // TOTPPossibleAlgorithms is a list of valid TOTP Algorithms.
TOTPPossibleAlgorithms = []string{TOTPAlgorithmSHA1, TOTPAlgorithmSHA256, TOTPAlgorithmSHA512} TOTPPossibleAlgorithms = []string{TOTPAlgorithmSHA1, TOTPAlgorithmSHA256, TOTPAlgorithmSHA512}
) )
const (
// TOTPSecretSizeDefault is the default secret size.
TOTPSecretSizeDefault = 32
// TOTPSecretSizeMinimum is the minimum secret size.
TOTPSecretSizeMinimum = 20
)

View File

@ -2,21 +2,23 @@ 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"` Algorithm string `koanf:"algorithm"`
Digits uint `koanf:"digits"` Digits uint `koanf:"digits"`
Period uint `koanf:"period"` Period uint `koanf:"period"`
Skew *uint `koanf:"skew"` Skew *uint `koanf:"skew"`
SecretSize uint `koanf:"secret_size"`
} }
var defaultOtpSkew = uint(1) var defaultOtpSkew = uint(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, Algorithm: TOTPAlgorithmSHA1,
Digits: 6, Digits: 6,
Period: 30, Period: 30,
Skew: &defaultOtpSkew, Skew: &defaultOtpSkew,
SecretSize: TOTPSecretSizeDefault,
} }

View File

@ -104,9 +104,10 @@ const (
// TOTP Error constants. // TOTP Error constants.
const ( const (
errFmtTOTPInvalidAlgorithm = "totp: option 'algorithm' must be one of '%s' but it is configured as '%s'" errFmtTOTPInvalidAlgorithm = "totp: option 'algorithm' must be one of '%s' but it is configured as '%s'"
errFmtTOTPInvalidPeriod = "totp: option 'period' option must be 15 or more but it is configured as '%d'" errFmtTOTPInvalidPeriod = "totp: option 'period' option must be 15 or more but it is configured as '%d'"
errFmtTOTPInvalidDigits = "totp: option 'digits' must be 6 or 8 but it is configured as '%d'" errFmtTOTPInvalidDigits = "totp: option 'digits' must be 6 or 8 but it is configured as '%d'"
errFmtTOTPInvalidSecretSize = "totp: option 'secret_size' must be %d or higher but it is configured as '%d'" //nolint:gosec
) )
// Storage Error constants. // Storage Error constants.
@ -114,7 +115,7 @@ const (
errStrStorage = "storage: configuration for a 'local', 'mysql' or 'postgres' database must be provided" errStrStorage = "storage: configuration for a 'local', 'mysql' or 'postgres' database must be provided"
errStrStorageEncryptionKeyMustBeProvided = "storage: option 'encryption_key' must is required" errStrStorageEncryptionKeyMustBeProvided = "storage: option 'encryption_key' must is required"
errStrStorageEncryptionKeyTooShort = "storage: option 'encryption_key' must be 20 characters or longer" errStrStorageEncryptionKeyTooShort = "storage: option 'encryption_key' must be 20 characters or longer"
errFmtStorageUserPassMustBeProvided = "storage: %s: option 'username' and 'password' are required" //nolint: gosec errFmtStorageUserPassMustBeProvided = "storage: %s: option 'username' and 'password' are required" //nolint:gosec
errFmtStorageOptionMustBeProvided = "storage: %s: option '%s' is required" errFmtStorageOptionMustBeProvided = "storage: %s: option '%s' is required"
errFmtStoragePostgreSQLInvalidSSLMode = "storage: postgres: ssl: option 'mode' must be one of '%s' but it is configured as '%s'" errFmtStoragePostgreSQLInvalidSSLMode = "storage: postgres: ssl: option 'mode' must be one of '%s' but it is configured as '%s'"
) )
@ -325,6 +326,7 @@ var ValidKeys = []string{
"totp.digits", "totp.digits",
"totp.period", "totp.period",
"totp.skew", "totp.skew",
"totp.secret_size",
// Webauthn Keys. // Webauthn Keys.
"webauthn.disable", "webauthn.disable",

View File

@ -10,6 +10,10 @@ import (
// ValidateTOTP validates and update TOTP configuration. // ValidateTOTP validates and update TOTP configuration.
func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidator) { func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidator) {
if config.TOTP.Disable {
return
}
if config.TOTP.Issuer == "" { if config.TOTP.Issuer == "" {
config.TOTP.Issuer = schema.DefaultTOTPConfiguration.Issuer config.TOTP.Issuer = schema.DefaultTOTPConfiguration.Issuer
} }
@ -39,4 +43,10 @@ func ValidateTOTP(config *schema.Configuration, validator *schema.StructValidato
if config.TOTP.Skew == nil { if config.TOTP.Skew == nil {
config.TOTP.Skew = schema.DefaultTOTPConfiguration.Skew config.TOTP.Skew = schema.DefaultTOTPConfiguration.Skew
} }
if config.TOTP.SecretSize == 0 {
config.TOTP.SecretSize = schema.DefaultTOTPConfiguration.SecretSize
} else if config.TOTP.SecretSize < schema.TOTPSecretSizeMinimum {
validator.Push(fmt.Errorf(errFmtTOTPInvalidSecretSize, schema.TOTPSecretSizeMinimum, config.TOTP.SecretSize))
}
} }

View File

@ -2,7 +2,6 @@ package validator
import ( import (
"fmt" "fmt"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -11,63 +10,112 @@ import (
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
) )
func TestShouldSetDefaultTOTPValues(t *testing.T) { func TestValidateTOTP(t *testing.T) {
validator := schema.NewStructValidator() testCases := []struct {
config := &schema.Configuration{ desc string
TOTP: schema.TOTPConfiguration{}, have schema.TOTPConfiguration
} expected schema.TOTPConfiguration
errs []string
ValidateTOTP(config, validator) warns []string
}{
require.Len(t, validator.Errors(), 0) {
assert.Equal(t, "Authelia", config.TOTP.Issuer) desc: "ShouldSetDefaultTOTPValues",
assert.Equal(t, schema.DefaultTOTPConfiguration.Algorithm, config.TOTP.Algorithm) expected: schema.DefaultTOTPConfiguration,
assert.Equal(t, schema.DefaultTOTPConfiguration.Skew, config.TOTP.Skew) },
assert.Equal(t, schema.DefaultTOTPConfiguration.Period, config.TOTP.Period) {
} desc: "ShouldNotSetDefaultTOTPValuesWhenDisabled",
have: schema.TOTPConfiguration{Disable: true},
func TestShouldNormalizeTOTPAlgorithm(t *testing.T) { expected: schema.TOTPConfiguration{Disable: true},
validator := schema.NewStructValidator() },
{
config := &schema.Configuration{ desc: "ShouldNormalizeTOTPAlgorithm",
TOTP: schema.TOTPConfiguration{ have: schema.TOTPConfiguration{
Algorithm: "sha1", Algorithm: "sha1",
Digits: 6,
Period: 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",
},
},
{
desc: "ShouldRaiseErrorWhenInvalidTOTPAlgorithm",
have: schema.TOTPConfiguration{
Algorithm: "sha3",
Digits: 6,
Period: 30,
SecretSize: 32,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
},
errs: []string{"totp: option 'algorithm' must be one of 'SHA1', 'SHA256', 'SHA512' but it is configured as 'SHA3'"},
},
{
desc: "ShouldRaiseErrorWhenInvalidTOTPValue",
have: schema.TOTPConfiguration{
Algorithm: "sha3",
Period: 5,
Digits: 20,
SecretSize: 10,
Skew: schema.DefaultTOTPConfiguration.Skew,
Issuer: "abc",
},
errs: []string{
"totp: option 'algorithm' must be one of 'SHA1', 'SHA256', 'SHA512' but it is configured as 'SHA3'",
"totp: option 'period' option must be 15 or more but it is configured as '5'",
"totp: option 'digits' must be 6 or 8 but it is configured as '20'",
"totp: option 'secret_size' must be 20 or higher but it is configured as '10'",
},
}, },
} }
ValidateTOTP(config, validator) for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
validator := schema.NewStructValidator()
config := &schema.Configuration{TOTP: tc.have}
assert.Len(t, validator.Errors(), 0) ValidateTOTP(config, validator)
assert.Equal(t, "SHA1", config.TOTP.Algorithm)
}
func TestShouldRaiseErrorWhenInvalidTOTPAlgorithm(t *testing.T) { errs := validator.Errors()
validator := schema.NewStructValidator() warns := validator.Warnings()
config := &schema.Configuration{ if len(tc.errs) == 0 {
TOTP: schema.TOTPConfiguration{ assert.Len(t, errs, 0)
Algorithm: "sha3", 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.Skew, config.TOTP.Skew)
assert.Equal(t, tc.expected.Period, config.TOTP.Period)
assert.Equal(t, tc.expected.SecretSize, config.TOTP.SecretSize)
} else {
expectedErrs := len(tc.errs)
require.Len(t, errs, expectedErrs)
for i := 0; i < expectedErrs; i++ {
t.Run(fmt.Sprintf("Err%d", i+1), func(t *testing.T) {
assert.EqualError(t, errs[i], tc.errs[i])
})
}
}
expectedWarns := len(tc.warns)
require.Len(t, warns, expectedWarns)
for i := 0; i < expectedWarns; i++ {
t.Run(fmt.Sprintf("Err%d", i+1), func(t *testing.T) {
assert.EqualError(t, warns[i], tc.warns[i])
})
}
})
} }
ValidateTOTP(config, validator)
require.Len(t, validator.Errors(), 1)
assert.EqualError(t, validator.Errors()[0], fmt.Sprintf(errFmtTOTPInvalidAlgorithm, strings.Join(schema.TOTPPossibleAlgorithms, "', '"), "SHA3"))
}
func TestShouldRaiseErrorWhenInvalidTOTPValues(t *testing.T) {
validator := schema.NewStructValidator()
config := &schema.Configuration{
TOTP: schema.TOTPConfiguration{
Period: 5,
Digits: 20,
},
}
ValidateTOTP(config, validator)
require.Len(t, validator.Errors(), 2)
assert.EqualError(t, validator.Errors()[0], fmt.Sprintf(errFmtTOTPInvalidPeriod, 5))
assert.EqualError(t, validator.Errors()[1], fmt.Sprintf(errFmtTOTPInvalidDigits, 20))
} }

View File

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

View File

@ -7,6 +7,6 @@ import (
// Provider for TOTP functionality. // Provider for TOTP functionality.
type Provider interface { type Provider interface {
Generate(username string) (config *model.TOTPConfiguration, err error) Generate(username string) (config *model.TOTPConfiguration, err error)
GenerateCustom(username string, algorithm 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)
} }

View File

@ -1,6 +1,8 @@
package totp package totp
import ( import (
"encoding/base32"
"fmt"
"time" "time"
"github.com/pquerna/otp" "github.com/pquerna/otp"
@ -32,13 +34,22 @@ type TimeBased struct {
} }
// GenerateCustom generates a TOTP with custom options. // GenerateCustom generates a TOTP with custom options.
func (p TimeBased) GenerateCustom(username, algorithm string, digits, period, secretSize uint) (config *model.TOTPConfiguration, err error) { func (p TimeBased) GenerateCustom(username, algorithm, secret string, digits, period, secretSize uint) (config *model.TOTPConfiguration, err error) {
var key *otp.Key var key *otp.Key
var secretData []byte
if secret != "" {
if secretData, err = base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret); err != nil {
return nil, fmt.Errorf("totp generate failed: error decoding base32 string: %w", err)
}
}
opts := totp.GenerateOpts{ opts := totp.GenerateOpts{
Issuer: p.config.Issuer, Issuer: p.config.Issuer,
AccountName: username, AccountName: username,
Period: period, Period: period,
Secret: secretData,
SecretSize: secretSize, SecretSize: secretSize,
Digits: otp.Digits(digits), Digits: otp.Digits(digits),
Algorithm: otpStringToAlgo(algorithm), Algorithm: otpStringToAlgo(algorithm),
@ -63,7 +74,7 @@ func (p TimeBased) GenerateCustom(username, algorithm string, digits, period, se
// 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, 32) return p.GenerateCustom(username, p.config.Algorithm, "", p.config.Digits, p.config.Period, p.config.SecretSize)
} }
// Validate the token against the given configuration. // Validate the token against the given configuration.

View File

@ -6,65 +6,127 @@ import (
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/authelia/authelia/v4/internal/configuration/schema" "github.com/authelia/authelia/v4/internal/configuration/schema"
) )
func TestTOTPGenerateCustom(t *testing.T) { func TestTOTPGenerateCustom(t *testing.T) {
testCases := []struct {
desc string
username, algorithm, secret string
digits, period, secretSize uint
err string
}{
{
desc: "ShouldGenerateSHA1",
username: "john",
algorithm: "SHA1",
digits: 6,
period: 30,
secretSize: 32,
},
{
desc: "ShouldGenerateLongSecret",
username: "john",
algorithm: "SHA1",
digits: 6,
period: 30,
secretSize: 42,
},
{
desc: "ShouldGenerateSHA256",
username: "john",
algorithm: "SHA256",
digits: 6,
period: 30,
secretSize: 32,
},
{
desc: "ShouldGenerateSHA512",
username: "john",
algorithm: "SHA512",
digits: 6,
period: 30,
secretSize: 32,
},
{
desc: "ShouldGenerateWithSecret",
username: "john",
algorithm: "SHA512",
secret: "ONTGOYLTMZQXGZDBONSGC43EMFZWMZ3BONTWMYLTMRQXGZBSGMYTEMZRMFYXGZDBONSA",
digits: 6,
period: 30,
secretSize: 32,
},
{
desc: "ShouldGenerateWithBadSecretB32Data",
username: "john",
algorithm: "SHA512",
secret: "@#UNH$IK!J@N#EIKJ@U!NIJKUN@#WIK",
digits: 6,
period: 30,
secretSize: 32,
err: "totp generate failed: error decoding base32 string: illegal base32 data at input byte 0",
},
{
desc: "ShouldGenerateWithBadSecretLength",
username: "john",
algorithm: "SHA512",
secret: "ONTGOYLTMZQXGZD",
digits: 6,
period: 30,
secretSize: 0,
},
}
totp := NewTimeBasedProvider(schema.TOTPConfiguration{ totp := NewTimeBasedProvider(schema.TOTPConfiguration{
Issuer: "Authelia", Issuer: "Authelia",
Algorithm: "SHA1", Algorithm: "SHA1",
Digits: 6, Digits: 6,
Period: 30, Period: 30,
SecretSize: 32,
}) })
assert.Equal(t, uint(1), totp.skew) for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
c, err := totp.GenerateCustom(tc.username, tc.algorithm, tc.secret, tc.digits, tc.period, tc.secretSize)
if tc.err == "" {
assert.NoError(t, err)
require.NotNil(t, c)
assert.Equal(t, tc.period, c.Period)
assert.Equal(t, tc.digits, c.Digits)
assert.Equal(t, tc.algorithm, c.Algorithm)
config, err := totp.GenerateCustom("john", "SHA1", 6, 30, 32) expectedSecretLen := int(tc.secretSize)
assert.NoError(t, err) if tc.secret != "" {
expectedSecretLen = base32.StdEncoding.WithPadding(base32.NoPadding).DecodedLen(len(tc.secret))
}
assert.Equal(t, uint(6), config.Digits) secret := make([]byte, expectedSecretLen)
assert.Equal(t, uint(30), config.Period)
assert.Equal(t, "SHA1", config.Algorithm)
assert.Less(t, time.Since(config.CreatedAt), time.Second) n, err := base32.StdEncoding.WithPadding(base32.NoPadding).Decode(secret, c.Secret)
assert.Greater(t, time.Since(config.CreatedAt), time.Second*-1) assert.NoError(t, err)
assert.Len(t, secret, expectedSecretLen)
secret := make([]byte, base32.StdEncoding.WithPadding(base32.NoPadding).DecodedLen(len(config.Secret))) assert.Equal(t, expectedSecretLen, n)
} else {
_, err = base32.StdEncoding.WithPadding(base32.NoPadding).Decode(secret, config.Secret) assert.Nil(t, c)
assert.NoError(t, err) assert.EqualError(t, err, tc.err)
assert.Len(t, secret, 32) }
})
config, err = totp.GenerateCustom("john", "SHA1", 6, 30, 42) }
assert.NoError(t, err)
assert.Equal(t, uint(6), config.Digits)
assert.Equal(t, uint(30), config.Period)
assert.Equal(t, "SHA1", config.Algorithm)
assert.Less(t, time.Since(config.CreatedAt), time.Second)
assert.Greater(t, time.Since(config.CreatedAt), time.Second*-1)
secret = make([]byte, base32.StdEncoding.WithPadding(base32.NoPadding).DecodedLen(len(config.Secret)))
_, err = base32.StdEncoding.WithPadding(base32.NoPadding).Decode(secret, config.Secret)
assert.NoError(t, err)
assert.Len(t, secret, 42)
_, err = totp.GenerateCustom("", "SHA1", 6, 30, 32)
assert.EqualError(t, err, "AccountName must be set")
} }
func TestTOTPGenerate(t *testing.T) { func TestTOTPGenerate(t *testing.T) {
skew := uint(2) skew := uint(2)
totp := NewTimeBasedProvider(schema.TOTPConfiguration{ totp := NewTimeBasedProvider(schema.TOTPConfiguration{
Issuer: "Authelia", Issuer: "Authelia",
Algorithm: "SHA256", Algorithm: "SHA256",
Digits: 8, Digits: 8,
Period: 60, Period: 60,
Skew: &skew, Skew: &skew,
SecretSize: 32,
}) })
assert.Equal(t, uint(2), totp.skew) assert.Equal(t, uint(2), totp.skew)