[FEATURE] TOTP Tuning Configuration Options and Fix Timer Graphic (#773)
* Add period TOPT config key to define the time in seconds each OTP is rotated * Add skew TOTP config to define how many keys either side of the current one should be considered valid * Add tests and set minimum values * Update config template * Use unix epoch for position calculation and Fix QR gen * This resolves the timer resetting improperly at the 0 seconds mark and allows for periods longer than 1 minute * Generate QR based on period * Fix OTP timer graphicpull/779/head
parent
c057c917f6
commit
40fb13ba3c
|
@ -35,12 +35,21 @@ default_redirection_url: https://home.example.com:8080/
|
||||||
#
|
#
|
||||||
## google_analytics: UA-00000-01
|
## google_analytics: UA-00000-01
|
||||||
|
|
||||||
# TOTP Issuer Name
|
# TOTP Settings
|
||||||
#
|
#
|
||||||
# This will be the issuer name displayed in Google Authenticator
|
# Parameters used for TOTP generation
|
||||||
# See: https://github.com/google/google-authenticator/wiki/Key-Uri-Format for more info on issuer names
|
|
||||||
totp:
|
totp:
|
||||||
|
# The issuer name displayed in the Authenticator application of your choice
|
||||||
|
# See: https://github.com/google/google-authenticator/wiki/Key-Uri-Format for more info on issuer names
|
||||||
issuer: authelia.com
|
issuer: authelia.com
|
||||||
|
# The period in seconds a one-time password is current for. Changing this will require all users to register
|
||||||
|
# their TOTP applications again.
|
||||||
|
# Warning: before changing period read the docs link below.
|
||||||
|
period: 30
|
||||||
|
# The skew controls number of one-time passwords either side of the current one that are valid.
|
||||||
|
# Warning: before changing skew read the docs link below.
|
||||||
|
skew: 1
|
||||||
|
# See: https://docs.authelia.com/configuration/one-time-password.html#period-and-skew to read the documentation.
|
||||||
|
|
||||||
# Duo Push API
|
# Duo Push API
|
||||||
#
|
#
|
||||||
|
|
|
@ -7,11 +7,47 @@ nav_order: 6
|
||||||
|
|
||||||
# One-Time Password
|
# One-Time Password
|
||||||
|
|
||||||
Applications generating one-time passwords usually displays an issuer to
|
Authelia uses time based one-time passwords as the OTP method. You have
|
||||||
differentiate the various applications registered by the user.
|
the option to tune the settings of the TOTP generation and you can see a
|
||||||
|
full example of TOTP configuration below, as well as sections describing them.
|
||||||
Authelia allows to customize the issuer to differentiate the entry created
|
|
||||||
by Authelia from others.
|
|
||||||
|
|
||||||
totp:
|
totp:
|
||||||
issuer: authelia.com
|
issuer: authelia.com
|
||||||
|
period: 30
|
||||||
|
skew: 1
|
||||||
|
|
||||||
|
## Issuer
|
||||||
|
|
||||||
|
Applications generating one-time passwords usually display an issuer to
|
||||||
|
differentiate applications registered by the user.
|
||||||
|
|
||||||
|
Authelia allows customisation of the issuer to differentiate the entry created
|
||||||
|
by Authelia from others.
|
||||||
|
|
||||||
|
## Period and Skew
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The way you configure these affects security by changing the length of time a one-time
|
||||||
|
password is valid for. The formula to calculate the effective validity period is
|
||||||
|
`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.
|
||||||
|
|
||||||
|
|
||||||
|
### Period
|
||||||
|
|
||||||
|
Configures the period of time in seconds a one-time password is current for. It is important
|
||||||
|
to note that changing this value will require your users to register their application again.
|
||||||
|
|
||||||
|
It is recommended to keep this value set to 30, the minimum is 1.
|
||||||
|
|
||||||
|
### Skew
|
||||||
|
|
||||||
|
Configures the number of one-time passwords either side of the current one that are
|
||||||
|
considered valid, each time you increase this it makes two more one-time passwords valid.
|
||||||
|
For example the default of 1 has a total of 3 keys valid. A value of 2 has 5 one-time passwords
|
||||||
|
valid.
|
||||||
|
|
||||||
|
It is recommended to keep this value set to 0 or 1, the minimum is 0.
|
2
go.mod
2
go.mod
|
@ -13,7 +13,7 @@ require (
|
||||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||||
github.com/fasthttp/router v0.7.0
|
github.com/fasthttp/router v0.7.0
|
||||||
github.com/fasthttp/session v1.1.7
|
github.com/fasthttp/session v1.1.7
|
||||||
github.com/go-ldap/ldap/v3 v3.1.7 // indirect
|
github.com/go-ldap/ldap/v3 v3.1.7
|
||||||
github.com/go-sql-driver/mysql v1.5.0
|
github.com/go-sql-driver/mysql v1.5.0
|
||||||
github.com/golang/mock v1.4.3
|
github.com/golang/mock v1.4.3
|
||||||
github.com/golang/snappy v0.0.1 // indirect
|
github.com/golang/snappy v0.0.1 // indirect
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -223,10 +223,12 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9
|
||||||
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
|
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
|
||||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||||
|
github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg=
|
||||||
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
|
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
|
||||||
github.com/philhofer/fwd v1.0.0 h1:UbZqGr5Y38ApvM/V/jEljVxwocdweyH+vmYvRPBnbqQ=
|
github.com/philhofer/fwd v1.0.0 h1:UbZqGr5Y38ApvM/V/jEljVxwocdweyH+vmYvRPBnbqQ=
|
||||||
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
|
|
@ -3,4 +3,6 @@ package schema
|
||||||
// TOTPConfiguration represents the configuration related to TOTP options.
|
// TOTPConfiguration represents the configuration related to TOTP options.
|
||||||
type TOTPConfiguration struct {
|
type TOTPConfiguration struct {
|
||||||
Issuer string `mapstructure:"issuer"`
|
Issuer string `mapstructure:"issuer"`
|
||||||
|
Period int `mapstructure:"period"`
|
||||||
|
Skew *int `mapstructure:"skew"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,8 +46,8 @@ func Validate(configuration *schema.Configuration, validator *schema.StructValid
|
||||||
|
|
||||||
if configuration.TOTP == nil {
|
if configuration.TOTP == nil {
|
||||||
configuration.TOTP = &schema.TOTPConfiguration{}
|
configuration.TOTP = &schema.TOTPConfiguration{}
|
||||||
ValidateTOTP(configuration.TOTP, validator)
|
|
||||||
}
|
}
|
||||||
|
ValidateTOTP(configuration.TOTP, validator)
|
||||||
|
|
||||||
if configuration.Notifier == nil {
|
if configuration.Notifier == nil {
|
||||||
validator.Push(fmt.Errorf("A notifier configuration must be provided"))
|
validator.Push(fmt.Errorf("A notifier configuration must be provided"))
|
||||||
|
|
|
@ -1,14 +1,29 @@
|
||||||
package validator
|
package validator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/authelia/authelia/internal/configuration/schema"
|
"github.com/authelia/authelia/internal/configuration/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultTOTPIssuer = "Authelia"
|
const defaultTOTPIssuer = "Authelia"
|
||||||
|
const DefaultTOTPPeriod = 30
|
||||||
|
const DefaultTOTPSkew = 1
|
||||||
|
|
||||||
// ValidateTOTP validates and update TOTP configuration.
|
// ValidateTOTP validates and update TOTP configuration.
|
||||||
func ValidateTOTP(configuration *schema.TOTPConfiguration, validator *schema.StructValidator) {
|
func ValidateTOTP(configuration *schema.TOTPConfiguration, validator *schema.StructValidator) {
|
||||||
if configuration.Issuer == "" {
|
if configuration.Issuer == "" {
|
||||||
configuration.Issuer = defaultTOTPIssuer
|
configuration.Issuer = defaultTOTPIssuer
|
||||||
}
|
}
|
||||||
|
if configuration.Period == 0 {
|
||||||
|
configuration.Period = DefaultTOTPPeriod
|
||||||
|
} else if configuration.Period < 0 {
|
||||||
|
validator.Push(fmt.Errorf("TOTP Period must be 1 or more"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if configuration.Skew == nil {
|
||||||
|
var skew = DefaultTOTPSkew
|
||||||
|
configuration.Skew = &skew
|
||||||
|
} else if *configuration.Skew < 0 {
|
||||||
|
validator.Push(fmt.Errorf("TOTP Skew must be 0 or more"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShouldSetDefaultIssuer(t *testing.T) {
|
func TestShouldSetDefaultTOTPValues(t *testing.T) {
|
||||||
validator := schema.NewStructValidator()
|
validator := schema.NewStructValidator()
|
||||||
config := schema.TOTPConfiguration{}
|
config := schema.TOTPConfiguration{}
|
||||||
|
|
||||||
|
@ -16,4 +16,20 @@ func TestShouldSetDefaultIssuer(t *testing.T) {
|
||||||
|
|
||||||
require.Len(t, validator.Errors(), 0)
|
require.Len(t, validator.Errors(), 0)
|
||||||
assert.Equal(t, "Authelia", config.Issuer)
|
assert.Equal(t, "Authelia", config.Issuer)
|
||||||
|
assert.Equal(t, DefaultTOTPSkew, *config.Skew)
|
||||||
|
assert.Equal(t, DefaultTOTPPeriod, config.Period)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldRaiseErrorWhenInvalidTOTPMinimumValues(t *testing.T) {
|
||||||
|
var badSkew = -1
|
||||||
|
validator := schema.NewStructValidator()
|
||||||
|
config := schema.TOTPConfiguration{
|
||||||
|
Period: -5,
|
||||||
|
Skew: &badSkew,
|
||||||
|
}
|
||||||
|
ValidateTOTP(&config, validator)
|
||||||
|
assert.Len(t, validator.Errors(), 2)
|
||||||
|
assert.EqualError(t, validator.Errors()[0], "TOTP Period must be 1 or more")
|
||||||
|
assert.EqualError(t, validator.Errors()[1], "TOTP Skew must be 0 or more")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,12 +11,16 @@ type ExtendedConfigurationBody struct {
|
||||||
|
|
||||||
// SecondFactorEnabled whether second factor is enabled
|
// SecondFactorEnabled whether second factor is enabled
|
||||||
SecondFactorEnabled bool `json:"second_factor_enabled"`
|
SecondFactorEnabled bool `json:"second_factor_enabled"`
|
||||||
|
|
||||||
|
// TOTP Period
|
||||||
|
TOTPPeriod int `json:"totp_period"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtendedConfigurationGet get the extended configuration accessible to authenticated users.
|
// ExtendedConfigurationGet get the extended configuration accessible to authenticated users.
|
||||||
func ExtendedConfigurationGet(ctx *middlewares.AutheliaCtx) {
|
func ExtendedConfigurationGet(ctx *middlewares.AutheliaCtx) {
|
||||||
body := ExtendedConfigurationBody{}
|
body := ExtendedConfigurationBody{}
|
||||||
body.AvailableMethods = MethodList{authentication.TOTP, authentication.U2F}
|
body.AvailableMethods = MethodList{authentication.TOTP, authentication.U2F}
|
||||||
|
body.TOTPPeriod = ctx.Configuration.TOTP.Period
|
||||||
|
|
||||||
if ctx.Configuration.DuoAPI != nil {
|
if ctx.Configuration.DuoAPI != nil {
|
||||||
body.AvailableMethods = append(body.AvailableMethods, authentication.Push)
|
body.AvailableMethods = append(body.AvailableMethods, authentication.Push)
|
||||||
|
|
|
@ -4,9 +4,10 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/authelia/authelia/internal/authorization"
|
"github.com/authelia/authelia/internal/authorization"
|
||||||
|
"github.com/authelia/authelia/internal/configuration/schema"
|
||||||
|
"github.com/authelia/authelia/internal/configuration/validator"
|
||||||
"github.com/authelia/authelia/internal/mocks"
|
"github.com/authelia/authelia/internal/mocks"
|
||||||
|
|
||||||
"github.com/authelia/authelia/internal/configuration/schema"
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -28,9 +29,15 @@ func (s *SecondFactorAvailableMethodsFixture) TearDownTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethods() {
|
func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethods() {
|
||||||
|
s.mock.Ctx.Configuration = schema.Configuration{
|
||||||
|
TOTP: &schema.TOTPConfiguration{
|
||||||
|
Period: validator.DefaultTOTPPeriod,
|
||||||
|
},
|
||||||
|
}
|
||||||
expectedBody := ExtendedConfigurationBody{
|
expectedBody := ExtendedConfigurationBody{
|
||||||
AvailableMethods: []string{"totp", "u2f"},
|
AvailableMethods: []string{"totp", "u2f"},
|
||||||
SecondFactorEnabled: false,
|
SecondFactorEnabled: false,
|
||||||
|
TOTPPeriod: validator.DefaultTOTPPeriod,
|
||||||
}
|
}
|
||||||
ExtendedConfigurationGet(s.mock.Ctx)
|
ExtendedConfigurationGet(s.mock.Ctx)
|
||||||
s.mock.Assert200OK(s.T(), expectedBody)
|
s.mock.Assert200OK(s.T(), expectedBody)
|
||||||
|
@ -39,16 +46,25 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethods() {
|
||||||
func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethodsAndMobilePush() {
|
func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethodsAndMobilePush() {
|
||||||
s.mock.Ctx.Configuration = schema.Configuration{
|
s.mock.Ctx.Configuration = schema.Configuration{
|
||||||
DuoAPI: &schema.DuoAPIConfiguration{},
|
DuoAPI: &schema.DuoAPIConfiguration{},
|
||||||
|
TOTP: &schema.TOTPConfiguration{
|
||||||
|
Period: validator.DefaultTOTPPeriod,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
expectedBody := ExtendedConfigurationBody{
|
expectedBody := ExtendedConfigurationBody{
|
||||||
AvailableMethods: []string{"totp", "u2f", "mobile_push"},
|
AvailableMethods: []string{"totp", "u2f", "mobile_push"},
|
||||||
SecondFactorEnabled: false,
|
SecondFactorEnabled: false,
|
||||||
|
TOTPPeriod: validator.DefaultTOTPPeriod,
|
||||||
}
|
}
|
||||||
ExtendedConfigurationGet(s.mock.Ctx)
|
ExtendedConfigurationGet(s.mock.Ctx)
|
||||||
s.mock.Assert200OK(s.T(), expectedBody)
|
s.mock.Assert200OK(s.T(), expectedBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsDisabledWhenNoRuleIsSetToTwoFactor() {
|
func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsDisabledWhenNoRuleIsSetToTwoFactor() {
|
||||||
|
s.mock.Ctx.Configuration = schema.Configuration{
|
||||||
|
TOTP: &schema.TOTPConfiguration{
|
||||||
|
Period: validator.DefaultTOTPPeriod,
|
||||||
|
},
|
||||||
|
}
|
||||||
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(schema.AccessControlConfiguration{
|
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(schema.AccessControlConfiguration{
|
||||||
DefaultPolicy: "bypass",
|
DefaultPolicy: "bypass",
|
||||||
Rules: []schema.ACLRule{
|
Rules: []schema.ACLRule{
|
||||||
|
@ -70,10 +86,16 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsDisab
|
||||||
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
|
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
|
||||||
AvailableMethods: []string{"totp", "u2f"},
|
AvailableMethods: []string{"totp", "u2f"},
|
||||||
SecondFactorEnabled: false,
|
SecondFactorEnabled: false,
|
||||||
|
TOTPPeriod: validator.DefaultTOTPPeriod,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabledWhenDefaultPolicySetToTwoFactor() {
|
func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabledWhenDefaultPolicySetToTwoFactor() {
|
||||||
|
s.mock.Ctx.Configuration = schema.Configuration{
|
||||||
|
TOTP: &schema.TOTPConfiguration{
|
||||||
|
Period: validator.DefaultTOTPPeriod,
|
||||||
|
},
|
||||||
|
}
|
||||||
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(schema.AccessControlConfiguration{
|
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(schema.AccessControlConfiguration{
|
||||||
DefaultPolicy: "two_factor",
|
DefaultPolicy: "two_factor",
|
||||||
Rules: []schema.ACLRule{
|
Rules: []schema.ACLRule{
|
||||||
|
@ -95,10 +117,16 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabl
|
||||||
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
|
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
|
||||||
AvailableMethods: []string{"totp", "u2f"},
|
AvailableMethods: []string{"totp", "u2f"},
|
||||||
SecondFactorEnabled: true,
|
SecondFactorEnabled: true,
|
||||||
|
TOTPPeriod: validator.DefaultTOTPPeriod,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabledWhenSomePolicySetToTwoFactor() {
|
func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabledWhenSomePolicySetToTwoFactor() {
|
||||||
|
s.mock.Ctx.Configuration = schema.Configuration{
|
||||||
|
TOTP: &schema.TOTPConfiguration{
|
||||||
|
Period: validator.DefaultTOTPPeriod,
|
||||||
|
},
|
||||||
|
}
|
||||||
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(schema.AccessControlConfiguration{
|
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(schema.AccessControlConfiguration{
|
||||||
DefaultPolicy: "bypass",
|
DefaultPolicy: "bypass",
|
||||||
Rules: []schema.ACLRule{
|
Rules: []schema.ACLRule{
|
||||||
|
@ -120,6 +148,7 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabl
|
||||||
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
|
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
|
||||||
AvailableMethods: []string{"totp", "u2f"},
|
AvailableMethods: []string{"totp", "u2f"},
|
||||||
SecondFactorEnabled: true,
|
SecondFactorEnabled: true,
|
||||||
|
TOTPPeriod: validator.DefaultTOTPPeriod,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -41,6 +41,7 @@ func secondFactorTOTPIdentityFinish(ctx *middlewares.AutheliaCtx, username strin
|
||||||
Issuer: ctx.Configuration.TOTP.Issuer,
|
Issuer: ctx.Configuration.TOTP.Issuer,
|
||||||
AccountName: username,
|
AccountName: username,
|
||||||
SecretSize: 32,
|
SecretSize: 32,
|
||||||
|
Period: uint(ctx.Configuration.TOTP.Period),
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -25,7 +25,11 @@ func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isValid := totpVerifier.Verify(bodyJSON.Token, secret)
|
isValid, err := totpVerifier.Verify(bodyJSON.Token, secret)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(fmt.Errorf("Error occurred during OTP validation for user %s: %s", userSession.Username, err), mfaValidationFailedMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !isValid {
|
if !isValid {
|
||||||
ctx.Error(fmt.Errorf("Wrong passcode during TOTP validation for user %s", userSession.Username), mfaValidationFailedMessage)
|
ctx.Error(fmt.Errorf("Wrong passcode during TOTP validation for user %s", userSession.Username), mfaValidationFailedMessage)
|
||||||
|
|
|
@ -40,7 +40,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToDefaultURL() {
|
||||||
|
|
||||||
verifier.EXPECT().
|
verifier.EXPECT().
|
||||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||||
Return(true)
|
Return(true, nil)
|
||||||
|
|
||||||
s.mock.Ctx.Configuration.DefaultRedirectionURL = "http://redirection.local"
|
s.mock.Ctx.Configuration.DefaultRedirectionURL = "http://redirection.local"
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ func (s *HandlerSignTOTPSuite) TestShouldNotReturnRedirectURL() {
|
||||||
|
|
||||||
verifier.EXPECT().
|
verifier.EXPECT().
|
||||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||||
Return(true)
|
Return(true, nil)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
||||||
Token: "abc",
|
Token: "abc",
|
||||||
|
@ -86,7 +86,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToSafeTargetURL() {
|
||||||
|
|
||||||
verifier.EXPECT().
|
verifier.EXPECT().
|
||||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||||
Return(true)
|
Return(true, nil)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
||||||
Token: "abc",
|
Token: "abc",
|
||||||
|
@ -110,7 +110,7 @@ func (s *HandlerSignTOTPSuite) TestShouldNotRedirectToUnsafeURL() {
|
||||||
|
|
||||||
verifier.EXPECT().
|
verifier.EXPECT().
|
||||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||||
Return(true)
|
Return(true, nil)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
||||||
Token: "abc",
|
Token: "abc",
|
||||||
|
@ -132,7 +132,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRegenerateSessionForPreventingSessionFi
|
||||||
|
|
||||||
verifier.EXPECT().
|
verifier.EXPECT().
|
||||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||||
Return(true)
|
Return(true, nil)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
||||||
Token: "abc",
|
Token: "abc",
|
||||||
|
|
|
@ -1,15 +1,26 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/pquerna/otp"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TOTPVerifier interface {
|
type TOTPVerifier interface {
|
||||||
Verify(token, secret string) bool
|
Verify(token, secret string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TOTPVerifierImpl struct{}
|
type TOTPVerifierImpl struct {
|
||||||
|
Period uint
|
||||||
func (tv *TOTPVerifierImpl) Verify(token, secret string) bool {
|
Skew uint
|
||||||
return totp.Validate(token, secret)
|
}
|
||||||
|
|
||||||
|
func (tv *TOTPVerifierImpl) Verify(token, secret string) (bool, error) {
|
||||||
|
opts := totp.ValidateOpts{
|
||||||
|
Period: tv.Period,
|
||||||
|
Skew: tv.Skew,
|
||||||
|
Digits: otp.DigitsSix,
|
||||||
|
Algorithm: otp.AlgorithmSHA1,
|
||||||
|
}
|
||||||
|
return totp.ValidateCustom(token, secret, time.Now().UTC(), opts)
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,11 +33,12 @@ func (m *MockTOTPVerifier) EXPECT() *MockTOTPVerifierMockRecorder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify mocks base method
|
// Verify mocks base method
|
||||||
func (m *MockTOTPVerifier) Verify(token, secret string) bool {
|
func (m *MockTOTPVerifier) Verify(token, secret string) (bool, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
ret := m.ctrl.Call(m, "Verify", token, secret)
|
ret := m.ctrl.Call(m, "Verify", token, secret)
|
||||||
ret0, _ := ret[0].(bool)
|
ret0, _ := ret[0].(bool)
|
||||||
return ret0
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify indicates an expected call of Verify
|
// Verify indicates an expected call of Verify
|
||||||
|
|
|
@ -61,7 +61,10 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
|
||||||
router.POST("/api/secondfactor/totp/identity/finish", autheliaMiddleware(
|
router.POST("/api/secondfactor/totp/identity/finish", autheliaMiddleware(
|
||||||
middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityFinish)))
|
middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityFinish)))
|
||||||
router.POST("/api/secondfactor/totp", autheliaMiddleware(
|
router.POST("/api/secondfactor/totp", autheliaMiddleware(
|
||||||
middlewares.RequireFirstFactor(handlers.SecondFactorTOTPPost(&handlers.TOTPVerifierImpl{}))))
|
middlewares.RequireFirstFactor(handlers.SecondFactorTOTPPost(&handlers.TOTPVerifierImpl{
|
||||||
|
Period: uint(configuration.TOTP.Period),
|
||||||
|
Skew: uint(*configuration.TOTP.Skew),
|
||||||
|
}))))
|
||||||
|
|
||||||
// U2F related endpoints
|
// U2F related endpoints
|
||||||
router.POST("/api/secondfactor/u2f/identity/start", autheliaMiddleware(
|
router.POST("/api/secondfactor/u2f/identity/start", autheliaMiddleware(
|
||||||
|
|
|
@ -26,7 +26,7 @@ export default function (props: Props) {
|
||||||
<circle r="5" cx="13" cy="13" fill="none"
|
<circle r="5" cx="13" cy="13" fill="none"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth="10"
|
strokeWidth="10"
|
||||||
strokeDasharray={`calc(${props.progress} * 31.6 / ${maxProgress}) 31.6`}
|
strokeDasharray={`${props.progress} ${maxProgress}`}
|
||||||
transform="rotate(-90) translate(-26)" />
|
transform="rotate(-90) translate(-26)" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,32 +4,31 @@ import PieChartIcon from "./PieChartIcon";
|
||||||
export interface Props {
|
export interface Props {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
period: number;
|
||||||
|
|
||||||
color?: string;
|
color?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function (props: Props) {
|
export default function (props: Props) {
|
||||||
const maxTimeProgress = 1000;
|
const radius = 31.6;
|
||||||
const [timeProgress, setTimeProgress] = useState(0);
|
const [timeProgress, setTimeProgress] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get the current number of seconds to initialize timer.
|
// Get the current number of seconds to initialize timer.
|
||||||
const initialValue = Math.floor((new Date().getSeconds() % 30) / 30 * maxTimeProgress);
|
const initialValue = (new Date().getTime() / 1000) % props.period / props.period * radius;
|
||||||
setTimeProgress(initialValue);
|
setTimeProgress(initialValue);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const ms = new Date().getSeconds() * 1000.0 + new Date().getMilliseconds();
|
const value = (new Date().getTime() / 1000) % props.period / props.period * radius;
|
||||||
const value = (ms % 30000) / 30000 * maxTimeProgress;
|
|
||||||
setTimeProgress(value);
|
setTimeProgress(value);
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [props]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PieChartIcon width={props.width} height={props.height}
|
<PieChartIcon width={props.width} height={props.height}
|
||||||
maxProgress={maxTimeProgress}
|
progress={timeProgress} maxProgress={radius}
|
||||||
progress={timeProgress}
|
|
||||||
backgroundColor={props.backgroundColor} color={props.color} />
|
backgroundColor={props.backgroundColor} color={props.color} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,4 +7,5 @@ export interface Configuration {
|
||||||
export interface ExtendedConfiguration {
|
export interface ExtendedConfiguration {
|
||||||
available_methods: Set<SecondFactorMethod>;
|
available_methods: Set<SecondFactorMethod>;
|
||||||
second_factor_enabled: boolean;
|
second_factor_enabled: boolean;
|
||||||
|
totp_period: number;
|
||||||
}
|
}
|
|
@ -10,6 +10,7 @@ export async function getConfiguration(): Promise<Configuration> {
|
||||||
interface ExtendedConfigurationPayload {
|
interface ExtendedConfigurationPayload {
|
||||||
available_methods: Method2FA[];
|
available_methods: Method2FA[];
|
||||||
second_factor_enabled: boolean;
|
second_factor_enabled: boolean;
|
||||||
|
totp_period: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getExtendedConfiguration(): Promise<ExtendedConfiguration> {
|
export async function getExtendedConfiguration(): Promise<ExtendedConfiguration> {
|
||||||
|
|
|
@ -10,13 +10,13 @@ import SuccessIcon from "../../../components/SuccessIcon";
|
||||||
export interface Props {
|
export interface Props {
|
||||||
passcode: string;
|
passcode: string;
|
||||||
state: State;
|
state: State;
|
||||||
|
period: number
|
||||||
|
|
||||||
onChange: (passcode: string) => void;
|
onChange: (passcode: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function (props: Props) {
|
export default function (props: Props) {
|
||||||
const style = useStyles();
|
const style = useStyles();
|
||||||
|
|
||||||
const dial = (
|
const dial = (
|
||||||
<span className={style.otpInput} id="otp-input">
|
<span className={style.otpInput} id="otp-input">
|
||||||
<OtpInput
|
<OtpInput
|
||||||
|
@ -31,7 +31,7 @@ export default function (props: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconWithContext
|
<IconWithContext
|
||||||
icon={<Icon state={props.state} />}
|
icon={<Icon state={props.state} period={props.period} />}
|
||||||
context={dial} />
|
context={dial} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -61,12 +61,13 @@ const useStyles = makeStyles(theme => ({
|
||||||
|
|
||||||
interface IconProps {
|
interface IconProps {
|
||||||
state: State;
|
state: State;
|
||||||
|
period: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Icon(props: IconProps) {
|
function Icon(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{props.state !== State.Success ? <TimerIcon backgroundColor="#000" color="#FFFFFF" width={64} height={64} /> : null}
|
{props.state !== State.Success ? <TimerIcon backgroundColor="#000" color="#FFFFFF" width={64} height={64} period={props.period} /> : null}
|
||||||
{props.state === State.Success ? <SuccessIcon /> : null}
|
{props.state === State.Success ? <SuccessIcon /> : null}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,6 +16,7 @@ export interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
authenticationLevel: AuthenticationLevel;
|
authenticationLevel: AuthenticationLevel;
|
||||||
registered: boolean;
|
registered: boolean;
|
||||||
|
totp_period: number
|
||||||
|
|
||||||
onRegisterClick: () => void;
|
onRegisterClick: () => void;
|
||||||
onSignInError: (err: Error) => void;
|
onSignInError: (err: Error) => void;
|
||||||
|
@ -83,7 +84,8 @@ export default function (props: Props) {
|
||||||
<OTPDial
|
<OTPDial
|
||||||
passcode={passcode}
|
passcode={passcode}
|
||||||
onChange={setPasscode}
|
onChange={setPasscode}
|
||||||
state={state} />
|
state={state}
|
||||||
|
period={props.totp_period} />
|
||||||
</MethodContainer>
|
</MethodContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -111,6 +111,7 @@ export default function (props: Props) {
|
||||||
authenticationLevel={props.authenticationLevel}
|
authenticationLevel={props.authenticationLevel}
|
||||||
// Whether the user has a TOTP secret registered already
|
// Whether the user has a TOTP secret registered already
|
||||||
registered={props.userInfo.has_totp}
|
registered={props.userInfo.has_totp}
|
||||||
|
totp_period={props.configuration.totp_period}
|
||||||
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
|
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
|
||||||
onSignInError={err => createErrorNotification(err.message)}
|
onSignInError={err => createErrorNotification(err.message)}
|
||||||
onSignInSuccess={props.onAuthenticationSuccess} />
|
onSignInSuccess={props.onAuthenticationSuccess} />
|
||||||
|
|
Loading…
Reference in New Issue