diff --git a/README.md b/README.md index 0318486d..6c1b60b5 100644 --- a/README.md +++ b/README.md @@ -103,15 +103,28 @@ Authelia takes security very seriously. We follow the rule of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we encourage the community to as well. - -If you discover a vulnerability in Authelia, please first contact **clems4ever** on -[Matrix](https://riot.im/app/#/room/#authelia:matrix.org) or by -[email](mailto:clement.michaud34@gmail.com). +If you discover a vulnerability in Authelia, please first contact one of the maintainers privately +either via [Matrix](#matrix) or [email](#email) as described in the [contact options](#contact-options) below. For details about security measures implemented in Authelia, please follow this [link](https://docs.authelia.com/security/measures.html) and for reading about the threat model follow this [link](https://docs.authelia.com/security/threat-model.html). +### Contact Options + +#### Matrix + +Join the [Matrix Room](https://riot.im/app/#/room/#authelia:matrix.org) and locate one of the maintainers. +You can identify them as they are the room administrators. Alternatively you can just ask for one of the +maintainers. Once you've made contact we ask you privately message the maintainer to communicate the vulnerability. + +#### Email + +You can contact any of the maintainers for security vulnerability related issues by emailing +[security@authelia.com](mailto:security@authelia.com). This email is strictly reserved for security and vulnerability +disclosure related matters. If you need to contact us for another reason please use [Matrix](#matrix) or +[team@authelia.com](mailto:team@authelia.com). + ## Breaking changes See [BREAKING](./BREAKING.md). diff --git a/SECURITY.md b/SECURITY.md index 446f56e8..ccd63824 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,10 +4,24 @@ Authelia takes security very seriously. We follow the rule of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we encourage the community to as well. -If you discover a vulnerability in Authelia, please first contact **clems4ever** on -[Matrix](https://riot.im/app/#/room/#authelia:matrix.org) or by -[email](mailto:clement.michaud34@gmail.com). +If you discover a vulnerability in Authelia, please first contact one of the maintainers privately +either via [Matrix](#matrix) or [email](#email) as described in the [contact options](#contact-options) below. For details about security measures implemented in Authelia, please follow this [link](https://docs.authelia.com/security/measures.html) and for reading about the threat model follow this [link](https://docs.authelia.com/security/threat-model.html). + +## Contact Options + +### Matrix + +Join the [Matrix Room](https://riot.im/app/#/room/#authelia:matrix.org) and locate one of the maintainers. +You can identify them as they are the room administrators. Alternatively you can just ask for one of the +maintainers. Once you've made contact we ask you privately message the maintainer to communicate the vulnerability. + +### Email + +You can contact any of the maintainers for security vulnerability related issues by emailing +[security@authelia.com](mailto:security@authelia.com). This email is strictly reserved for security and vulnerability +disclosure related matters. If you need to contact us for another reason please use [Matrix](#matrix) or +[team@authelia.com](mailto:security@authelia.com). \ No newline at end of file diff --git a/config.template.yml b/config.template.yml index 28fc1273..88291e46 100644 --- a/config.template.yml +++ b/config.template.yml @@ -79,6 +79,15 @@ authentication_backend: # Disable both the HTML element and the API for reset password functionality disable_reset_password: false + # The amount of time to wait before we refresh data from the authentication backend. Uses duration notation. + # To disable this feature set it to 'disable', this will slightly reduce security because for Authelia, users + # will always belong to groups they belonged to at the time of login even if they have been removed from them in LDAP. + # To force update on every request you can set this to '0' or 'always', this will increase processor demand. + # See the below documentation for more information. + # Duration Notation docs: https://docs.authelia.com/configuration/index.html#duration-notation-format + # Refresh Interval docs: https://docs.authelia.com/configuration/authentication/ldap.html#refresh-interval + refresh_interval: 5m + # LDAP backend configuration. # # This backend allows Authelia to be scaled to more @@ -137,14 +146,14 @@ authentication_backend: # - DON'T USE - {0} is an alias for {input} supported for backward compatibility but it will be deprecated in later versions, so please don't use it. # - DON'T USE - {1} is an alias for {username} supported for backward compatibility but it will be deprecated in later version, so please don't use it. groups_filter: (&(member={dn})(objectclass=groupOfNames)) - + # The attribute holding the name of the group group_name_attribute: cn - + # The attribute holding the mail address of the user. If multiple email addresses are defined for a user, only the first # one returned by the LDAP server is used. mail_attribute: mail - + # The username and password of the admin user. user: cn=admin,dc=example,dc=com # Password can also be set using a secret: https://docs.authelia.com/configuration/secrets.html diff --git a/docs/configuration/authentication/file.md b/docs/configuration/authentication/file.md index c50cd4c1..1e1a9d9a 100644 --- a/docs/configuration/authentication/file.md +++ b/docs/configuration/authentication/file.md @@ -17,7 +17,21 @@ file in the configuration file. ```yaml authentication_backend: + # Disable both the HTML element and the API for reset password functionality disable_reset_password: false + + # File backend configuration. + # + # With this backend, the users database is stored in a file + # which is updated when users reset their passwords. + # Therefore, this backend is meant to be used in a dev environment + # and not in production since it prevents Authelia to be scaled to + # more than one instance. The options under 'password' have sane + # defaults, and as it has security implications it is highly recommended + # you leave the default values. Before considering changing these settings + # please read the docs page below: + # https://docs.authelia.com/configuration/authentication/file.html#password-hash-algorithm-tuning + file: path: /var/lib/authelia/users.yml password: diff --git a/docs/configuration/authentication/ldap.md b/docs/configuration/authentication/ldap.md index 7a907836..488f9a32 100644 --- a/docs/configuration/authentication/ldap.md +++ b/docs/configuration/authentication/ldap.md @@ -16,7 +16,18 @@ Configuration of the LDAP backend is done as follows ```yaml authentication_backend: + # Disable both the HTML element and the API for reset password functionality disable_reset_password: false + + # The amount of time to wait before we refresh data from the authentication backend. Uses duration notation. + # To disable this feature set it to 'disable', this will slightly reduce security because for Authelia, users + # will always belong to groups they belonged to at the time of login even if they have been removed from them in LDAP. + # To force update on every request you can set this to '0' or 'always', this will increase processor demand. + # See the below documentation for more information. + # Duration Notation docs: https://docs.authelia.com/configuration/index.html#duration-notation-format + # Refresh Interval docs: https://docs.authelia.com/configuration/authentication/ldap.html#refresh-interval + refresh_interval: 5m + ldap: # The url to the ldap server. Scheme can be ldap:// or ldaps:// url: ldap://127.0.0.1 @@ -89,6 +100,25 @@ The user must have an email address in order for Authelia to perform identity verification when a user attempts to reset their password or register a second factor device. + +## Refresh Interval + +This setting takes a [duration notation](../index.md#duration-notation-format) that sets the max frequency +for how often Authelia contacts the backend to verify the user still exists and that the groups stored +in the session are up to date. This allows us to destroy sessions when the user no longer matches the +user_filter, or deny access to resources as they are removed from groups. + +In addition to the duration notation, you may provide the value `always` or `disable`. Setting to `always` +is the same as setting it to 0 which will refresh on every request, `disable` turns the feature off, which is +not recommended. This completely prevents Authelia from refreshing this information, and it would only be +refreshed when the user session gets destroyed by other means like inactivity, session expiration or logging +out and in. + +This value can be any value including 0, setting it to 0 would automatically refresh the session on +every single request. This means Authelia will have to contact the LDAP backend every time an element +on a page loads which could be substantially costly. It's a trade-off between load and security that +you should adapt according to your own security policy. + ## Important notes Users must be uniquely identified by an attribute, this attribute must obviously contain a single value and @@ -102,4 +132,3 @@ unique identifier for your users. ## Loading a password from a secret instead of inside the configuration Password can also be defined using a [secret](../secrets.md). - diff --git a/docs/security/index.md b/docs/security/index.md index d8f00054..e89f1f7f 100644 --- a/docs/security/index.md +++ b/docs/security/index.md @@ -7,11 +7,28 @@ has_children: true # Security -Authelia takes security very seriously. We follow the rule of -[responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we +Authelia takes security very seriously. We follow the rule of +[responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we encourage the community to as well. +If you discover a vulnerability in Authelia, please first contact one of the maintainers privately +either via [Matrix](#matrix) or [email](#email) as described in the [contact options](#contact-options) below. -If you discover a vulnerability in Authelia, please first contact **clems4ever** on -[Matrix](https://riot.im/app/#/room/#authelia:matrix.org) or by -[email](mailto:clement.michaud34@gmail.com). +For details about security measures implemented in Authelia, please follow +this [link](https://docs.authelia.com/security/measures.html) and for reading about +the threat model follow this [link](https://docs.authelia.com/security/threat-model.html). + +## Contact Options + +### Matrix + +Join the [Matrix Room](https://riot.im/app/#/room/#authelia:matrix.org) and locate one of the maintainers. +You can identify them as they are the room administrators. Alternatively you can just ask for one of the +maintainers. Once you've made contact we ask you privately message the maintainer to communicate the vulnerability. + +### Email + +You can contact any of the maintainers for security vulnerability related issues by emailing +[security@authelia.com](mailto:security@authelia.com). This email is strictly reserved for security and vulnerability +disclosure related matters. If you need to contact us for another reason please use [Matrix](#matrix) or +[team@authelia.com](mailto:security@authelia.com). diff --git a/docs/security/measures.md b/docs/security/measures.md index 2740ea3f..e97c4cba 100644 --- a/docs/security/measures.md +++ b/docs/security/measures.md @@ -45,6 +45,16 @@ Lastly Authelia's implementation of Argon2id is highly tunable. You can tune the used, iterations (time), parallelism, and memory usage. To read more about this please read how to [configure](../configuration/authentication/file.md) file authentication. +## User profile and group membership always kept up-to-date (LDAP authentication provider) + +Authelia by default refreshes the user's profile and membership every 5 minutes. Additionally, it +will invalidate any session where the user could not be retrieved from LDAP based on the user filter, for +example if they were deleted or disabled provided the user filter is set correctly. These updates occur when +a user accesses a resource protected by Authelia. + +These protections can be [tuned](../configuration/authentication/ldap.md) according to your security policy +by changing refresh_interval, however we believe that 5 minutes is a fairly safe interval. + ## Notifier security measures (SMTP) By default the SMTP Notifier implementation does not allow connections that are not secure. @@ -137,4 +147,4 @@ add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "1; mode=block"; ``` -[HSTS]: https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/ \ No newline at end of file +[HSTS]: https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/ diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 5c553bd2..9a414f14 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -28,7 +28,8 @@ If properly configured, Authelia guarantees the following for security of your u * Prevention against session fixation by regenerating a new session after each privilege elevation. * Prevention against LDAP injection by following OWASP recommendations regarding valid input characters (https://cheatsheetseries.owasp.org/cheatsheets/LDAP_Injection_Prevention_Cheat_Sheet.html). * Connections between Authelia and thirdparty components like mail server, database, cache and LDAP server can be made over TLS to protect against man-in-the-middle attacks from within the infrastructure. - +* Validation of user session group memberships gets refreshed regularly from the authentication backend (LDAP only). + ## Potential future guarantees * Define and enforce a password policy (to be designed since such a policy can clash with a policy set by the LDAP server). @@ -38,6 +39,8 @@ If properly configured, Authelia guarantees the following for security of your u * Securely transmit authentication data to backends (OAuth2 with bearer tokens). * Protect secrets stored in DB with encryption to prevent secrets leak by DB exfiltration. * Least privilege on LDAP binding operations (currently administrative user is used to bind while it could be anonymous). +* Extend the check of user group memberships to authentication backends other than LDAP (File currently). +* Invalidate user session after profile or membership has changed in order to drop remaining privileges on the fly. ## Trusted environment diff --git a/internal/authentication/const.go b/internal/authentication/const.go index a939ab25..46ac1a72 100644 --- a/internal/authentication/const.go +++ b/internal/authentication/const.go @@ -1,5 +1,9 @@ package authentication +import ( + "errors" +) + // Level is the type representing a level of authentication. type Level int @@ -47,6 +51,9 @@ const ( // HashingPossibleSaltCharacters represents valid hashing runes. var HashingPossibleSaltCharacters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/") +// ErrUserNotFound indicates the user wasn't found in the authentication backend. +var ErrUserNotFound = errors.New("user not found") + const sha512 = "sha512" const testPassword = "my;secure*password" diff --git a/internal/authentication/ldap_user_provider.go b/internal/authentication/ldap_user_provider.go index 9300f2e1..eebbdace 100644 --- a/internal/authentication/ldap_user_provider.go +++ b/internal/authentication/ldap_user_provider.go @@ -48,7 +48,7 @@ func (p *LDAPUserProvider) connect(userDN string, password string) (LDAPConnecti if url.Scheme == "ldaps" { logging.Logger().Trace("LDAP client starts a TLS session") conn, err := p.connectionFactory.DialTLS("tcp", url.Host, &tls.Config{ - InsecureSkipVerify: p.configuration.SkipVerify, //nolint:gosec + InsecureSkipVerify: p.configuration.SkipVerify, //nolint:gosec // This is a configurable option, is desirable in some situations and is off by default }) if err != nil { return nil, err @@ -150,7 +150,7 @@ func (p *LDAPUserProvider) getUserProfile(conn LDAPConnection, inputUsername str } if len(sr.Entries) == 0 { - return nil, fmt.Errorf("No user %s found", inputUsername) + return nil, ErrUserNotFound } if len(sr.Entries) > 1 { diff --git a/internal/authentication/ldap_user_provider_test.go b/internal/authentication/ldap_user_provider_test.go index 3c5032ec..b9b9a121 100644 --- a/internal/authentication/ldap_user_provider_test.go +++ b/internal/authentication/ldap_user_provider_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/go-ldap/ldap/v3" - gomock "github.com/golang/mock/gomock" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/authorization/authorizer.go b/internal/authorization/authorizer.go index 1ec1394e..e138c3c2 100644 --- a/internal/authorization/authorizer.go +++ b/internal/authorization/authorizer.go @@ -67,11 +67,8 @@ func selectMatchingObjectRules(rules []schema.ACLRule, object Object) []schema.A selectedRules := []schema.ACLRule{} for _, rule := range rules { - for _, domain := range rule.Domains { - if isDomainMatching(object.Domain, domain) && - isPathMatching(object.Path, rule.Resources) { - selectedRules = append(selectedRules, rule) - } + if isDomainMatching(object.Domain, rule.Domains) && isPathMatching(object.Path, rule.Resources) { + selectedRules = append(selectedRules, rule) } } return selectedRules @@ -131,3 +128,18 @@ func (p *Authorizer) GetRequiredLevel(subject Subject, requestURL url.URL) Level return PolicyToLevel(p.configuration.DefaultPolicy) } + +// IsURLMatchingRuleWithGroupSubjects returns true if the request has at least one +// matching ACL with a subject of type group attached to it, otherwise false. +func (p *Authorizer) IsURLMatchingRuleWithGroupSubjects(requestURL url.URL) (hasGroupSubjects bool) { + for _, rule := range p.configuration.Rules { + if isDomainMatching(requestURL.Hostname(), rule.Domains) && isPathMatching(requestURL.Path, rule.Resources) { + for _, subjectRule := range rule.Subjects { + if strings.HasPrefix(subjectRule, groupPrefix) { + return true + } + } + } + } + return false +} diff --git a/internal/authorization/domain_matcher.go b/internal/authorization/domain_matcher.go index 80d98d0f..e4a11f39 100644 --- a/internal/authorization/domain_matcher.go +++ b/internal/authorization/domain_matcher.go @@ -2,12 +2,13 @@ package authorization import "strings" -func isDomainMatching(domain string, domainRule string) bool { - if domain == domainRule { // if domain matches exactly - return true - } else if strings.HasPrefix(domainRule, "*.") && strings.HasSuffix(domain, domainRule[1:]) { - // If domain pattern starts with *, it's a multi domain pattern. - return true +func isDomainMatching(domain string, domainRules []string) bool { + for _, domainRule := range domainRules { + if domain == domainRule { + return true + } else if strings.HasPrefix(domainRule, "*.") && strings.HasSuffix(domain, domainRule[1:]) { + return true + } } return false } diff --git a/internal/authorization/domain_matcher_test.go b/internal/authorization/domain_matcher_test.go index 7ba3b1cc..2bf596e0 100644 --- a/internal/authorization/domain_matcher_test.go +++ b/internal/authorization/domain_matcher_test.go @@ -6,20 +6,31 @@ import ( "github.com/stretchr/testify/assert" ) -func TestDomainMatcher(t *testing.T) { - assert.True(t, isDomainMatching("example.com", "example.com")) +func TestShouldMatchACLWithSingleDomain(t *testing.T) { + assert.True(t, isDomainMatching("example.com", []string{"example.com"})) - assert.False(t, isDomainMatching("example.com", "*.example.com")) - assert.True(t, isDomainMatching("abc.example.com", "*.example.com")) - assert.True(t, isDomainMatching("abc.def.example.com", "*.example.com")) - - // Character * must be followed by . to be valid. - assert.False(t, isDomainMatching("example.com", "*example.com")) - - assert.False(t, isDomainMatching("example.com", "*.example.com")) - assert.False(t, isDomainMatching("example.com", "*.exampl.com")) - - assert.False(t, isDomainMatching("example.com", "*.other.net")) - assert.False(t, isDomainMatching("example.com", "*other.net")) - assert.False(t, isDomainMatching("example.com", "other.net")) + assert.True(t, isDomainMatching("abc.example.com", []string{"*.example.com"})) + assert.True(t, isDomainMatching("abc.def.example.com", []string{"*.example.com"})) +} + +func TestShouldNotMatchACLWithSingleDomain(t *testing.T) { + assert.False(t, isDomainMatching("example.com", []string{"*.example.com"})) + // Character * must be followed by . to be valid. + assert.False(t, isDomainMatching("example.com", []string{"*example.com"})) + + assert.False(t, isDomainMatching("example.com", []string{"*.exampl.com"})) + + assert.False(t, isDomainMatching("example.com", []string{"*.other.net"})) + assert.False(t, isDomainMatching("example.com", []string{"*other.net"})) + assert.False(t, isDomainMatching("example.com", []string{"other.net"})) +} + +func TestShouldMatchACLWithMultipleDomains(t *testing.T) { + assert.True(t, isDomainMatching("example.com", []string{"*.example.com", "example.com"})) + assert.True(t, isDomainMatching("apple.example.com", []string{"*.example.com", "example.com"})) +} + +func TestShouldNotMatchACLWithMultipleDomains(t *testing.T) { + assert.False(t, isDomainMatching("example.com", []string{"*.example.com", "*example.com"})) + assert.False(t, isDomainMatching("apple.example.com", []string{"*example.com", "example.com"})) } diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index 91f8a58f..aa963523 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -32,6 +32,14 @@ type PasswordConfiguration struct { Parallelism int `mapstructure:"parallelism"` } +// AuthenticationBackendConfiguration represents the configuration related to the authentication backend. +type AuthenticationBackendConfiguration struct { + DisableResetPassword bool `mapstructure:"disable_reset_password"` + RefreshInterval string `mapstructure:"refresh_interval"` + Ldap *LDAPAuthenticationBackendConfiguration `mapstructure:"ldap"` + File *FileAuthenticationBackendConfiguration `mapstructure:"file"` +} + // DefaultPasswordConfiguration represents the default configuration related to Argon2id hashing. var DefaultPasswordConfiguration = PasswordConfiguration{ Iterations: 1, @@ -59,9 +67,8 @@ var DefaultPasswordSHA512Configuration = PasswordConfiguration{ Algorithm: "sha512", } -// AuthenticationBackendConfiguration represents the configuration related to the authentication backend. -type AuthenticationBackendConfiguration struct { - DisableResetPassword bool `mapstructure:"disable_reset_password"` - Ldap *LDAPAuthenticationBackendConfiguration `mapstructure:"ldap"` - File *FileAuthenticationBackendConfiguration `mapstructure:"file"` +// DefaultLDAPAuthenticationBackendConfiguration represents the default LDAP config. +var DefaultLDAPAuthenticationBackendConfiguration = LDAPAuthenticationBackendConfiguration{ + MailAttribute: "mail", + GroupNameAttribute: "cn", } diff --git a/internal/configuration/schema/const.go b/internal/configuration/schema/const.go index c11f5b5d..e8cef750 100644 --- a/internal/configuration/schema/const.go +++ b/internal/configuration/schema/const.go @@ -1,3 +1,19 @@ package schema +import ( + "time" +) + const denyPolicy = "deny" + +// ProfileRefreshDisabled represents a value for refresh_interval that disables the check entirely. +const ProfileRefreshDisabled = "disable" + +// ProfileRefreshAlways represents a value for refresh_interval that's the same as 0ms. +const ProfileRefreshAlways = "always" + +// RefreshIntervalDefault represents the default value of refresh_interval. +const RefreshIntervalDefault = "5m" + +// RefreshIntervalAlways represents the duration value refresh interval should have if set to always. +const RefreshIntervalAlways = 0 * time.Millisecond diff --git a/internal/configuration/validator/authentication.go b/internal/configuration/validator/authentication.go index 19c56e99..6045e479 100644 --- a/internal/configuration/validator/authentication.go +++ b/internal/configuration/validator/authentication.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/authelia/authelia/internal/configuration/schema" + "github.com/authelia/authelia/internal/utils" ) //nolint:gocyclo // TODO: Consider refactoring/simplifying, time permitting @@ -147,11 +148,11 @@ func validateLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationB } if configuration.GroupNameAttribute == "" { - configuration.GroupNameAttribute = "cn" + configuration.GroupNameAttribute = schema.DefaultLDAPAuthenticationBackendConfiguration.GroupNameAttribute } if configuration.MailAttribute == "" { - configuration.MailAttribute = "mail" + configuration.MailAttribute = schema.DefaultLDAPAuthenticationBackendConfiguration.MailAttribute } } @@ -170,4 +171,13 @@ func ValidateAuthenticationBackend(configuration *schema.AuthenticationBackendCo } else if configuration.Ldap != nil { validateLdapAuthenticationBackend(configuration.Ldap, validator) } + + if configuration.RefreshInterval == "" { + configuration.RefreshInterval = schema.RefreshIntervalDefault + } else { + _, err := utils.ParseDurationString(configuration.RefreshInterval) + if err != nil && configuration.RefreshInterval != schema.ProfileRefreshDisabled && configuration.RefreshInterval != schema.ProfileRefreshAlways { + validator.Push(fmt.Errorf("Auth Backend `refresh_interval` is configured to '%s' but it must be either a duration notation or one of 'disable', or 'always'. Error from parser: %s", configuration.RefreshInterval, err)) + } + } } diff --git a/internal/configuration/validator/authentication_test.go b/internal/configuration/validator/authentication_test.go index 3181bb95..8669dfe4 100644 --- a/internal/configuration/validator/authentication_test.go +++ b/internal/configuration/validator/authentication_test.go @@ -229,6 +229,13 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnEmptyUsernameAttri assert.EqualError(suite.T(), suite.validator.Errors()[0], "Please provide a username attribute with `username_attribute`") } +func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseOnBadRefreshInterval() { + suite.configuration.RefreshInterval = "blah" + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + require.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Auth Backend `refresh_interval` is configured to 'blah' but it must be either a duration notation or one of 'disable', or 'always'. Error from parser: Could not convert the input string of blah into a duration") +} + func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultGroupNameAttribute() { ValidateAuthenticationBackend(&suite.configuration, suite.validator) assert.Len(suite.T(), suite.validator.Errors(), 0) @@ -241,6 +248,12 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultMailAttribute() assert.Equal(suite.T(), "mail", suite.configuration.Ldap.MailAttribute) } +func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultRefreshInterval() { + ValidateAuthenticationBackend(&suite.configuration, suite.validator) + assert.Len(suite.T(), suite.validator.Errors(), 0) + assert.Equal(suite.T(), "5m", suite.configuration.RefreshInterval) +} + func (suite *LdapAuthenticationBackendSuite) TestShouldRaiseWhenUsersFilterDoesNotContainEnclosingParenthesis() { suite.configuration.Ldap.UsersFilter = "uid={input}" ValidateAuthenticationBackend(&suite.configuration, suite.validator) diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index 1225dc0a..fab32e87 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -85,6 +85,7 @@ var validKeys = []string{ // Authentication Backend Keys. "authentication_backend.disable_reset_password", + "authentication_backend.refresh_interval", // LDAP Authentication Backend Keys. "authentication_backend.ldap.url", diff --git a/internal/handlers/handler_firstfactor.go b/internal/handlers/handler_firstfactor.go index 54ade266..54561d4f 100644 --- a/internal/handlers/handler_firstfactor.go +++ b/internal/handlers/handler_firstfactor.go @@ -11,6 +11,7 @@ import ( ) // FirstFactorPost is the handler performing the first factory. +//nolint:gocyclo // TODO: Consider refactoring time permitting. func FirstFactorPost(ctx *middlewares.AutheliaCtx) { bodyJSON := firstFactorRequestBody{} err := ctx.ParseBody(&bodyJSON) @@ -104,6 +105,10 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) { userSession.AuthenticationLevel = authentication.OneFactor userSession.LastActivity = time.Now().Unix() userSession.KeepMeLoggedIn = keepMeLoggedIn + refresh, refreshInterval := getProfileRefreshSettings(ctx.Configuration.AuthenticationBackend) + if refresh { + userSession.RefreshTTL = ctx.Clock.Now().Add(refreshInterval) + } err = ctx.SaveSession(userSession) if err != nil { diff --git a/internal/handlers/handler_firstfactor_test.go b/internal/handlers/handler_firstfactor_test.go index f52f0160..c27c8e5d 100644 --- a/internal/handlers/handler_firstfactor_test.go +++ b/internal/handlers/handler_firstfactor_test.go @@ -342,6 +342,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldRedirectToDefaultURLWhenURLIsUns "keepMeLoggedIn": false, "targetURL": "http://notsafe.local" }`) + FirstFactorPost(s.mock.Ctx) // Respond with 200. @@ -361,6 +362,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenNoTargetURLProvidedA "password": "hello", "keepMeLoggedIn": false }`) + FirstFactorPost(s.mock.Ctx) // Respond with 200. @@ -390,6 +392,7 @@ func (s *FirstFactorRedirectionSuite) TestShouldReply200WhenUnsafeTargetURLProvi "password": "hello", "keepMeLoggedIn": false }`) + FirstFactorPost(s.mock.Ctx) // Respond with 200. diff --git a/internal/handlers/handler_verify.go b/internal/handlers/handler_verify.go index b166c0eb..7fd5c4dc 100644 --- a/internal/handlers/handler_verify.go +++ b/internal/handlers/handler_verify.go @@ -6,12 +6,16 @@ import ( "net" "net/url" "strings" + "time" "github.com/valyala/fasthttp" "github.com/authelia/authelia/internal/authentication" "github.com/authelia/authelia/internal/authorization" + "github.com/authelia/authelia/internal/configuration/schema" "github.com/authelia/authelia/internal/middlewares" + "github.com/authelia/authelia/internal/session" + "github.com/authelia/authelia/internal/utils" ) func isURLUnderProtectedDomain(url *url.URL, domain string) bool { @@ -34,7 +38,7 @@ func getOriginalURL(ctx *middlewares.AutheliaCtx) (*url.URL, error) { if err != nil { return nil, fmt.Errorf("Unable to parse URL extracted from X-Original-URL header: %v", err) } - ctx.Logger.Debug("Using X-Original-URL header content as targeted site URL") + ctx.Logger.Trace("Using X-Original-URL header content as targeted site URL") return url, nil } @@ -59,7 +63,7 @@ func getOriginalURL(ctx *middlewares.AutheliaCtx) (*url.URL, error) { if err != nil { return nil, fmt.Errorf("Unable to parse URL %s: %v", requestURI, err) } - ctx.Logger.Debugf("Using X-Fowarded-Proto, X-Forwarded-Host and X-Forwarded-URI headers " + + ctx.Logger.Tracef("Using X-Fowarded-Proto, X-Forwarded-Host and X-Forwarded-URI headers " + "to construct targeted site URL") return url, nil } @@ -151,8 +155,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) { //nolint:unparam +// hasUserBeenInactiveTooLong checks whether the user has been inactive for too long. +func hasUserBeenInactiveTooLong(ctx *middlewares.AutheliaCtx) (bool, error) { //nolint:unparam maxInactivityPeriod := int64(ctx.Providers.SessionProvider.Inactivity.Seconds()) if maxInactivityPeriod == 0 { return false, nil @@ -171,9 +175,8 @@ func hasUserBeenInactiveLongEnough(ctx *middlewares.AutheliaCtx) (bool, error) { return false, nil } -// verifyFromSessionCookie verify if a user identified by a cookie is allowed to access target URL. -func verifyFromSessionCookie(targetURL url.URL, ctx *middlewares.AutheliaCtx) (username string, groups []string, authLevel authentication.Level, err error) { //nolint:unparam - userSession := ctx.GetSession() +// verifySessionCookie verifies if a user is identified by a cookie. +func verifySessionCookie(ctx *middlewares.AutheliaCtx, targetURL *url.URL, userSession *session.UserSession, refreshProfile bool, refreshProfileInterval time.Duration) (username string, groups []string, authLevel authentication.Level, err error) { //nolint:unparam // No username in the session means the user is anonymous. isUserAnonymous := userSession.Username == "" @@ -182,7 +185,7 @@ func verifyFromSessionCookie(targetURL url.URL, ctx *middlewares.AutheliaCtx) (u } if !userSession.KeepMeLoggedIn && !isUserAnonymous { - inactiveLongEnough, err := hasUserBeenInactiveLongEnough(ctx) + inactiveLongEnough, err := hasUserBeenInactiveTooLong(ctx) if err != nil { return "", nil, authentication.NotAuthenticated, fmt.Errorf("Unable to check if user has been inactive for a long time: %s", err) } @@ -197,6 +200,19 @@ func verifyFromSessionCookie(targetURL url.URL, ctx *middlewares.AutheliaCtx) (u return userSession.Username, userSession.Groups, authentication.NotAuthenticated, fmt.Errorf("User %s has been inactive for too long", userSession.Username) } } + + err = verifySessionHasUpToDateProfile(ctx, targetURL, userSession, refreshProfile, refreshProfileInterval) + if err != nil { + if err == authentication.ErrUserNotFound { + err = ctx.Providers.SessionProvider.DestroySession(ctx.RequestCtx) + if err != nil { + ctx.Logger.Error(fmt.Errorf("Unable to destroy user session after provider refresh didn't find the user: %s", err)) + } + return userSession.Username, userSession.Groups, authentication.NotAuthenticated, err + } + ctx.Logger.Warnf("Error occurred while attempting to update user details from LDAP: %s", err) + } + return userSession.Username, userSession.Groups, userSession.AuthenticationLevel, nil } @@ -235,66 +251,168 @@ func updateActivityTimestamp(ctx *middlewares.AutheliaCtx, isBasicAuth bool, use return ctx.SaveSession(userSession) } -// VerifyGet is the handler verifying if a request is allowed to go through. -func VerifyGet(ctx *middlewares.AutheliaCtx) { - ctx.Logger.Tracef("Headers=%s", ctx.Request.Header.String()) - targetURL, err := getOriginalURL(ctx) +// generateVerifySessionHasUpToDateProfileTraceLogs is used to generate trace logs only when trace logging is enabled. +// The information calculated in this function is completely useless other than trace for now. +func generateVerifySessionHasUpToDateProfileTraceLogs(ctx *middlewares.AutheliaCtx, userSession *session.UserSession, + details *authentication.UserDetails) { + groupsAdded, groupsRemoved := utils.StringSlicesDelta(userSession.Groups, details.Groups) + emailsAdded, emailsRemoved := utils.StringSlicesDelta(userSession.Emails, details.Emails) - if err != nil { - ctx.Error(fmt.Errorf("Unable to parse target URL: %s", err), operationFailedMessage) - return + // Check Groups. + var groupsDelta []string + if len(groupsAdded) != 0 { + groupsDelta = append(groupsDelta, fmt.Sprintf("Added: %s.", strings.Join(groupsAdded, ", "))) } - - if !isSchemeHTTPS(targetURL) && !isSchemeWSS(targetURL) { - ctx.Logger.Error(fmt.Errorf("Scheme of target URL %s must be secure since cookies are "+ - "only transported over a secure connection for security reasons", targetURL.String())) - ctx.ReplyUnauthorized() - return + if len(groupsRemoved) != 0 { + groupsDelta = append(groupsDelta, fmt.Sprintf("Removed: %s.", strings.Join(groupsRemoved, ", "))) } - - if !isURLUnderProtectedDomain(targetURL, ctx.Configuration.Session.Domain) { - ctx.Logger.Error(fmt.Errorf("The target URL %s is not under the protected domain %s", - targetURL.String(), ctx.Configuration.Session.Domain)) - ctx.ReplyUnauthorized() - return - } - - var username string - var groups []string - var authLevel authentication.Level - - proxyAuthorization := ctx.Request.Header.Peek(AuthorizationHeader) - isBasicAuth := proxyAuthorization != nil - - if isBasicAuth { - username, groups, authLevel, err = verifyBasicAuth(proxyAuthorization, *targetURL, ctx) + if len(groupsDelta) != 0 { + ctx.Logger.Tracef("Updated groups detected for %s. %s", userSession.Username, strings.Join(groupsDelta, " ")) } else { - username, groups, authLevel, err = verifyFromSessionCookie(*targetURL, ctx) + ctx.Logger.Tracef("No updated groups detected for %s", userSession.Username) } - if err != nil { - ctx.Logger.Error(fmt.Sprintf("Error caught when verifying user authorization: %s", err)) - if err := updateActivityTimestamp(ctx, isBasicAuth, username); err != nil { - ctx.Error(fmt.Errorf("Unable to update last activity: %s", err), operationFailedMessage) - return - } - handleUnauthorized(ctx, targetURL, username) - return + // Check Emails. + var emailsDelta []string + if len(emailsAdded) != 0 { + emailsDelta = append(emailsDelta, fmt.Sprintf("Added: %s.", strings.Join(emailsAdded, ", "))) } - - authorization := isTargetURLAuthorized(ctx.Providers.Authorizer, *targetURL, username, - groups, ctx.RemoteIP(), authLevel) - - if authorization == Forbidden { - ctx.Logger.Infof("Access to %s is forbidden to user %s", targetURL.String(), username) - ctx.ReplyForbidden() - } else if authorization == NotAuthorized { - handleUnauthorized(ctx, targetURL, username) - } else if authorization == Authorized { - setForwardedHeaders(&ctx.Response.Header, username, groups) + if len(emailsRemoved) != 0 { + emailsDelta = append(emailsDelta, fmt.Sprintf("Removed: %s.", strings.Join(emailsRemoved, ", "))) } - - if err := updateActivityTimestamp(ctx, isBasicAuth, username); err != nil { - ctx.Error(fmt.Errorf("Unable to update last activity: %s", err), operationFailedMessage) + if len(emailsDelta) != 0 { + ctx.Logger.Tracef("Updated emails detected for %s. %s", userSession.Username, strings.Join(emailsDelta, " ")) + } else { + ctx.Logger.Tracef("No updated emails detected for %s", userSession.Username) + } +} + +func verifySessionHasUpToDateProfile(ctx *middlewares.AutheliaCtx, targetURL *url.URL, userSession *session.UserSession, + refreshProfile bool, refreshProfileInterval time.Duration) error { + // TODO: Add a check for LDAP password changes based on a time format attribute. + // See https://docs.authelia.com/security/threat-model.html#potential-future-guarantees + + ctx.Logger.Tracef("Checking if we need check the authentication backend for an updated profile for %s.", userSession.Username) + if refreshProfile && userSession.Username != "" && targetURL != nil && + ctx.Providers.Authorizer.IsURLMatchingRuleWithGroupSubjects(*targetURL) && + (refreshProfileInterval == schema.RefreshIntervalAlways || userSession.RefreshTTL.Before(ctx.Clock.Now())) { + ctx.Logger.Debugf("Checking the authentication backend for an updated profile for user %s", userSession.Username) + details, err := ctx.Providers.UserProvider.GetDetails(userSession.Username) + // Only update the session if we could get the new details. + if err != nil { + return err + } + + groupsDiff := utils.IsStringSlicesDifferent(userSession.Groups, details.Groups) + emailsDiff := utils.IsStringSlicesDifferent(userSession.Emails, details.Emails) + if !groupsDiff && !emailsDiff { + ctx.Logger.Tracef("Updated profile not detected for %s.", userSession.Username) + } else { + ctx.Logger.Debugf("Updated profile detected for %s.", userSession.Username) + if ctx.Logger.Level.String() == "trace" { + generateVerifySessionHasUpToDateProfileTraceLogs(ctx, userSession, details) + } + userSession.Groups = details.Groups + userSession.Emails = details.Emails + + // Only update TTL if the user has a interval set. + if refreshProfileInterval != schema.RefreshIntervalAlways { + userSession.RefreshTTL = ctx.Clock.Now().Add(refreshProfileInterval) + } + return ctx.SaveSession(*userSession) + } + // Only update TTL if the user has a interval set. + // Also make sure to update the session even if no difference was found. + // This is so that we don't check every subsequent request after this one. + if refreshProfileInterval != schema.RefreshIntervalAlways { + userSession.RefreshTTL = ctx.Clock.Now().Add(refreshProfileInterval) + return ctx.SaveSession(*userSession) + } + } + return nil +} + +func getProfileRefreshSettings(cfg schema.AuthenticationBackendConfiguration) (refresh bool, refreshInterval time.Duration) { + if cfg.Ldap != nil { + if cfg.RefreshInterval != schema.ProfileRefreshDisabled { + refresh = true + if cfg.RefreshInterval != schema.ProfileRefreshAlways { + // Skip Error Check since validator checks it + refreshInterval, _ = utils.ParseDurationString(cfg.RefreshInterval) + } else { + refreshInterval = schema.RefreshIntervalAlways + } + } + } + return refresh, refreshInterval +} + +// VerifyGet returns the handler verifying if a request is allowed to go through. +func VerifyGet(cfg schema.AuthenticationBackendConfiguration) middlewares.RequestHandler { + refreshProfile, refreshProfileInterval := getProfileRefreshSettings(cfg) + + return func(ctx *middlewares.AutheliaCtx) { + ctx.Logger.Tracef("Headers=%s", ctx.Request.Header.String()) + targetURL, err := getOriginalURL(ctx) + + if err != nil { + ctx.Error(fmt.Errorf("Unable to parse target URL: %s", err), operationFailedMessage) + return + } + + if !isSchemeHTTPS(targetURL) && !isSchemeWSS(targetURL) { + ctx.Logger.Error(fmt.Errorf("Scheme of target URL %s must be secure since cookies are "+ + "only transported over a secure connection for security reasons", targetURL.String())) + ctx.ReplyUnauthorized() + return + } + + if !isURLUnderProtectedDomain(targetURL, ctx.Configuration.Session.Domain) { + ctx.Logger.Error(fmt.Errorf("The target URL %s is not under the protected domain %s", + targetURL.String(), ctx.Configuration.Session.Domain)) + ctx.ReplyUnauthorized() + return + } + + var username string + var groups []string + var authLevel authentication.Level + + proxyAuthorization := ctx.Request.Header.Peek(AuthorizationHeader) + isBasicAuth := proxyAuthorization != nil + userSession := ctx.GetSession() + + if isBasicAuth { + username, groups, authLevel, err = verifyBasicAuth(proxyAuthorization, *targetURL, ctx) + } else { + username, groups, authLevel, err = verifySessionCookie(ctx, targetURL, &userSession, + refreshProfile, refreshProfileInterval) + } + + if err != nil { + ctx.Logger.Error(fmt.Sprintf("Error caught when verifying user authorization: %s", err)) + if err := updateActivityTimestamp(ctx, isBasicAuth, username); err != nil { + ctx.Error(fmt.Errorf("Unable to update last activity: %s", err), operationFailedMessage) + return + } + handleUnauthorized(ctx, targetURL, username) + return + } + + authorization := isTargetURLAuthorized(ctx.Providers.Authorizer, *targetURL, username, + groups, ctx.RemoteIP(), authLevel) + + if authorization == Forbidden { + ctx.Logger.Infof("Access to %s is forbidden to user %s", targetURL.String(), username) + ctx.ReplyForbidden() + } else if authorization == NotAuthorized { + handleUnauthorized(ctx, targetURL, username) + } else if authorization == Authorized { + setForwardedHeaders(&ctx.Response.Header, username, groups) + } + + if err := updateActivityTimestamp(ctx, isBasicAuth, username); err != nil { + ctx.Error(fmt.Errorf("Unable to update last activity: %s", err), operationFailedMessage) + } } } diff --git a/internal/handlers/handler_verify_test.go b/internal/handlers/handler_verify_test.go index 4f4521ba..512a60d3 100644 --- a/internal/handlers/handler_verify_test.go +++ b/internal/handlers/handler_verify_test.go @@ -17,8 +17,14 @@ import ( "github.com/authelia/authelia/internal/configuration/schema" "github.com/authelia/authelia/internal/mocks" "github.com/authelia/authelia/internal/session" + "github.com/authelia/authelia/internal/utils" ) +var verifyGetCfg = schema.AuthenticationBackendConfiguration{ + RefreshInterval: schema.RefreshIntervalDefault, + Ldap: &schema.LDAPAuthenticationBackendConfiguration{}, +} + // Test getOriginalURL. func TestShouldGetOriginalURLFromOriginalURLHeader(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) @@ -87,24 +93,26 @@ func TestShouldRaiseWhenNoXForwardedHostHeaderProvidedToDetectTargetURL(t *testi assert.Equal(t, "Missing header X-Fowarded-Host", err.Error()) } -func TestShouldRaiseWhenXForwardedProtoIsNotParseable(t *testing.T) { +func TestShouldRaiseWhenXForwardedProtoIsNotParsable(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "!:;;:,") mock.Ctx.Request.Header.Set("X-Forwarded-Host", "myhost.local") + _, err := getOriginalURL(mock.Ctx) assert.Error(t, err) assert.Equal(t, "Unable to parse URL !:;;:,://myhost.local: parse !:;;:,://myhost.local: invalid URI for request", err.Error()) } -func TestShouldRaiseWhenXForwardedURIIsNotParseable(t *testing.T) { +func TestShouldRaiseWhenXForwardedURIIsNotParsable(t *testing.T) { mock := mocks.NewMockAutheliaCtx(t) defer mock.Close() mock.Ctx.Request.Header.Set("X-Forwarded-Proto", "https") mock.Ctx.Request.Header.Set("X-Forwarded-Host", "myhost.local") mock.Ctx.Request.Header.Set("X-Forwarded-URI", "!:;;:,") + _, err := getOriginalURL(mock.Ctx) require.Error(t, err) assert.Equal(t, "Unable to parse URL https://myhost.local!:;;:,: parse https://myhost.local!:;;:,: invalid port \":,\" after host", err.Error()) @@ -124,7 +132,7 @@ func TestShouldRaiseWhenCredentialsAreNotInBase64(t *testing.T) { } func TestShouldRaiseWhenCredentialsAreNotInCorrectForm(t *testing.T) { - // the decoded format should be user:password. + // The decoded format should be user:password. _, _, err := parseBasicAuth("Basic am9obiBwYXNzd29yZA==") assert.Error(t, err) assert.Equal(t, "Format of Proxy-Authorization header must be user:password", err.Error()) @@ -225,7 +233,7 @@ func (s *BasicAuthorizationSuite) TestShouldNotBeAbleToParseBasicAuth() { mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpaaaaaaaaaaaaaaaa") mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) } @@ -248,7 +256,7 @@ func (s *BasicAuthorizationSuite) TestShouldApplyDefaultPolicy() { Groups: []string{"dev", "admins"}, }, nil) - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) assert.Equal(s.T(), 403, mock.Ctx.Response.StatusCode()) } @@ -271,7 +279,7 @@ func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfBypassDomain() { Groups: []string{"dev", "admins"}, }, nil) - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) assert.Equal(s.T(), 200, mock.Ctx.Response.StatusCode()) } @@ -294,7 +302,7 @@ func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfOneFactorDomain() { Groups: []string{"dev", "admins"}, }, nil) - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) assert.Equal(s.T(), 200, mock.Ctx.Response.StatusCode()) } @@ -317,7 +325,7 @@ func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfTwoFactorDomain() { Groups: []string{"dev", "admins"}, }, nil) - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) assert.Equal(s.T(), 401, mock.Ctx.Response.StatusCode()) } @@ -340,7 +348,7 @@ func (s *BasicAuthorizationSuite) TestShouldApplyPolicyOfDenyDomain() { Groups: []string{"dev", "admins"}, }, nil) - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) assert.Equal(s.T(), 403, mock.Ctx.Response.StatusCode()) } @@ -360,7 +368,7 @@ func TestShouldVerifyWrongCredentialsInBasicAuth(t *testing.T) { mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objp3cm9uZ3Bhc3M=") mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) expStatus, actualStatus := 401, mock.Ctx.Response.StatusCode() assert.Equal(t, expStatus, actualStatus, "URL=%s -> StatusCode=%d != ExpectedStatusCode=%d", "https://test.example.com", actualStatus, expStatus) @@ -377,7 +385,7 @@ func TestShouldVerifyFailingPasswordCheckingInBasicAuth(t *testing.T) { mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objp3cm9uZ3Bhc3M=") mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) expStatus, actualStatus := 401, mock.Ctx.Response.StatusCode() assert.Equal(t, expStatus, actualStatus, "URL=%s -> StatusCode=%d != ExpectedStatusCode=%d", "https://test.example.com", actualStatus, expStatus) @@ -398,7 +406,7 @@ func TestShouldVerifyFailingDetailsFetchingInBasicAuth(t *testing.T) { mock.Ctx.Request.Header.Set("Proxy-Authorization", "Basic am9objpwYXNzd29yZA==") mock.Ctx.Request.Header.Set("X-Original-URL", "https://test.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) expStatus, actualStatus := 401, mock.Ctx.Response.StatusCode() assert.Equal(t, expStatus, actualStatus, "URL=%s -> StatusCode=%d != ExpectedStatusCode=%d", "https://test.example.com", actualStatus, expStatus) @@ -450,7 +458,7 @@ func TestShouldVerifyAuthorizationsUsingSessionCookie(t *testing.T) { mock.Ctx.Request.Header.Set("X-Original-URL", testCase.URL) - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) expStatus, actualStatus := testCase.ExpectedStatusCode, mock.Ctx.Response.StatusCode() assert.Equal(t, expStatus, actualStatus, "URL=%s -> AuthLevel=%d, StatusCode=%d != ExpectedStatusCode=%d", testCase.URL, testCase.AuthenticationLevel, actualStatus, expStatus) @@ -485,7 +493,7 @@ func TestShouldDestroySessionWhenInactiveForTooLong(t *testing.T) { mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) // The session has been destroyed. newUserSession := mock.Ctx.GetSession() @@ -516,7 +524,7 @@ func TestShouldDestroySessionWhenInactiveForTooLongUsingDurationNotation(t *test mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) // The session has been destroyed. newUserSession := mock.Ctx.GetSession() @@ -542,9 +550,9 @@ func TestShouldKeepSessionWhenUserCheckedRememberMeAndIsInactiveForTooLong(t *te mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) - // The session has been destroyed. + // Check the session is still active. newUserSession := mock.Ctx.GetSession() assert.Equal(t, "john", newUserSession.Username) assert.Equal(t, authentication.TwoFactor, newUserSession.AuthenticationLevel) @@ -572,7 +580,7 @@ func TestShouldKeepSessionWhenInactivityTimeoutHasNotBeenExceeded(t *testing.T) mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) // The session has been destroyed. newUserSession := mock.Ctx.GetSession() @@ -608,7 +616,7 @@ func TestShouldRedirectWhenSessionInactiveForTooLongAndRDParamProvided(t *testin mock.Ctx.QueryArgs().Add("rd", "https://login.example.com") mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) assert.Equal(t, "Found. Redirecting to https://login.example.com?rd=https%3A%2F%2Ftwo-factor.example.com", string(mock.Ctx.Response.Body())) @@ -638,7 +646,7 @@ func TestShouldUpdateInactivityTimestampEvenWhenHittingForbiddenResources(t *tes mock.Ctx.Request.Header.Set("X-Original-URL", "https://deny.example.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) // The resource if forbidden. assert.Equal(t, 403, mock.Ctx.Response.StatusCode()) @@ -661,7 +669,7 @@ func TestShouldURLEncodeRedirectionURLParameter(t *testing.T) { mock.Ctx.Request.SetHost("mydomain.com") mock.Ctx.Request.SetRequestURI("/?rd=https://auth.mydomain.com") - VerifyGet(mock.Ctx) + VerifyGet(verifyGetCfg)(mock.Ctx) assert.Equal(t, "Found. Redirecting to https://auth.mydomain.com?rd=https%3A%2F%2Ftwo-factor.example.com", string(mock.Ctx.Response.Body())) @@ -722,3 +730,264 @@ func TestSchemeIsWSS(t *testing.T) { assert.True(t, isSchemeWSS( GetURL("wss://mytest.example.com/abc/?query=abc"))) } + +func TestShouldNotRefreshUserGroupsFromBackend(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + // Setup pointer to john so we can adjust it during the test. + user := &authentication.UserDetails{ + Username: "john", + Groups: []string{ + "admin", + "users", + }, + Emails: []string{ + "john@example.com", + }, + } + + cfg := verifyGetCfg + cfg.RefreshInterval = "disable" + verifyGet := VerifyGet(cfg) + + mock.UserProviderMock.EXPECT().GetDetails("john").Times(0) + + clock := mocks.TestingClock{} + clock.Set(time.Now()) + + userSession := mock.Ctx.GetSession() + userSession.Username = user.Username + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = clock.Now().Unix() + userSession.Groups = user.Groups + userSession.Emails = user.Emails + userSession.KeepMeLoggedIn = true + err := mock.Ctx.SaveSession(userSession) + require.NoError(t, err) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") + verifyGet(mock.Ctx) + assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com") + verifyGet(mock.Ctx) + assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) + + // Check Refresh TTL has not been updated. + userSession = mock.Ctx.GetSession() + + // Check user groups are correct. + require.Len(t, userSession.Groups, len(user.Groups)) + assert.Equal(t, utils.RFC3339Zero, userSession.RefreshTTL.Unix()) + assert.Equal(t, "admin", userSession.Groups[0]) + assert.Equal(t, "users", userSession.Groups[1]) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com") + verifyGet(mock.Ctx) + assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) + + // Check admin group is not removed from the session. + userSession = mock.Ctx.GetSession() + assert.Equal(t, utils.RFC3339Zero, userSession.RefreshTTL.Unix()) + require.Len(t, userSession.Groups, 2) + assert.Equal(t, "admin", userSession.Groups[0]) + assert.Equal(t, "users", userSession.Groups[1]) +} + +func TestShouldNotRefreshUserGroupsFromBackendWhenNoGroupSubject(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + // Setup user john. + user := &authentication.UserDetails{ + Username: "john", + Groups: []string{ + "admin", + "users", + }, + Emails: []string{ + "john@example.com", + }, + } + + mock.UserProviderMock.EXPECT().GetDetails("john").Times(0) + + clock := mocks.TestingClock{} + clock.Set(time.Now()) + + userSession := mock.Ctx.GetSession() + userSession.Username = user.Username + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = clock.Now().Unix() + userSession.RefreshTTL = clock.Now().Add(-1 * time.Minute) + userSession.Groups = user.Groups + userSession.Emails = user.Emails + userSession.KeepMeLoggedIn = true + err := mock.Ctx.SaveSession(userSession) + + require.NoError(t, err) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") + VerifyGet(verifyGetCfg)(mock.Ctx) + assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) + + // Session time should NOT have been updated, it should still have a refresh TTL 1 minute in the past. + userSession = mock.Ctx.GetSession() + assert.Equal(t, clock.Now().Add(-1*time.Minute).Unix(), userSession.RefreshTTL.Unix()) +} + +func TestShouldGetRemovedUserGroupsFromBackend(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + defer mock.Close() + + // Setup pointer to john so we can adjust it during the test. + user := &authentication.UserDetails{ + Username: "john", + Groups: []string{ + "admin", + "users", + }, + Emails: []string{ + "john@example.com", + }, + } + + verifyGet := VerifyGet(verifyGetCfg) + mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(2) + + clock := mocks.TestingClock{} + clock.Set(time.Now()) + + userSession := mock.Ctx.GetSession() + userSession.Username = user.Username + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = clock.Now().Unix() + userSession.RefreshTTL = clock.Now().Add(-1 * time.Minute) + userSession.Groups = user.Groups + userSession.Emails = user.Emails + userSession.KeepMeLoggedIn = true + err := mock.Ctx.SaveSession(userSession) + require.NoError(t, err) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") + verifyGet(mock.Ctx) + assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) + + // Request should get refresh settings and new user details. + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com") + verifyGet(mock.Ctx) + assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) + + // Check Refresh TTL has been updated since admin.example.com has a group subject and refresh is enabled. + userSession = mock.Ctx.GetSession() + + // Check user groups are correct. + require.Len(t, userSession.Groups, len(user.Groups)) + assert.Equal(t, clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + assert.Equal(t, "admin", userSession.Groups[0]) + assert.Equal(t, "users", userSession.Groups[1]) + + // Remove the admin group, and force the next request to refresh. + user.Groups = []string{"users"} + userSession.RefreshTTL = clock.Now().Add(-1 * time.Second) + err = mock.Ctx.SaveSession(userSession) + require.NoError(t, err) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://admin.example.com") + verifyGet(mock.Ctx) + assert.Equal(t, 403, mock.Ctx.Response.StatusCode()) + + // Check admin group is removed from the session. + userSession = mock.Ctx.GetSession() + assert.Equal(t, clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + require.Len(t, userSession.Groups, 1) + assert.Equal(t, "users", userSession.Groups[0]) +} + +func TestShouldGetAddedUserGroupsFromBackend(t *testing.T) { + mock := mocks.NewMockAutheliaCtx(t) + //defer mock.Close() + + // Setup pointer to john so we can adjust it during the test. + user := &authentication.UserDetails{ + Username: "john", + Groups: []string{ + "admin", + "users", + }, + Emails: []string{ + "john@example.com", + }, + } + + mock.UserProviderMock.EXPECT().GetDetails("john").Times(0) + + verifyGet := VerifyGet(verifyGetCfg) + + clock := mocks.TestingClock{} + clock.Set(time.Now()) + + userSession := mock.Ctx.GetSession() + userSession.Username = user.Username + userSession.AuthenticationLevel = authentication.TwoFactor + userSession.LastActivity = clock.Now().Unix() + userSession.RefreshTTL = clock.Now().Add(-1 * time.Minute) + userSession.Groups = user.Groups + userSession.Emails = user.Emails + userSession.KeepMeLoggedIn = true + err := mock.Ctx.SaveSession(userSession) + require.NoError(t, err) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://two-factor.example.com") + verifyGet(mock.Ctx) + assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) + + // Request should get refresh user profile. + mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://grafana.example.com") + verifyGet(mock.Ctx) + assert.Equal(t, 403, mock.Ctx.Response.StatusCode()) + + // Check Refresh TTL has been updated since grafana.example.com has a group subject and refresh is enabled. + userSession = mock.Ctx.GetSession() + + // Check user groups are correct. + require.Len(t, userSession.Groups, len(user.Groups)) + assert.Equal(t, clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + assert.Equal(t, "admin", userSession.Groups[0]) + assert.Equal(t, "users", userSession.Groups[1]) + + // Add the grafana group, and force the next request to refresh. + user.Groups = append(user.Groups, "grafana") + userSession.RefreshTTL = clock.Now().Add(-1 * time.Second) + err = mock.Ctx.SaveSession(userSession) + require.NoError(t, err) + + // Reset otherwise we get the last 403 when we check the Response. Is there a better way to do this? + mock.Close() + mock = mocks.NewMockAutheliaCtx(t) + defer mock.Close() + err = mock.Ctx.SaveSession(userSession) + assert.NoError(t, err) + + gomock.InOrder( + mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1), + ) + + mock.Ctx.Request.Header.Set("X-Original-URL", "https://grafana.example.com") + VerifyGet(verifyGetCfg)(mock.Ctx) + assert.Equal(t, 200, mock.Ctx.Response.StatusCode()) + + // Check admin group is removed from the session. + userSession = mock.Ctx.GetSession() + assert.Equal(t, true, userSession.KeepMeLoggedIn) + assert.Equal(t, authentication.TwoFactor, userSession.AuthenticationLevel) + assert.Equal(t, clock.Now().Add(5*time.Minute).Unix(), userSession.RefreshTTL.Unix()) + require.Len(t, userSession.Groups, 3) + assert.Equal(t, "admin", userSession.Groups[0]) + assert.Equal(t, "users", userSession.Groups[1]) + assert.Equal(t, "grafana", userSession.Groups[2]) +} diff --git a/internal/mocks/mock_authelia_ctx.go b/internal/mocks/mock_authelia_ctx.go index 48413028..8115a29b 100644 --- a/internal/mocks/mock_authelia_ctx.go +++ b/internal/mocks/mock_authelia_ctx.go @@ -82,6 +82,14 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx { }, { Domains: []string{"deny.example.com"}, Policy: "deny", + }, { + Domains: []string{"admin.example.com"}, + Policy: "two_factor", + Subjects: []string{"group:admin"}, + }, { + Domains: []string{"grafana.example.com"}, + Policy: "two_factor", + Subjects: []string{"group:grafana"}, }} providers := middlewares.Providers{} diff --git a/internal/mocks/mock_duo_api.go b/internal/mocks/mock_duo_api.go index d0c93040..31987efa 100644 --- a/internal/mocks/mock_duo_api.go +++ b/internal/mocks/mock_duo_api.go @@ -14,30 +14,30 @@ import ( "github.com/authelia/authelia/internal/middlewares" ) -// MockAPI is a mock of API interface +// MockAPI is a mock of API interface. type MockAPI struct { ctrl *gomock.Controller recorder *MockAPIMockRecorder } -// MockAPIMockRecorder is the mock recorder for MockAPI +// MockAPIMockRecorder is the mock recorder for MockAPI. type MockAPIMockRecorder struct { mock *MockAPI } -// NewMockAPI creates a new mock instance +// NewMockAPI creates a new mock instance. func NewMockAPI(ctrl *gomock.Controller) *MockAPI { mock := &MockAPI{ctrl: ctrl} mock.recorder = &MockAPIMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockAPI) EXPECT() *MockAPIMockRecorder { return m.recorder } -// Call mocks base method +// Call mocks base method. func (m *MockAPI) Call(arg0 url.Values, arg1 *middlewares.AutheliaCtx) (*duo.Response, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Call", arg0, arg1) @@ -46,7 +46,7 @@ func (m *MockAPI) Call(arg0 url.Values, arg1 *middlewares.AutheliaCtx) (*duo.Res return ret0, ret1 } -// Call indicates an expected call of Call +// Call indicates an expected call of Call. func (mr *MockAPIMockRecorder) Call(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockAPI)(nil).Call), arg0, arg1) diff --git a/internal/mocks/mock_notifier.go b/internal/mocks/mock_notifier.go index d8569afc..2b3f8373 100644 --- a/internal/mocks/mock_notifier.go +++ b/internal/mocks/mock_notifier.go @@ -33,11 +33,6 @@ func (m *MockNotifier) EXPECT() *MockNotifierMockRecorder { return m.recorder } -// StartupCheck mocks base method. -func (m *MockNotifier) StartupCheck() (bool, error) { - return true, nil -} - // Send mocks base method. func (m *MockNotifier) Send(arg0, arg1, arg2 string) error { m.ctrl.T.Helper() @@ -51,3 +46,18 @@ func (mr *MockNotifierMockRecorder) Send(arg0, arg1, arg2 interface{}) *gomock.C mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockNotifier)(nil).Send), arg0, arg1, arg2) } + +// StartupCheck mocks base method. +func (m *MockNotifier) StartupCheck() (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartupCheck") + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// StartupCheck indicates an expected call of StartupCheck. +func (mr *MockNotifierMockRecorder) StartupCheck() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartupCheck", reflect.TypeOf((*MockNotifier)(nil).StartupCheck)) +} diff --git a/internal/mocks/mock_user_provider.go b/internal/mocks/mock_user_provider.go index 0872de6b..9d96c46b 100644 --- a/internal/mocks/mock_user_provider.go +++ b/internal/mocks/mock_user_provider.go @@ -5,37 +5,37 @@ package mocks import ( - reflect "reflect" + "reflect" - gomock "github.com/golang/mock/gomock" + "github.com/golang/mock/gomock" - authentication "github.com/authelia/authelia/internal/authentication" + "github.com/authelia/authelia/internal/authentication" ) -// MockUserProvider is a mock of UserProvider interface +// MockUserProvider is a mock of UserProvider interface. type MockUserProvider struct { ctrl *gomock.Controller recorder *MockUserProviderMockRecorder } -// MockUserProviderMockRecorder is the mock recorder for MockUserProvider +// MockUserProviderMockRecorder is the mock recorder for MockUserProvider. type MockUserProviderMockRecorder struct { mock *MockUserProvider } -// NewMockUserProvider creates a new mock instance +// NewMockUserProvider creates a new mock instance. func NewMockUserProvider(ctrl *gomock.Controller) *MockUserProvider { mock := &MockUserProvider{ctrl: ctrl} mock.recorder = &MockUserProviderMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockUserProvider) EXPECT() *MockUserProviderMockRecorder { return m.recorder } -// CheckUserPassword mocks base method +// CheckUserPassword mocks base method. func (m *MockUserProvider) CheckUserPassword(arg0, arg1 string) (bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CheckUserPassword", arg0, arg1) @@ -44,13 +44,13 @@ func (m *MockUserProvider) CheckUserPassword(arg0, arg1 string) (bool, error) { return ret0, ret1 } -// CheckUserPassword indicates an expected call of CheckUserPassword +// CheckUserPassword indicates an expected call of CheckUserPassword. func (mr *MockUserProviderMockRecorder) CheckUserPassword(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckUserPassword", reflect.TypeOf((*MockUserProvider)(nil).CheckUserPassword), arg0, arg1) } -// GetDetails mocks base method +// GetDetails mocks base method. func (m *MockUserProvider) GetDetails(arg0 string) (*authentication.UserDetails, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDetails", arg0) @@ -59,7 +59,7 @@ func (m *MockUserProvider) GetDetails(arg0 string) (*authentication.UserDetails, return ret0, ret1 } -// GetDetails indicates an expected call of GetDetails +// GetDetails indicates an expected call of GetDetails. func (mr *MockUserProviderMockRecorder) GetDetails(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDetails", reflect.TypeOf((*MockUserProvider)(nil).GetDetails), arg0) @@ -73,7 +73,7 @@ func (m *MockUserProvider) UpdatePassword(arg0, arg1 string) error { return ret0 } -// UpdatePassword indicates an expected call of UpdatePassword +// UpdatePassword indicates an expected call of UpdatePassword. func (mr *MockUserProviderMockRecorder) UpdatePassword(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePassword", reflect.TypeOf((*MockUserProvider)(nil).UpdatePassword), arg0, arg1) diff --git a/internal/server/server.go b/internal/server/server.go index 1b32e0a9..08ab520f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -38,8 +38,8 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi router.GET("/api/configuration/extended", autheliaMiddleware( middlewares.RequireFirstFactor(handlers.ExtendedConfigurationGet))) - router.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet)) - router.HEAD("/api/verify", autheliaMiddleware(handlers.VerifyGet)) + router.GET("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend))) + router.HEAD("/api/verify", autheliaMiddleware(handlers.VerifyGet(configuration.AuthenticationBackend))) router.POST("/api/firstfactor", autheliaMiddleware(handlers.FirstFactorPost)) router.POST("/api/logout", autheliaMiddleware(handlers.LogoutPost)) diff --git a/internal/session/types.go b/internal/session/types.go index 53dbf722..05a6bc90 100644 --- a/internal/session/types.go +++ b/internal/session/types.go @@ -1,6 +1,8 @@ package session import ( + "time" + "github.com/fasthttp/session" "github.com/tstranex/u2f" @@ -41,6 +43,8 @@ type UserSession struct { // This boolean is set to true after identity verification and checked // while doing the query actually updating the password. PasswordResetUsername *string + + RefreshTTL time.Time } // Identity identity of the user who is being verified. diff --git a/internal/session/user_session.go b/internal/session/user_session.go index bf2b245c..a148285b 100644 --- a/internal/session/user_session.go +++ b/internal/session/user_session.go @@ -1,6 +1,8 @@ package session -import "github.com/authelia/authelia/internal/authentication" +import ( + "github.com/authelia/authelia/internal/authentication" +) // NewDefaultUserSession create a default user session. func NewDefaultUserSession() UserSession { diff --git a/internal/utils/const.go b/internal/utils/const.go index 2097fc3b..ca4096ee 100644 --- a/internal/utils/const.go +++ b/internal/utils/const.go @@ -25,4 +25,7 @@ const Year = Day * 365 // Month is an int based representation of the time unit. const Month = Year / 12 +// RFC3339Zero is the default value for time.Time.Unix(). +const RFC3339Zero = int64(-62135596800) + const testStringInput = "abcdefghijkl" diff --git a/internal/utils/strings.go b/internal/utils/strings.go index e433036d..d1cb06d6 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -30,6 +30,37 @@ func SliceString(s string, d int) (array []string) { return } +// IsStringSlicesDifferent checks two slices of strings and on the first occurrence of a string item not existing in the +// other slice returns true, otherwise returns false. +func IsStringSlicesDifferent(a, b []string) (different bool) { + for _, s := range a { + if !IsStringInSlice(s, b) { + return true + } + } + for _, s := range b { + if !IsStringInSlice(s, a) { + return true + } + } + return false +} + +// StringSlicesDelta takes a before and after []string and compares them returning a added and removed []string. +func StringSlicesDelta(before, after []string) (added, removed []string) { + for _, s := range before { + if !IsStringInSlice(s, after) { + removed = append(removed, s) + } + } + for _, s := range after { + if !IsStringInSlice(s, before) { + added = append(added, s) + } + } + return added, removed +} + // RandomString generate a random string of n characters. func RandomString(n int, characters []rune) (randomString string) { rand.Seed(time.Now().UnixNano()) diff --git a/internal/utils/strings_test.go b/internal/utils/strings_test.go index 8c39941d..fcfc240a 100644 --- a/internal/utils/strings_test.go +++ b/internal/utils/strings_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestShouldSplitIntoEvenStringsOfFour(t *testing.T) { @@ -35,3 +36,35 @@ func TestShouldSplitIntoUnevenStringsOfFour(t *testing.T) { assert.Equal(t, "ijkl", arrayOfStrings[2]) assert.Equal(t, "m", arrayOfStrings[3]) } + +func TestShouldFindSliceDifferencesDelta(t *testing.T) { + before := []string{"abc", "onetwothree"} + after := []string{"abc", "xyz"} + added, removed := StringSlicesDelta(before, after) + require.Len(t, added, 1) + require.Len(t, removed, 1) + assert.Equal(t, "onetwothree", removed[0]) + assert.Equal(t, "xyz", added[0]) +} + +func TestShouldNotFindSliceDifferencesDelta(t *testing.T) { + before := []string{"abc", "onetwothree"} + after := []string{"abc", "onetwothree"} + added, removed := StringSlicesDelta(before, after) + require.Len(t, added, 0) + require.Len(t, removed, 0) +} + +func TestShouldFindSliceDifferences(t *testing.T) { + a := []string{"abc", "onetwothree"} + b := []string{"abc", "xyz"} + diff := IsStringSlicesDifferent(a, b) + assert.True(t, diff) +} + +func TestShouldNotFindSliceDifferences(t *testing.T) { + a := []string{"abc", "onetwothree"} + b := []string{"abc", "onetwothree"} + diff := IsStringSlicesDifferent(a, b) + assert.False(t, diff) +}