[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
This commit is contained in:
James Elliott 2020-04-04 10:11:33 +11:00 committed by GitHub
parent 4fcaff7c4b
commit 626f5d2949
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 366 additions and 47 deletions

View File

@ -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.

View File

@ -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
@ -44,3 +51,34 @@ session:
# This secret can also be set using the env variables AUTHELIA_SESSION_REDIS_PASSWORD
password: authelia
```
### 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

View File

@ -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

View File

@ -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",
}

View File

@ -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 == "" {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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.

View File

@ -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 {

View File

@ -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

View File

@ -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()

View File

@ -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{

View File

@ -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)
}

View File

@ -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 {

View File

@ -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",

View File

@ -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)

View File

@ -17,6 +17,7 @@ session:
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
storage:
local:

View File

@ -19,6 +19,7 @@ session:
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
storage:
local:

View File

@ -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:

View File

@ -17,6 +17,7 @@ session:
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
storage:
local:

View File

@ -85,6 +85,7 @@ session:
host: redis
port: 6379
password: authelia
remember_me_duration: 1y
regulation:
max_retries: 3

View File

@ -30,6 +30,7 @@ session:
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
storage:
local:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -19,6 +19,7 @@ session:
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
storage:
local:

View File

@ -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:

View File

@ -19,6 +19,7 @@ session:
domain: example.com
inactivity: 5
expiration: 8
remember_me_duration: 1y
storage:
local:

View File

@ -16,6 +16,7 @@ session:
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
storage:
local:

View File

@ -17,6 +17,7 @@ session:
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
storage:
local:

View File

@ -17,6 +17,7 @@ session:
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
remember_me_duration: 1y
storage:
local:

View File

@ -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

17
internal/utils/const.go Normal file
View File

@ -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

View File

@ -1,6 +0,0 @@
package utils
import "errors"
// ErrTimeoutReached error thrown when a timeout is reached
var ErrTimeoutReached = errors.New("timeout reached")

48
internal/utils/time.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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>

View File

@ -2,6 +2,7 @@ import { SecondFactorMethod } from "./Methods";
export interface Configuration {
ga_tracking_id: string;
remember_me_enabled: boolean;
}
export interface ExtendedConfiguration {

View File

@ -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,

View File

@ -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} />