diff options
41 files changed, 366 insertions, 47 deletions
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<Duration>[1-9]\d*?)(?P<Unit>[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 = () => {                              <SignOut />                          </Route>                          <Route path={FirstFactorRoute}> -                            <LoginPortal /> +                            <LoginPortal rememberMe={configuration?.remember_me_enabled === true}/>                          </Route>                          <Route path="/">                              <Redirect to={FirstFactorRoute}></Redirect> 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) {                          }} />                  </Grid>                  <Grid item xs={12} className={classnames(style.leftAlign, style.actionRow)}> -                    <FormControlLabel -                        control={ -                            <Checkbox -                                id="remember-checkbox" -                                disabled={disabled} -                                checked={rememberMe} -                                onChange={handleRememberMeChange} -                                value="rememberMe" -                                color="primary" /> -                        } -                        className={style.rememberMe} -                        label="Remember me" -                    /> +                    {props.rememberMe ? +                        <FormControlLabel +                            control={ +                                <Checkbox +                                    id="remember-checkbox" +                                    disabled={disabled} +                                    checked={rememberMe} +                                    onChange={handleRememberMeChange} +                                    value="rememberMe" +                                    color="primary"/> +                            } +                            className={style.rememberMe} +                            label="Remember me" +                        /> : null}                      <Link                          id="reset-password-button"                          component="button" @@ -171,6 +173,8 @@ const useStyles = makeStyles(theme => ({      },      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 () {                  <ComponentOrLoading ready={firstFactorReady}>                      <FirstFactorForm                          disabled={firstFactorDisabled} +                        rememberMe={props.rememberMe}                          onAuthenticationStart={() => setFirstFactorDisabled(true)}                          onAuthenticationFailure={() => setFirstFactorDisabled(false)}                          onAuthenticationSuccess={handleAuthSuccess} />  | 
