Repository: authelia/authelia (go)

Issue:
Add an option to disable "keep me logged in".
From a security point of view, an `allow_keep_me_logged_in` boolean parameter in Authelia configuration file to toggle this feature on or off would be useful.

Pull request:
[FEATURE] Remember Me Configuration
Closes #291 

Things left to do:

- [x] General Testing (due to additional major changes)
- [x] Check what needs unit testing
- [x] GUI Adjustment - with remember me disabled the vertical spacing changes

Patch:
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/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_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/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/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/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} />


Test patch:
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_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_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/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/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)
+}