From 626f5d29492cf7ece02fae393c50d924df32f280 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Sat, 4 Apr 2020 10:11:33 +1100 Subject: [PATCH] [FEATURE] Remember Me Configuration (#813) * [FEATURE] Remember Me Configuration * allow users to specify the duration of remember me using remember_me_duration in session config * setting the duration to 0 disables remember me * only render the remember me element if remember me is enabled * prevent malicious users from faking remember me functionality in the backend * add string to duration helper called ParseDurationString to parse a string into a duration * added tests to the helper function * use the SessionProvider to store the time.Duration instead of parsing it over and over again * add sec doc, adjust month/min, consistency * renamed internal/utils/constants.go to internal/utils/const.go to be consistent * added security measure docs * adjusted default remember me duration to be 1 month instead of 1 year * utilize default remember me duration in the autheliaCtx mock * adjust order of keys in session configuration examples * add notes on session security measures secret only being redis * add TODO items for duration notation for both Expiration and Inactivity (will be removed soon) * fix error text for Inactivity in the validator * add session validator tests * deref check bodyJSON.KeepMeLoggedIn and derive the value based on conf and user input and store it (DRY) * remove unnecessary regex for the simplified ParseDurationString utility * ParseDurationString only accepts decimals without leading zeros now * comprehensively test all unit types * remove unnecessary type unions in web * add test to check sanity of time duration consts, this is just so they can't be accidentally changed * simplify deref check and assignment * fix reset password padding/margins * adjust some doc wording * adjust the handler configuration suite test * actually run the handler configuration suite test (whoops) * reduce the number of regex's used by ParseDurationString to 1, thanks to Clement * adjust some error wording --- config.template.yml | 9 ++- docs/configuration/session.md | 40 +++++++++- docs/security/measures.md | 16 +++- internal/configuration/schema/session.go | 21 ++--- internal/configuration/validator/session.go | 19 ++++- .../configuration/validator/session_test.go | 34 ++++++++ internal/handlers/handler_configuration.go | 2 + .../handlers/handler_configuration_test.go | 25 ++++++ .../handler_extended_configuration.go | 10 +-- internal/handlers/handler_firstfactor.go | 11 ++- internal/handlers/handler_verify.go | 2 + internal/handlers/handler_verify_test.go | 3 + internal/mocks/mock_authelia_ctx.go | 1 + internal/session/provider.go | 9 ++- internal/session/provider_config.go | 1 + internal/session/provider_config_test.go | 3 + internal/session/provider_test.go | 3 + internal/suites/BypassAll/configuration.yml | 1 + internal/suites/Docker/configuration.yml | 1 + internal/suites/DuoPush/configuration.yml | 1 + internal/suites/HAProxy/configuration.yml | 1 + .../suites/HighAvailability/configuration.yml | 1 + internal/suites/LDAP/configuration.yml | 1 + internal/suites/Mariadb/configuration.yml | 1 + internal/suites/MySQL/configuration.yml | 1 + internal/suites/NetworkACL/configuration.yml | 1 + .../suites/OneFactorOnly/configuration.yml | 1 + internal/suites/Postgres/configuration.yml | 1 + .../suites/ShortTimeouts/configuration.yml | 1 + internal/suites/Standalone/configuration.yml | 1 + internal/suites/Traefik/configuration.yml | 1 + internal/suites/Traefik2/configuration.yml | 1 + .../kube/authelia/configs/configuration.yml | 1 + internal/utils/const.go | 17 ++++ internal/utils/constants.go | 6 -- internal/utils/time.go | 48 ++++++++++++ internal/utils/time_test.go | 77 +++++++++++++++++++ web/src/App.tsx | 2 +- web/src/models/Configuration.ts | 1 + .../FirstFactor/FirstFactorForm.tsx | 30 ++++---- web/src/views/LoginPortal/LoginPortal.tsx | 7 +- 41 files changed, 366 insertions(+), 47 deletions(-) create mode 100644 internal/utils/const.go delete mode 100644 internal/utils/constants.go create mode 100644 internal/utils/time.go create mode 100644 internal/utils/time_test.go diff --git a/config.template.yml b/config.template.yml index 55d5b2bb7..8d6e1d12f 100644 --- a/config.template.yml +++ b/config.template.yml @@ -256,7 +256,7 @@ session: # The secret to encrypt the session data. This is only used with Redis. # This secret can also be set using the env variables AUTHELIA_SESSION_SECRET - secret: unsecure_session_secret + secret: insecure_session_secret # The time in seconds before the cookie expires and session is reset. expiration: 3600 # 1 hour @@ -264,6 +264,13 @@ session: # The inactivity time in seconds before the session is reset. inactivity: 300 # 5 minutes + # The remember me duration. + # Value of 0 disables remember me. + # Value is in seconds, or duration notation. See: https://docs.authelia.com/configuration/session.html#duration-notation + # Longer periods are considered less secure because a stolen cookie will last longer giving attackers more time to spy + # or attack. Currently the default is 1M or 1 month. + remember_me_duration: 1M + # The domain to protect. # Note: the authenticator must also be in that domain. If empty, the cookie # is restricted to the subdomain of the issuer. diff --git a/docs/configuration/session.md b/docs/configuration/session.md index 42b4e2cdc..1abc7f731 100644 --- a/docs/configuration/session.md +++ b/docs/configuration/session.md @@ -32,6 +32,13 @@ session: # The inactivity time in seconds before the session is reset. inactivity: 300 # 5 minutes + # The remember me duration. + # Value of 0 disables remember me. + # Value is in seconds, or duration notation. See: https://docs.authelia.com/configuration/session.html#duration-notation + # Longer periods are considered less secure because a stolen cookie will last longer giving attackers more time to spy + # or attack. Currently the default is 1M or 1 month. + remember_me_duration: 1M + # The domain to protect. # Note: the login portal must also be a subdomain of that domain. domain: example.com @@ -43,4 +50,35 @@ session: port: 6379 # This secret can also be set using the env variables AUTHELIA_SESSION_REDIS_PASSWORD password: authelia -``` \ No newline at end of file +``` + +### Security + +Configuration of this section has an impact on security. You should read notes in +[security measures](../security/measures.md#session-security) for more information. + +# Duration Notation + +We have implemented a string based notation for configuration options that take a duration. This section describes its +usage. + +**NOTE:** At the time of this writing, only remember_me_duration uses this value type. But we plan to change expiration +and inactivity. + +The notation is comprised of a number which must be positive and not have leading zeros, followed by a letter +denoting the unit of time measurement. The table below describes the units of time and the associated letter. + +|Unit |Associated Letter| +|:-----:|:---------------:| +|Years |y | +|Months |M | +|Weeks |w | +|Days |d | +|Hours |h | +|Minutes|m | +|Seconds|s | + +Examples: +* 1 hour and 30 minutes: 90m +* 1 day: 1d +* 10 hours: 10h \ No newline at end of file diff --git a/docs/security/measures.md b/docs/security/measures.md index b3956119b..48cd6bc16 100644 --- a/docs/security/measures.md +++ b/docs/security/measures.md @@ -95,7 +95,21 @@ There are a few reasons for the security measures implemented: an attacker to intercept a link used to setup 2FA; which reduces security 3. Not validating the identity of the server allows man-in-the-middle attacks -## More protections measures with Nginx +## Additional security + +### Session security + +We have a few options to configure the security of a session. The main and most important +one is the session secret. This is used to encrypt the session data when when stored in the +Redis key value database. This should be as random as possible. + +Additionally you can configure the validity period of sessions. For example in a highly +security conscious domain you would probably want to set the session remember_me_duration +to 0 to disable this feature, and set an expiration of something like 2 hours and inactivity +of 10 minutes. This means the hard limit or the time the session will be destroyed no matter +what is 2 hours, and the soft limit or the time a user can be inactive for is 10 minutes. + +### More protections measures with Nginx You can also apply the following headers to your nginx configuration for improving security. Please read the documentation of those headers before diff --git a/internal/configuration/schema/session.go b/internal/configuration/schema/session.go index 797cce82f..268dfef07 100644 --- a/internal/configuration/schema/session.go +++ b/internal/configuration/schema/session.go @@ -10,18 +10,19 @@ type RedisSessionConfiguration struct { // SessionConfiguration represents the configuration related to user sessions. type SessionConfiguration struct { - Name string `mapstructure:"name"` - Secret string `mapstructure:"secret"` - // Expiration in seconds - Expiration int64 `mapstructure:"expiration"` - // Inactivity in seconds - Inactivity int64 `mapstructure:"inactivity"` - Domain string `mapstructure:"domain"` - Redis *RedisSessionConfiguration `mapstructure:"redis"` + // TODO(james-d-elliott): Convert to duration notation (Both Expiration and Activity need to be strings, and default needs to be changed) + Name string `mapstructure:"name"` + Secret string `mapstructure:"secret"` + Expiration int64 `mapstructure:"expiration"` // Expiration in seconds + Inactivity int64 `mapstructure:"inactivity"` // Inactivity in seconds + RememberMeDuration string `mapstructure:"remember_me_duration"` + Domain string `mapstructure:"domain"` + Redis *RedisSessionConfiguration `mapstructure:"redis"` } // DefaultSessionConfiguration is the default session configuration var DefaultSessionConfiguration = SessionConfiguration{ - Name: "authelia_session", - Expiration: 3600, + Name: "authelia_session", + Expiration: 3600, + RememberMeDuration: "1M", } diff --git a/internal/configuration/validator/session.go b/internal/configuration/validator/session.go index d49ee185a..d78e917f4 100644 --- a/internal/configuration/validator/session.go +++ b/internal/configuration/validator/session.go @@ -2,8 +2,9 @@ package validator import ( "errors" - + "fmt" "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/utils" ) // ValidateSession validates and update session configuration. @@ -16,8 +17,24 @@ func ValidateSession(configuration *schema.SessionConfiguration, validator *sche validator.Push(errors.New("Set secret of the session object")) } + // TODO(james-d-elliott): Convert to duration notation if configuration.Expiration == 0 { configuration.Expiration = schema.DefaultSessionConfiguration.Expiration // 1 hour + } else if configuration.Expiration < 1 { + validator.Push(errors.New("Set expiration of the session above 0")) + } + + // TODO(james-d-elliott): Convert to duration notation + if configuration.Inactivity < 0 { + validator.Push(errors.New("Set inactivity of the session to 0 or above")) + } + + if configuration.RememberMeDuration == "" { + configuration.RememberMeDuration = schema.DefaultSessionConfiguration.RememberMeDuration + } else { + if _, err := utils.ParseDurationString(configuration.RememberMeDuration); err != nil { + validator.Push(errors.New(fmt.Sprintf("Error occurred parsing remember_me_duration string: %s", err))) + } } if configuration.Domain == "" { diff --git a/internal/configuration/validator/session_test.go b/internal/configuration/validator/session_test.go index 29a2b1eba..77f1ddddd 100644 --- a/internal/configuration/validator/session_test.go +++ b/internal/configuration/validator/session_test.go @@ -53,3 +53,37 @@ func TestShouldRaiseErrorWhenDomainNotSet(t *testing.T) { assert.Len(t, validator.Errors(), 1) assert.EqualError(t, validator.Errors()[0], "Set domain of the session object") } + +func TestShouldRaiseErrorWhenBadInactivityAndExpirationSet(t *testing.T) { + validator := schema.NewStructValidator() + config := newDefaultSessionConfig() + config.Inactivity = -1 + config.Expiration = -1 + + ValidateSession(&config, validator) + + assert.Len(t, validator.Errors(), 2) + assert.EqualError(t, validator.Errors()[0], "Set expiration of the session above 0") + assert.EqualError(t, validator.Errors()[1], "Set inactivity of the session to 0 or above") +} + +func TestShouldRaiseErrorWhenBadRememberMeDurationSet(t *testing.T) { + validator := schema.NewStructValidator() + config := newDefaultSessionConfig() + config.RememberMeDuration = "1 year" + + ValidateSession(&config, validator) + + assert.Len(t, validator.Errors(), 1) + assert.EqualError(t, validator.Errors()[0], "Error occurred parsing remember_me_duration string: could not convert the input string of 1 year into a duration") +} + +func TestShouldSetDefaultRememberMeDuration(t *testing.T) { + validator := schema.NewStructValidator() + config := newDefaultSessionConfig() + + ValidateSession(&config, validator) + + assert.Len(t, validator.Errors(), 0) + assert.Equal(t, config.RememberMeDuration, schema.DefaultSessionConfiguration.RememberMeDuration) +} diff --git a/internal/handlers/handler_configuration.go b/internal/handlers/handler_configuration.go index e4ae3d3f5..3a2b02cd3 100644 --- a/internal/handlers/handler_configuration.go +++ b/internal/handlers/handler_configuration.go @@ -4,11 +4,13 @@ import "github.com/authelia/authelia/internal/middlewares" type ConfigurationBody struct { GoogleAnalyticsTrackingID string `json:"ga_tracking_id,omitempty"` + RememberMeEnabled bool `json:"remember_me_enabled"` // whether remember me is enabled or not } func ConfigurationGet(ctx *middlewares.AutheliaCtx) { body := ConfigurationBody{ GoogleAnalyticsTrackingID: ctx.Configuration.GoogleAnalyticsTrackingID, + RememberMeEnabled: ctx.Providers.SessionProvider.RememberMe != 0, } ctx.SetJSONBody(body) } diff --git a/internal/handlers/handler_configuration_test.go b/internal/handlers/handler_configuration_test.go index a01e42f61..a5189241c 100644 --- a/internal/handlers/handler_configuration_test.go +++ b/internal/handlers/handler_configuration_test.go @@ -1,8 +1,11 @@ package handlers import ( + "github.com/authelia/authelia/internal/configuration/schema" "github.com/authelia/authelia/internal/mocks" + "github.com/authelia/authelia/internal/session" "github.com/stretchr/testify/suite" + "testing" ) type ConfigurationSuite struct { @@ -22,11 +25,33 @@ func (s *ConfigurationSuite) TearDownTest() { func (s *ConfigurationSuite) TestShouldReturnConfiguredGATrackingID() { GATrackingID := "ABC" s.mock.Ctx.Configuration.GoogleAnalyticsTrackingID = GATrackingID + s.mock.Ctx.Configuration.Session.RememberMeDuration = schema.DefaultSessionConfiguration.RememberMeDuration expectedBody := ConfigurationBody{ GoogleAnalyticsTrackingID: GATrackingID, + RememberMeEnabled: true, } ConfigurationGet(s.mock.Ctx) s.mock.Assert200OK(s.T(), expectedBody) } + +func (s *ConfigurationSuite) TestShouldDisableRememberMe() { + GATrackingID := "ABC" + s.mock.Ctx.Configuration.GoogleAnalyticsTrackingID = GATrackingID + s.mock.Ctx.Configuration.Session.RememberMeDuration = "0" + s.mock.Ctx.Providers.SessionProvider = session.NewProvider( + s.mock.Ctx.Configuration.Session) + expectedBody := ConfigurationBody{ + GoogleAnalyticsTrackingID: GATrackingID, + RememberMeEnabled: false, + } + + ConfigurationGet(s.mock.Ctx) + s.mock.Assert200OK(s.T(), expectedBody) +} + +func TestRunHandlerConfigurationSuite(t *testing.T) { + s := new(ConfigurationSuite) + suite.Run(t, s) +} diff --git a/internal/handlers/handler_extended_configuration.go b/internal/handlers/handler_extended_configuration.go index 0ac352a70..052798670 100644 --- a/internal/handlers/handler_extended_configuration.go +++ b/internal/handlers/handler_extended_configuration.go @@ -7,13 +7,9 @@ import ( // ExtendedConfigurationBody the content returned by extended configuration endpoint type ExtendedConfigurationBody struct { - AvailableMethods MethodList `json:"available_methods"` - - // SecondFactorEnabled whether second factor is enabled - SecondFactorEnabled bool `json:"second_factor_enabled"` - - // TOTP Period - TOTPPeriod int `json:"totp_period"` + AvailableMethods MethodList `json:"available_methods"` + SecondFactorEnabled bool `json:"second_factor_enabled"` // whether second factor is enabled or not + TOTPPeriod int `json:"totp_period"` } // ExtendedConfigurationGet get the extended configuration accessible to authenticated users. diff --git a/internal/handlers/handler_firstfactor.go b/internal/handlers/handler_firstfactor.go index 00c11cb69..c7a1a57fe 100644 --- a/internal/handlers/handler_firstfactor.go +++ b/internal/handlers/handler_firstfactor.go @@ -74,9 +74,12 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) { return } - // set the cookie to expire in 1 year if "Remember me" was ticked. - if *bodyJSON.KeepMeLoggedIn { - err = ctx.Providers.SessionProvider.UpdateExpiration(ctx.RequestCtx, time.Duration(31556952*time.Second)) + // Check if bodyJSON.KeepMeLoggedIn can be deref'd and derive the value based on the configuration and JSON data + keepMeLoggedIn := ctx.Providers.SessionProvider.RememberMe != 0 && bodyJSON.KeepMeLoggedIn != nil && *bodyJSON.KeepMeLoggedIn + + // Set the cookie to expire if remember me is enabled and the user has asked us to + if keepMeLoggedIn { + err = ctx.Providers.SessionProvider.UpdateExpiration(ctx.RequestCtx, ctx.Providers.SessionProvider.RememberMe) if err != nil { ctx.Error(fmt.Errorf("Unable to update expiration timer for user %s: %s", bodyJSON.Username, err), authenticationFailedMessage) return @@ -100,7 +103,7 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) { userSession.Emails = userDetails.Emails userSession.AuthenticationLevel = authentication.OneFactor userSession.LastActivity = time.Now().Unix() - userSession.KeepMeLoggedIn = *bodyJSON.KeepMeLoggedIn + userSession.KeepMeLoggedIn = keepMeLoggedIn err = ctx.SaveSession(userSession) if err != nil { diff --git a/internal/handlers/handler_verify.go b/internal/handlers/handler_verify.go index 4b152420a..aafa2667d 100644 --- a/internal/handlers/handler_verify.go +++ b/internal/handlers/handler_verify.go @@ -154,6 +154,8 @@ func setForwardedHeaders(headers *fasthttp.ResponseHeader, username string, grou // hasUserBeenInactiveLongEnough check whether the user has been inactive for too long. func hasUserBeenInactiveLongEnough(ctx *middlewares.AutheliaCtx) (bool, error) { + + // TODO(james-d-elliott): Convert to duration notation maxInactivityPeriod := ctx.Configuration.Session.Inactivity if maxInactivityPeriod == 0 { return false, nil diff --git a/internal/handlers/handler_verify_test.go b/internal/handlers/handler_verify_test.go index eaf193920..f65dbbd0c 100644 --- a/internal/handlers/handler_verify_test.go +++ b/internal/handlers/handler_verify_test.go @@ -469,6 +469,7 @@ func TestShouldDestroySessionWhenInactiveForTooLong(t *testing.T) { clock := mocks.TestingClock{} clock.Set(time.Now()) + // TODO(james-d-elliott): Convert to duration notation mock.Ctx.Configuration.Session.Inactivity = 10 userSession := mock.Ctx.GetSession() @@ -494,6 +495,7 @@ func TestShouldKeepSessionWhenUserCheckedRememberMeAndIsInactiveForTooLong(t *te clock := mocks.TestingClock{} clock.Set(time.Now()) + // TODO(james-d-elliott): Convert to duration notation mock.Ctx.Configuration.Session.Inactivity = 10 userSession := mock.Ctx.GetSession() @@ -520,6 +522,7 @@ func TestShouldKeepSessionWhenInactivityTimeoutHasNotBeenExceeded(t *testing.T) clock := mocks.TestingClock{} clock.Set(time.Now()) + // TODO(james-d-elliott): Convert to duration notation mock.Ctx.Configuration.Session.Inactivity = 10 userSession := mock.Ctx.GetSession() diff --git a/internal/mocks/mock_authelia_ctx.go b/internal/mocks/mock_authelia_ctx.go index d91facc59..2037a19b7 100644 --- a/internal/mocks/mock_authelia_ctx.go +++ b/internal/mocks/mock_authelia_ctx.go @@ -67,6 +67,7 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx { mockAuthelia.Clock.Set(datetime) configuration := schema.Configuration{} + configuration.Session.RememberMeDuration = schema.DefaultSessionConfiguration.RememberMeDuration configuration.Session.Name = "authelia_session" configuration.AccessControl.DefaultPolicy = "deny" configuration.AccessControl.Rules = []schema.ACLRule{schema.ACLRule{ diff --git a/internal/session/provider.go b/internal/session/provider.go index f61b12ad4..c321d6298 100644 --- a/internal/session/provider.go +++ b/internal/session/provider.go @@ -2,6 +2,7 @@ package session import ( "encoding/json" + "github.com/authelia/authelia/internal/utils" "time" "github.com/authelia/authelia/internal/configuration/schema" @@ -12,6 +13,7 @@ import ( // Provider a session provider. type Provider struct { sessionHolder *fasthttpsession.Session + RememberMe time.Duration } // NewProvider instantiate a session provider given a configuration. @@ -20,7 +22,12 @@ func NewProvider(configuration schema.SessionConfiguration) *Provider { provider := new(Provider) provider.sessionHolder = fasthttpsession.New(providerConfig.config) - err := provider.sessionHolder.SetProvider(providerConfig.providerName, providerConfig.providerConfig) + duration, err := utils.ParseDurationString(configuration.RememberMeDuration) + if err != nil { + panic(err) + } + provider.RememberMe = duration + err = provider.sessionHolder.SetProvider(providerConfig.providerName, providerConfig.providerConfig) if err != nil { panic(err) } diff --git a/internal/session/provider_config.go b/internal/session/provider_config.go index ea33a8bc3..8d176ad15 100644 --- a/internal/session/provider_config.go +++ b/internal/session/provider_config.go @@ -24,6 +24,7 @@ func NewProviderConfig(configuration schema.SessionConfiguration) ProviderConfig // Only serve the header over HTTPS. config.Secure = true + // TODO(james-d-elliott): Convert to duration notation if configuration.Expiration > 0 { config.Expires = time.Duration(configuration.Expiration) * time.Second } else { diff --git a/internal/session/provider_config_test.go b/internal/session/provider_config_test.go index 0741deb3e..1f1c7d863 100644 --- a/internal/session/provider_config_test.go +++ b/internal/session/provider_config_test.go @@ -19,6 +19,7 @@ func TestShouldCreateInMemorySessionProvider(t *testing.T) { configuration := schema.SessionConfiguration{} configuration.Domain = "example.com" configuration.Name = "my_session" + // TODO(james-d-elliott): Convert to duration notation configuration.Expiration = 40 providerConfig := NewProviderConfig(configuration) @@ -37,6 +38,7 @@ func TestShouldCreateRedisSessionProvider(t *testing.T) { configuration := schema.SessionConfiguration{} configuration.Domain = "example.com" configuration.Name = "my_session" + // TODO(james-d-elliott): Convert to duration notation configuration.Expiration = 40 configuration.Redis = &schema.RedisSessionConfiguration{ Host: "redis.example.com", @@ -66,6 +68,7 @@ func TestShouldSetDbNumber(t *testing.T) { configuration := schema.SessionConfiguration{} configuration.Domain = "example.com" configuration.Name = "my_session" + // TODO(james-d-elliott): Convert to duration notation configuration.Expiration = 40 configuration.Redis = &schema.RedisSessionConfiguration{ Host: "redis.example.com", diff --git a/internal/session/provider_test.go b/internal/session/provider_test.go index f7f1b6835..c136d874d 100644 --- a/internal/session/provider_test.go +++ b/internal/session/provider_test.go @@ -18,6 +18,7 @@ func TestShouldInitializerSession(t *testing.T) { configuration := schema.SessionConfiguration{} configuration.Domain = "example.com" configuration.Name = "my_session" + // TODO(james-d-elliott): Convert to duration notation configuration.Expiration = 40 provider := NewProvider(configuration) @@ -32,6 +33,7 @@ func TestShouldUpdateSession(t *testing.T) { configuration := schema.SessionConfiguration{} configuration.Domain = "example.com" configuration.Name = "my_session" + // TODO(james-d-elliott): Convert to duration notation configuration.Expiration = 40 provider := NewProvider(configuration) @@ -57,6 +59,7 @@ func TestShouldDestroySessionAndWipeSessionData(t *testing.T) { configuration := schema.SessionConfiguration{} configuration.Domain = "example.com" configuration.Name = "my_session" + // TODO(james-d-elliott): Convert to duration notation configuration.Expiration = 40 provider := NewProvider(configuration) diff --git a/internal/suites/BypassAll/configuration.yml b/internal/suites/BypassAll/configuration.yml index de4fe375c..2b956f9c3 100644 --- a/internal/suites/BypassAll/configuration.yml +++ b/internal/suites/BypassAll/configuration.yml @@ -17,6 +17,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y storage: local: diff --git a/internal/suites/Docker/configuration.yml b/internal/suites/Docker/configuration.yml index 0b1abef1d..76d8ec4ee 100644 --- a/internal/suites/Docker/configuration.yml +++ b/internal/suites/Docker/configuration.yml @@ -19,6 +19,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y storage: local: diff --git a/internal/suites/DuoPush/configuration.yml b/internal/suites/DuoPush/configuration.yml index 028d3df7d..5bb130d92 100644 --- a/internal/suites/DuoPush/configuration.yml +++ b/internal/suites/DuoPush/configuration.yml @@ -19,6 +19,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/HAProxy/configuration.yml b/internal/suites/HAProxy/configuration.yml index 54283cd34..cf85a43c5 100644 --- a/internal/suites/HAProxy/configuration.yml +++ b/internal/suites/HAProxy/configuration.yml @@ -17,6 +17,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y storage: local: diff --git a/internal/suites/HighAvailability/configuration.yml b/internal/suites/HighAvailability/configuration.yml index 6b211f421..c82ddab75 100644 --- a/internal/suites/HighAvailability/configuration.yml +++ b/internal/suites/HighAvailability/configuration.yml @@ -85,6 +85,7 @@ session: host: redis port: 6379 password: authelia + remember_me_duration: 1y regulation: max_retries: 3 diff --git a/internal/suites/LDAP/configuration.yml b/internal/suites/LDAP/configuration.yml index 5570ed954..d7a51cfae 100644 --- a/internal/suites/LDAP/configuration.yml +++ b/internal/suites/LDAP/configuration.yml @@ -30,6 +30,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y storage: local: diff --git a/internal/suites/Mariadb/configuration.yml b/internal/suites/Mariadb/configuration.yml index c67052bc1..dadfc6e4d 100644 --- a/internal/suites/Mariadb/configuration.yml +++ b/internal/suites/Mariadb/configuration.yml @@ -19,6 +19,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/MySQL/configuration.yml b/internal/suites/MySQL/configuration.yml index 7d521eea8..447bfd128 100644 --- a/internal/suites/MySQL/configuration.yml +++ b/internal/suites/MySQL/configuration.yml @@ -19,6 +19,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/NetworkACL/configuration.yml b/internal/suites/NetworkACL/configuration.yml index e10b8d297..3da03b5ab 100644 --- a/internal/suites/NetworkACL/configuration.yml +++ b/internal/suites/NetworkACL/configuration.yml @@ -17,6 +17,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/OneFactorOnly/configuration.yml b/internal/suites/OneFactorOnly/configuration.yml index 5ff1e87fe..cb791f2ca 100644 --- a/internal/suites/OneFactorOnly/configuration.yml +++ b/internal/suites/OneFactorOnly/configuration.yml @@ -19,6 +19,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y storage: local: diff --git a/internal/suites/Postgres/configuration.yml b/internal/suites/Postgres/configuration.yml index a33794a2a..aff76b0a1 100644 --- a/internal/suites/Postgres/configuration.yml +++ b/internal/suites/Postgres/configuration.yml @@ -19,6 +19,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y # Configuration of the storage backend used to store data and secrets. i.e. totp data storage: diff --git a/internal/suites/ShortTimeouts/configuration.yml b/internal/suites/ShortTimeouts/configuration.yml index 05cdb71cb..6446ebf9b 100644 --- a/internal/suites/ShortTimeouts/configuration.yml +++ b/internal/suites/ShortTimeouts/configuration.yml @@ -19,6 +19,7 @@ session: domain: example.com inactivity: 5 expiration: 8 + remember_me_duration: 1y storage: local: diff --git a/internal/suites/Standalone/configuration.yml b/internal/suites/Standalone/configuration.yml index 91aa67dec..8e7dc51e7 100644 --- a/internal/suites/Standalone/configuration.yml +++ b/internal/suites/Standalone/configuration.yml @@ -16,6 +16,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y storage: local: diff --git a/internal/suites/Traefik/configuration.yml b/internal/suites/Traefik/configuration.yml index 1c849b8ea..154ba83aa 100644 --- a/internal/suites/Traefik/configuration.yml +++ b/internal/suites/Traefik/configuration.yml @@ -17,6 +17,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y storage: local: diff --git a/internal/suites/Traefik2/configuration.yml b/internal/suites/Traefik2/configuration.yml index 1c849b8ea..154ba83aa 100644 --- a/internal/suites/Traefik2/configuration.yml +++ b/internal/suites/Traefik2/configuration.yml @@ -17,6 +17,7 @@ session: domain: example.com expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y storage: local: diff --git a/internal/suites/example/kube/authelia/configs/configuration.yml b/internal/suites/example/kube/authelia/configs/configuration.yml index 9e1a75280..20eb1f481 100644 --- a/internal/suites/example/kube/authelia/configs/configuration.yml +++ b/internal/suites/example/kube/authelia/configs/configuration.yml @@ -75,6 +75,7 @@ access_control: session: expiration: 3600 # 1 hour inactivity: 300 # 5 minutes + remember_me_duration: 1y domain: example.com redis: host: redis-service diff --git a/internal/utils/const.go b/internal/utils/const.go new file mode 100644 index 000000000..05ffb344c --- /dev/null +++ b/internal/utils/const.go @@ -0,0 +1,17 @@ +package utils + +import ( + "errors" + "regexp" + "time" +) + +// ErrTimeoutReached error thrown when a timeout is reached +var ErrTimeoutReached = errors.New("timeout reached") +var parseDurationRegexp = regexp.MustCompile(`^(?P[1-9]\d*?)(?P[smhdwMy])?$`) + +const Hour = time.Minute * 60 +const Day = Hour * 24 +const Week = Day * 7 +const Year = Day * 365 +const Month = Year / 12 diff --git a/internal/utils/constants.go b/internal/utils/constants.go deleted file mode 100644 index bb19fd55a..000000000 --- a/internal/utils/constants.go +++ /dev/null @@ -1,6 +0,0 @@ -package utils - -import "errors" - -// ErrTimeoutReached error thrown when a timeout is reached -var ErrTimeoutReached = errors.New("timeout reached") diff --git a/internal/utils/time.go b/internal/utils/time.go new file mode 100644 index 000000000..bb513c2e0 --- /dev/null +++ b/internal/utils/time.go @@ -0,0 +1,48 @@ +package utils + +import ( + "errors" + "fmt" + "strconv" + "time" +) + +// Parses a string to a duration +// Duration notations are an integer followed by a unit +// Units are s = second, m = minute, d = day, w = week, M = month, y = year +// Example 1y is the same as 1 year +func ParseDurationString(input string) (duration time.Duration, err error) { + duration = 0 + err = nil + matches := parseDurationRegexp.FindStringSubmatch(input) + if len(matches) == 3 && matches[2] != "" { + d, _ := strconv.Atoi(matches[1]) + switch matches[2] { + case "y": + duration = time.Duration(d) * Year + case "M": + duration = time.Duration(d) * Month + case "w": + duration = time.Duration(d) * Week + case "d": + duration = time.Duration(d) * Day + case "h": + duration = time.Duration(d) * Hour + case "m": + duration = time.Duration(d) * time.Minute + case "s": + duration = time.Duration(d) * time.Second + } + } else if input == "0" || len(matches) == 3 { + seconds, err := strconv.Atoi(input) + if err != nil { + err = errors.New(fmt.Sprintf("could not convert the input string of %s into a duration: %s", input, err)) + } else { + duration = time.Duration(seconds) * time.Second + } + } else if input != "" { + // Throw this error if input is anything other than a blank string, blank string will default to a duration of nothing + err = errors.New(fmt.Sprintf("could not convert the input string of %s into a duration", input)) + } + return +} diff --git a/internal/utils/time_test.go b/internal/utils/time_test.go new file mode 100644 index 000000000..a589d6d88 --- /dev/null +++ b/internal/utils/time_test.go @@ -0,0 +1,77 @@ +package utils + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestShouldParseDurationString(t *testing.T) { + duration, err := ParseDurationString("1h") + assert.NoError(t, err) + assert.Equal(t, 60*time.Minute, duration) +} + +func TestShouldParseDurationStringAllUnits(t *testing.T) { + duration, err := ParseDurationString("1y") + assert.NoError(t, err) + assert.Equal(t, Year, duration) + + duration, err = ParseDurationString("1M") + assert.NoError(t, err) + assert.Equal(t, Month, duration) + + duration, err = ParseDurationString("1w") + assert.NoError(t, err) + assert.Equal(t, Week, duration) + + duration, err = ParseDurationString("1d") + assert.NoError(t, err) + assert.Equal(t, Day, duration) + + duration, err = ParseDurationString("1h") + assert.NoError(t, err) + assert.Equal(t, Hour, duration) + + duration, err = ParseDurationString("1s") + assert.NoError(t, err) + assert.Equal(t, time.Second, duration) +} + +func TestShouldParseSecondsString(t *testing.T) { + duration, err := ParseDurationString("100") + assert.NoError(t, err) + assert.Equal(t, 100*time.Second, duration) +} + +func TestShouldNotParseDurationStringWithOutOfOrderQuantitiesAndUnits(t *testing.T) { + duration, err := ParseDurationString("h1") + assert.EqualError(t, err, "could not convert the input string of h1 into a duration") + assert.Equal(t, time.Duration(0), duration) +} + +func TestShouldNotParseBadDurationString(t *testing.T) { + duration, err := ParseDurationString("10x") + assert.EqualError(t, err, "could not convert the input string of 10x into a duration") + assert.Equal(t, time.Duration(0), duration) +} + +func TestShouldNotParseDurationStringWithMultiValueUnits(t *testing.T) { + duration, err := ParseDurationString("10ms") + assert.EqualError(t, err, "could not convert the input string of 10ms into a duration") + assert.Equal(t, time.Duration(0), duration) +} + +func TestShouldNotParseDurationStringWithLeadingZero(t *testing.T) { + duration, err := ParseDurationString("005h") + assert.EqualError(t, err, "could not convert the input string of 005h into a duration") + assert.Equal(t, time.Duration(0), duration) +} + +func TestShouldTimeIntervalsMakeSense(t *testing.T) { + assert.Equal(t, Hour, time.Minute*60) + assert.Equal(t, Day, Hour*24) + assert.Equal(t, Week, Day*7) + assert.Equal(t, Year, Day*365) + assert.Equal(t, Month, Year/12) +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 83afe3db1..7d9135873 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -56,7 +56,7 @@ const App: React.FC = () => { - + diff --git a/web/src/models/Configuration.ts b/web/src/models/Configuration.ts index 709efa604..c57dd9898 100644 --- a/web/src/models/Configuration.ts +++ b/web/src/models/Configuration.ts @@ -2,6 +2,7 @@ import { SecondFactorMethod } from "./Methods"; export interface Configuration { ga_tracking_id: string; + remember_me_enabled: boolean; } export interface ExtendedConfiguration { diff --git a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx index f3825a933..a9f1636b0 100644 --- a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx +++ b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx @@ -11,6 +11,7 @@ import FixedTextField from "../../../components/FixedTextField"; export interface Props { disabled: boolean; + rememberMe: boolean; onAuthenticationStart: () => void; onAuthenticationFailure: () => void; @@ -121,19 +122,20 @@ export default function (props: Props) { }} /> - - } - className={style.rememberMe} - label="Remember me" - /> + {props.rememberMe ? + + } + className={style.rememberMe} + label="Remember me" + /> : null} ({ }, resetLink: { cursor: "pointer", + paddingTop: 13.5, + paddingBottom: 13.5, }, rememberMe: { flexGrow: 1, diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx index bc95d3521..91ca68847 100644 --- a/web/src/views/LoginPortal/LoginPortal.tsx +++ b/web/src/views/LoginPortal/LoginPortal.tsx @@ -16,7 +16,11 @@ import { SecondFactorMethod } from "../../models/Methods"; import { useExtendedConfiguration } from "../../hooks/Configuration"; import AuthenticatedView from "./AuthenticatedView/AuthenticatedView"; -export default function () { +export interface Props { + rememberMe: boolean; +} + +export default function (props: Props) { const history = useHistory(); const location = useLocation(); const redirectionURL = useRedirectionURL(); @@ -114,6 +118,7 @@ export default function () { setFirstFactorDisabled(true)} onAuthenticationFailure={() => setFirstFactorDisabled(false)} onAuthenticationSuccess={handleAuthSuccess} />