[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
parent
99bb782708
commit
3f374534ab
21
README.md
21
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).
|
||||
|
|
20
SECURITY.md
20
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).
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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).
|
||||
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"}))
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue