[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 graphic
pull/779/head
James Elliott 2020-03-25 12:48:20 +11:00 committed by GitHub
parent c057c917f6
commit 40fb13ba3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 177 additions and 39 deletions

View File

@ -35,12 +35,21 @@ default_redirection_url: https://home.example.com:8080/
#
## google_analytics: UA-00000-01
# TOTP Issuer Name
# TOTP Settings
#
# This will be the issuer name displayed in Google Authenticator
# See: https://github.com/google/google-authenticator/wiki/Key-Uri-Format for more info on issuer names
# Parameters used for TOTP generation
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
# 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
#

View File

@ -7,11 +7,47 @@ nav_order: 6
# One-Time Password
Applications generating one-time passwords usually displays an issuer to
differentiate the various applications registered by the user.
Authelia allows to customize the issuer to differentiate the entry created
by Authelia from others.
Authelia uses time based one-time passwords as the OTP method. You have
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.
totp:
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
View File

@ -13,7 +13,7 @@ require (
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/fasthttp/router v0.7.0
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/golang/mock v1.4.3
github.com/golang/snappy v0.0.1 // indirect

2
go.sum
View File

@ -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/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.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg=
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/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.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View File

@ -3,4 +3,6 @@ package schema
// TOTPConfiguration represents the configuration related to TOTP options.
type TOTPConfiguration struct {
Issuer string `mapstructure:"issuer"`
Period int `mapstructure:"period"`
Skew *int `mapstructure:"skew"`
}

View File

@ -46,8 +46,8 @@ func Validate(configuration *schema.Configuration, validator *schema.StructValid
if configuration.TOTP == nil {
configuration.TOTP = &schema.TOTPConfiguration{}
ValidateTOTP(configuration.TOTP, validator)
}
ValidateTOTP(configuration.TOTP, validator)
if configuration.Notifier == nil {
validator.Push(fmt.Errorf("A notifier configuration must be provided"))

View File

@ -1,14 +1,29 @@
package validator
import (
"fmt"
"github.com/authelia/authelia/internal/configuration/schema"
)
const defaultTOTPIssuer = "Authelia"
const DefaultTOTPPeriod = 30
const DefaultTOTPSkew = 1
// ValidateTOTP validates and update TOTP configuration.
func ValidateTOTP(configuration *schema.TOTPConfiguration, validator *schema.StructValidator) {
if configuration.Issuer == "" {
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"))
}
}

View File

@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestShouldSetDefaultIssuer(t *testing.T) {
func TestShouldSetDefaultTOTPValues(t *testing.T) {
validator := schema.NewStructValidator()
config := schema.TOTPConfiguration{}
@ -16,4 +16,20 @@ func TestShouldSetDefaultIssuer(t *testing.T) {
require.Len(t, validator.Errors(), 0)
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")
}

View File

@ -11,12 +11,16 @@ type ExtendedConfigurationBody struct {
// SecondFactorEnabled whether second factor is enabled
SecondFactorEnabled bool `json:"second_factor_enabled"`
// TOTP Period
TOTPPeriod int `json:"totp_period"`
}
// ExtendedConfigurationGet get the extended configuration accessible to authenticated users.
func ExtendedConfigurationGet(ctx *middlewares.AutheliaCtx) {
body := ExtendedConfigurationBody{}
body.AvailableMethods = MethodList{authentication.TOTP, authentication.U2F}
body.TOTPPeriod = ctx.Configuration.TOTP.Period
if ctx.Configuration.DuoAPI != nil {
body.AvailableMethods = append(body.AvailableMethods, authentication.Push)

View File

@ -4,9 +4,10 @@ import (
"testing"
"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/configuration/schema"
"github.com/stretchr/testify/suite"
)
@ -28,9 +29,15 @@ func (s *SecondFactorAvailableMethodsFixture) TearDownTest() {
}
func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethods() {
s.mock.Ctx.Configuration = schema.Configuration{
TOTP: &schema.TOTPConfiguration{
Period: validator.DefaultTOTPPeriod,
},
}
expectedBody := ExtendedConfigurationBody{
AvailableMethods: []string{"totp", "u2f"},
SecondFactorEnabled: false,
TOTPPeriod: validator.DefaultTOTPPeriod,
}
ExtendedConfigurationGet(s.mock.Ctx)
s.mock.Assert200OK(s.T(), expectedBody)
@ -39,16 +46,25 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethods() {
func (s *SecondFactorAvailableMethodsFixture) TestShouldServeDefaultMethodsAndMobilePush() {
s.mock.Ctx.Configuration = schema.Configuration{
DuoAPI: &schema.DuoAPIConfiguration{},
TOTP: &schema.TOTPConfiguration{
Period: validator.DefaultTOTPPeriod,
},
}
expectedBody := ExtendedConfigurationBody{
AvailableMethods: []string{"totp", "u2f", "mobile_push"},
SecondFactorEnabled: false,
TOTPPeriod: validator.DefaultTOTPPeriod,
}
ExtendedConfigurationGet(s.mock.Ctx)
s.mock.Assert200OK(s.T(), expectedBody)
}
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{
DefaultPolicy: "bypass",
Rules: []schema.ACLRule{
@ -70,10 +86,16 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsDisab
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
AvailableMethods: []string{"totp", "u2f"},
SecondFactorEnabled: false,
TOTPPeriod: validator.DefaultTOTPPeriod,
})
}
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{
DefaultPolicy: "two_factor",
Rules: []schema.ACLRule{
@ -95,10 +117,16 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabl
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
AvailableMethods: []string{"totp", "u2f"},
SecondFactorEnabled: true,
TOTPPeriod: validator.DefaultTOTPPeriod,
})
}
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{
DefaultPolicy: "bypass",
Rules: []schema.ACLRule{
@ -120,6 +148,7 @@ func (s *SecondFactorAvailableMethodsFixture) TestShouldCheckSecondFactorIsEnabl
s.mock.Assert200OK(s.T(), ExtendedConfigurationBody{
AvailableMethods: []string{"totp", "u2f"},
SecondFactorEnabled: true,
TOTPPeriod: validator.DefaultTOTPPeriod,
})
}

View File

@ -41,6 +41,7 @@ func secondFactorTOTPIdentityFinish(ctx *middlewares.AutheliaCtx, username strin
Issuer: ctx.Configuration.TOTP.Issuer,
AccountName: username,
SecretSize: 32,
Period: uint(ctx.Configuration.TOTP.Period),
})
if err != nil {

View File

@ -25,7 +25,11 @@ func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler
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 {
ctx.Error(fmt.Errorf("Wrong passcode during TOTP validation for user %s", userSession.Username), mfaValidationFailedMessage)

View File

@ -40,7 +40,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToDefaultURL() {
verifier.EXPECT().
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
Return(true)
Return(true, nil)
s.mock.Ctx.Configuration.DefaultRedirectionURL = "http://redirection.local"
@ -65,7 +65,7 @@ func (s *HandlerSignTOTPSuite) TestShouldNotReturnRedirectURL() {
verifier.EXPECT().
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
Return(true)
Return(true, nil)
bodyBytes, err := json.Marshal(signTOTPRequestBody{
Token: "abc",
@ -86,7 +86,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToSafeTargetURL() {
verifier.EXPECT().
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
Return(true)
Return(true, nil)
bodyBytes, err := json.Marshal(signTOTPRequestBody{
Token: "abc",
@ -110,7 +110,7 @@ func (s *HandlerSignTOTPSuite) TestShouldNotRedirectToUnsafeURL() {
verifier.EXPECT().
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
Return(true)
Return(true, nil)
bodyBytes, err := json.Marshal(signTOTPRequestBody{
Token: "abc",
@ -132,7 +132,7 @@ func (s *HandlerSignTOTPSuite) TestShouldRegenerateSessionForPreventingSessionFi
verifier.EXPECT().
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
Return(true)
Return(true, nil)
bodyBytes, err := json.Marshal(signTOTPRequestBody{
Token: "abc",

View File

@ -1,15 +1,26 @@
package handlers
import (
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"time"
)
type TOTPVerifier interface {
Verify(token, secret string) bool
Verify(token, secret string) (bool, error)
}
type TOTPVerifierImpl struct{}
func (tv *TOTPVerifierImpl) Verify(token, secret string) bool {
return totp.Validate(token, secret)
type TOTPVerifierImpl struct {
Period uint
Skew uint
}
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)
}

View File

@ -33,11 +33,12 @@ func (m *MockTOTPVerifier) EXPECT() *MockTOTPVerifierMockRecorder {
}
// 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()
ret := m.ctrl.Call(m, "Verify", token, secret)
ret0, _ := ret[0].(bool)
return ret0
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Verify indicates an expected call of Verify

View File

@ -61,7 +61,10 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
router.POST("/api/secondfactor/totp/identity/finish", autheliaMiddleware(
middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityFinish)))
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
router.POST("/api/secondfactor/u2f/identity/start", autheliaMiddleware(

View File

@ -26,7 +26,7 @@ export default function (props: Props) {
<circle r="5" cx="13" cy="13" fill="none"
stroke={color}
strokeWidth="10"
strokeDasharray={`calc(${props.progress} * 31.6 / ${maxProgress}) 31.6`}
strokeDasharray={`${props.progress} ${maxProgress}`}
transform="rotate(-90) translate(-26)" />
</svg>
)

View File

@ -4,32 +4,31 @@ import PieChartIcon from "./PieChartIcon";
export interface Props {
width: number;
height: number;
period: number;
color?: string;
backgroundColor?: string;
}
export default function (props: Props) {
const maxTimeProgress = 1000;
const radius = 31.6;
const [timeProgress, setTimeProgress] = useState(0);
useEffect(() => {
// 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);
const interval = setInterval(() => {
const ms = new Date().getSeconds() * 1000.0 + new Date().getMilliseconds();
const value = (ms % 30000) / 30000 * maxTimeProgress;
const value = (new Date().getTime() / 1000) % props.period / props.period * radius;
setTimeProgress(value);
}, 100);
return () => clearInterval(interval);
}, []);
}, [props]);
return (
<PieChartIcon width={props.width} height={props.height}
maxProgress={maxTimeProgress}
progress={timeProgress}
progress={timeProgress} maxProgress={radius}
backgroundColor={props.backgroundColor} color={props.color} />
)
}

View File

@ -7,4 +7,5 @@ export interface Configuration {
export interface ExtendedConfiguration {
available_methods: Set<SecondFactorMethod>;
second_factor_enabled: boolean;
totp_period: number;
}

View File

@ -10,6 +10,7 @@ export async function getConfiguration(): Promise<Configuration> {
interface ExtendedConfigurationPayload {
available_methods: Method2FA[];
second_factor_enabled: boolean;
totp_period: number;
}
export async function getExtendedConfiguration(): Promise<ExtendedConfiguration> {

View File

@ -10,13 +10,13 @@ import SuccessIcon from "../../../components/SuccessIcon";
export interface Props {
passcode: string;
state: State;
period: number
onChange: (passcode: string) => void;
}
export default function (props: Props) {
const style = useStyles();
const dial = (
<span className={style.otpInput} id="otp-input">
<OtpInput
@ -31,7 +31,7 @@ export default function (props: Props) {
return (
<IconWithContext
icon={<Icon state={props.state} />}
icon={<Icon state={props.state} period={props.period} />}
context={dial} />
)
}
@ -61,12 +61,13 @@ const useStyles = makeStyles(theme => ({
interface IconProps {
state: State;
period: number;
}
function Icon(props: IconProps) {
return (
<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}
</Fragment>
)

View File

@ -16,6 +16,7 @@ export interface Props {
id: string;
authenticationLevel: AuthenticationLevel;
registered: boolean;
totp_period: number
onRegisterClick: () => void;
onSignInError: (err: Error) => void;
@ -83,7 +84,8 @@ export default function (props: Props) {
<OTPDial
passcode={passcode}
onChange={setPasscode}
state={state} />
state={state}
period={props.totp_period} />
</MethodContainer>
)
}

View File

@ -111,6 +111,7 @@ export default function (props: Props) {
authenticationLevel={props.authenticationLevel}
// Whether the user has a TOTP secret registered already
registered={props.userInfo.has_totp}
totp_period={props.configuration.totp_period}
onRegisterClick={initiateRegistration(initiateTOTPRegistrationProcess)}
onSignInError={err => createErrorNotification(err.message)}
onSignInSuccess={props.onAuthenticationSuccess} />