[FEATURE] Automatic Profile Refresh - LDAP (#912)

* [FIX] LDAP Not Checking for Updated Groups

* refactor handlers verifyFromSessionCookie
* refactor authorizer selectMatchingObjectRules
* refactor authorizer isDomainMatching
* add authorizer URLHasGroupSubjects method
* add user provider ProviderType method
* update tests
* check for new LDAP groups and update session when:
  * user provider type is LDAP
  * authorization is forbidden
  * URL has rule with group subjects

* Implement Refresh Interval

* add default values for LDAP user provider
* add default for refresh interval
* add schema validator for refresh interval
* add various tests
* rename hasUserBeenInactiveLongEnough to hasUserBeenInactiveTooLong
* use Authelia ctx clock
* add check to determine if user is deleted, if so destroy the
* make ldap user not found error a const
* implement GetRefreshSettings in mock

* Use user not found const with FileProvider
* comment exports

* use ctx.Clock instead of time pkg

* add debug logging

* use ptr to reference userSession so we don't have to retrieve it again

* add documenation
* add check for 0 refresh interval to reduce CPU cost
* remove badly copied debug msg

* add group change delta message

* add SliceStringDelta
* refactor ldap refresh to use the new func

* improve delta add/remove log message

* fix incorrect logic in SliceStringDelta
* add tests to SliceStringDelta

* add always config option
* add tests for always config option
* update docs

* apply suggestions from code review

Co-Authored-By: Amir Zarrinkafsh <nightah@me.com>

* complete mocks and fix an old one
* show warning when LDAP details failed to update for an unknown reason

* golint fix

* actually fix existing mocks

* use mocks for LDAP refresh testing

* use mocks for LDAP refresh testing for both added and removed groups

* use test mock to verify disabled refresh behaviour
* add information to threat model
* add time const for default Unix() value

* misc adjustments to mocks

* Suggestions from code review

* requested changes
* update emails
* docs updates
* test updates
* misc

* golint fix

* set debug for dev testing

* misc docs and logging updates

* misc grammar/spelling

* use built function for VerifyGet

* fix reviewdog suggestions

* requested changes

* Apply suggestions from code review

Co-authored-by: Amir Zarrinkafsh <nightah@me.com>
Co-authored-by: Clément Michaud <clement.michaud34@gmail.com>
pull/975/head
James Elliott 2020-05-05 05:39:25 +10:00 committed by GitHub
parent 99bb782708
commit 3f374534ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 835 additions and 162 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/
[HSTS]: https://www.nginx.com/blog/http-strict-transport-security-hsts-and-nginx/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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