feat(authentication): ldap time replacements (#4483)

This adds and utilizes several time replacements for both specialized LDAP implementations.

Closes #1964, Closes #1284
pull/4498/head^2
James Elliott 2022-12-21 21:31:21 +11:00 committed by GitHub
parent d0d80b4f66
commit d67554ab88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 314 additions and 243 deletions

View File

@ -61,11 +61,14 @@ search.
#### Users filter replacements
| Placeholder | Phase | Replacement |
|:------------------------:|:-------:|:-------------------------------------:|
|:-------------------------:|:-------:|:--------------------------------------------------------------------------------------------------------------:|
| {username_attribute} | startup | The configured username attribute |
| {mail_attribute} | startup | The configured mail attribute |
| {display_name_attribute} | startup | The configured display name attribute |
| {input} | search | The input into the username field |
| {date-time:generalized} | search | The current UTC time formatted as a LDAP generalized time in the format of `20060102150405.0Z` |
| {date-time:unix-epoch} | search | The current time formatted as a Unix epoch |
| {date-time:msft-nt-epoch} | search | The current time formatted as a Microsoft NT epoch which is used by some Microsoft Active Directory attributes |
#### Groups filter replacements
@ -92,16 +95,24 @@ Username column.
#### Filter defaults
The filters are probably the most important part to get correct when setting up LDAP. You want to exclude disabled
accounts. The active directory example has two attribute filters that accomplish this as an example (more examples would
be appreciated). The userAccountControl filter checks that the account is not disabled and the pwdLastSet makes sure that
value is not 0 which means the password requires changing at the next login.
The filters are probably the most important part to get correct when setting up LDAP. You want to exclude accounts under
the following conditions:
- The account is disabled or locked:
- The Active Directory implementation achieves this via the `(!(userAccountControl:1.2.840.113556.1.4.803:=2))` filter.
- The FreeIPA implementation achieves this via the `(!(nsAccountLock=TRUE))` filter.
- Their password is expired:
- The Active Directory implementation achieves this via the `(!(pwdLastSet=0))` filter.
- The FreeIPA implementation achieves this via the `(krbPasswordExpiration>={date-time:generalized})` filter.
- Their account is expired:
- The Active Directory implementation achieves this via the `(|(!(accountExpires=*))(accountExpires=0)(accountExpires>={date-time:msft-nt-epoch}))` filter.
- The FreeIPA implementation achieves this via the `(|(!(krbPrincipalExpiration=*))(krbPrincipalExpiration>={date-time:generalized}))` filter.
| Implementation | Users Filter | Groups Filter |
|:---------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|
|:---------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------:|
| custom | N/A | N/A |
| activedirectory | (&(|({username_attribute}={input})({mail_attribute}={input}))(sAMAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(pwdLastSet=0))) | (&(member={dn})(|(sAMAccountType=268435456)(sAMAccountType=536870912))) |
| freeipa | (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person)(!(nsAccountLock=TRUE))) | (&(member={dn})(objectClass=groupOfNames)) |
| activedirectory | (&(|({username_attribute}={input})({mail_attribute}={input}))(sAMAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(pwdLastSet=0))(|(!(accountExpires=*))(accountExpires=0)(accountExpires>={date-time:msft-nt-epoch}))) | (&(member={dn})(|(sAMAccountType=268435456)(sAMAccountType=536870912))) |
| freeipa | (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person)(!(nsAccountLock=TRUE))(krbPasswordExpiration>={date-time:generalized})(|(!(krbPrincipalExpiration=*))(krbPrincipalExpiration>={date-time:generalized}))) | (&(member={dn})(objectClass=groupOfNames)) |
##### Microsoft Active Directory sAMAccountType

View File

@ -73,6 +73,13 @@ const (
ldapPlaceholderInput = "{input}"
ldapPlaceholderDistinguishedName = "{dn}"
ldapPlaceholderUsername = "{username}"
ldapPlaceholderDateTimeGeneralized = "{date-time:generalized}"
ldapPlaceholderDateTimeMicrosoftNTTimeEpoch = "{date-time:msft-nt-epoch}"
ldapPlaceholderDateTimeUnixEpoch = "{date-time:unix-epoch}"
)
const (
ldapGeneralizedTimeDateTimeFormat = "20060102150405.0Z"
)
const (

View File

@ -5,6 +5,7 @@ import (
"crypto/x509"
"fmt"
"net"
"strconv"
"strings"
"github.com/go-ldap/ldap/v3"
@ -23,6 +24,8 @@ type LDAPUserProvider struct {
log *logrus.Logger
factory LDAPClientFactory
clock utils.Clock
disableResetPassword bool
// Automatically detected LDAP features.
@ -32,6 +35,9 @@ type LDAPUserProvider struct {
usersBaseDN string
usersAttributes []string
usersFilterReplacementInput bool
usersFilterReplacementDateTimeGeneralized bool
usersFilterReplacementDateTimeUnixEpoch bool
usersFilterReplacementDateTimeMicrosoftNTTimeEpoch bool
// Dynamically generated groups values.
groupsBaseDN string
@ -41,14 +47,15 @@ type LDAPUserProvider struct {
groupsFilterReplacementDN bool
}
// NewLDAPUserProvider creates a new instance of LDAPUserProvider.
// NewLDAPUserProvider creates a new instance of LDAPUserProvider with the ProductionLDAPClientFactory.
func NewLDAPUserProvider(config schema.AuthenticationBackend, certPool *x509.CertPool) (provider *LDAPUserProvider) {
provider = newLDAPUserProvider(*config.LDAP, config.PasswordReset.Disable, certPool, nil)
provider = NewLDAPUserProviderWithFactory(*config.LDAP, config.PasswordReset.Disable, certPool, NewProductionLDAPClientFactory())
return provider
}
func newLDAPUserProvider(config schema.LDAPAuthenticationBackend, disableResetPassword bool, certPool *x509.CertPool, factory LDAPClientFactory) (provider *LDAPUserProvider) {
// NewLDAPUserProviderWithFactory creates a new instance of LDAPUserProvider with the specified LDAPClientFactory.
func NewLDAPUserProviderWithFactory(config schema.LDAPAuthenticationBackend, disableResetPassword bool, certPool *x509.CertPool, factory LDAPClientFactory) (provider *LDAPUserProvider) {
if config.TLS == nil {
config.TLS = schema.DefaultLDAPAuthenticationBackendConfigurationImplementationCustom.TLS
}
@ -74,6 +81,7 @@ func newLDAPUserProvider(config schema.LDAPAuthenticationBackend, disableResetPa
log: logging.Logger(),
factory: factory,
disableResetPassword: disableResetPassword,
clock: &utils.RealClock{},
}
provider.parseDynamicUsersConfiguration()
@ -394,12 +402,24 @@ func (p *LDAPUserProvider) getUserProfile(client LDAPClient, username string) (p
return &userProfile, nil
}
func (p *LDAPUserProvider) resolveUsersFilter(username string) (filter string) {
func (p *LDAPUserProvider) resolveUsersFilter(input string) (filter string) {
filter = p.config.UsersFilter
if p.usersFilterReplacementInput {
// The {input} placeholder is replaced by the username input.
filter = strings.ReplaceAll(filter, ldapPlaceholderInput, ldapEscape(username))
filter = strings.ReplaceAll(filter, ldapPlaceholderInput, ldapEscape(input))
}
if p.usersFilterReplacementDateTimeGeneralized {
filter = strings.ReplaceAll(filter, ldapPlaceholderDateTimeGeneralized, p.clock.Now().UTC().Format(ldapGeneralizedTimeDateTimeFormat))
}
if p.usersFilterReplacementDateTimeUnixEpoch {
filter = strings.ReplaceAll(filter, ldapPlaceholderDateTimeUnixEpoch, strconv.Itoa(int(p.clock.Now().Unix())))
}
if p.usersFilterReplacementDateTimeMicrosoftNTTimeEpoch {
filter = strings.ReplaceAll(filter, ldapPlaceholderDateTimeMicrosoftNTTimeEpoch, strconv.Itoa(int(utils.UnixNanoTimeToMicrosoftNTEpoch(p.clock.Now().UnixNano()))))
}
p.log.Tracef("Detected user filter is %s", filter)
@ -407,12 +427,12 @@ func (p *LDAPUserProvider) resolveUsersFilter(username string) (filter string) {
return filter
}
func (p *LDAPUserProvider) resolveGroupsFilter(username string, profile *ldapUserProfile) (filter string) {
func (p *LDAPUserProvider) resolveGroupsFilter(input string, profile *ldapUserProfile) (filter string) {
filter = p.config.GroupsFilter
if p.groupsFilterReplacementInput {
// The {input} placeholder is replaced by the users username input.
filter = strings.ReplaceAll(p.config.GroupsFilter, ldapPlaceholderInput, ldapEscape(username))
filter = strings.ReplaceAll(p.config.GroupsFilter, ldapPlaceholderInput, ldapEscape(input))
}
if profile != nil {

View File

@ -120,6 +120,18 @@ func (p *LDAPUserProvider) parseDynamicUsersConfiguration() {
p.usersFilterReplacementInput = true
}
if strings.Contains(p.config.UsersFilter, ldapPlaceholderDateTimeGeneralized) {
p.usersFilterReplacementDateTimeGeneralized = true
}
if strings.Contains(p.config.UsersFilter, ldapPlaceholderDateTimeUnixEpoch) {
p.usersFilterReplacementDateTimeUnixEpoch = true
}
if strings.Contains(p.config.UsersFilter, ldapPlaceholderDateTimeMicrosoftNTTimeEpoch) {
p.usersFilterReplacementDateTimeMicrosoftNTTimeEpoch = true
}
p.log.Tracef("Detected user filter replacements that need to be resolved per lookup are: %s=%v",
ldapPlaceholderInput, p.usersFilterReplacementInput)
}

File diff suppressed because it is too large Load Diff

View File

@ -187,7 +187,7 @@ var DefaultLDAPAuthenticationBackendConfigurationImplementationCustom = LDAPAuth
// DefaultLDAPAuthenticationBackendConfigurationImplementationActiveDirectory represents the default LDAP config for the LDAPImplementationActiveDirectory Implementation.
var DefaultLDAPAuthenticationBackendConfigurationImplementationActiveDirectory = LDAPAuthenticationBackend{
UsersFilter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(sAMAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(pwdLastSet=0)))",
UsersFilter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(sAMAccountType=805306368)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(pwdLastSet=0))(|(!(accountExpires=*))(accountExpires=0)(accountExpires>={date-time:msft-nt-epoch})))",
UsernameAttribute: "sAMAccountName",
MailAttribute: ldapAttrMail,
DisplayNameAttribute: ldapAttrDisplayName,
@ -201,7 +201,7 @@ var DefaultLDAPAuthenticationBackendConfigurationImplementationActiveDirectory =
// DefaultLDAPAuthenticationBackendConfigurationImplementationFreeIPA represents the default LDAP config for the LDAPImplementationFreeIPA Implementation.
var DefaultLDAPAuthenticationBackendConfigurationImplementationFreeIPA = LDAPAuthenticationBackend{
UsersFilter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person)(!(nsAccountLock=TRUE)))",
UsersFilter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person)(!(nsAccountLock=TRUE))(krbPasswordExpiration>={date-time:generalized})(|(!(krbPrincipalExpiration=*))(krbPrincipalExpiration>={date-time:generalized})))",
UsernameAttribute: ldapAttrUserID,
MailAttribute: ldapAttrMail,
DisplayNameAttribute: ldapAttrDisplayName,

View File

@ -702,7 +702,7 @@ func TestShouldDestroySessionWhenInactiveForTooLong(t *testing.T) {
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
clock := mocks.TestingClock{}
clock := utils.TestingClock{}
clock.Set(time.Now())
past := clock.Now().Add(-1 * time.Hour)
@ -736,7 +736,7 @@ func TestShouldDestroySessionWhenInactiveForTooLongUsingDurationNotation(t *test
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
clock := mocks.TestingClock{}
clock := utils.TestingClock{}
clock.Set(time.Now())
mock.Ctx.Configuration.Session.Inactivity = time.Second * 10
@ -833,7 +833,7 @@ func TestShouldRedirectWhenSessionInactiveForTooLongAndRDParamProvided(t *testin
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
clock := mocks.TestingClock{}
clock := utils.TestingClock{}
clock.Set(time.Now())
mock.Ctx.Configuration.Session.Inactivity = testInactivity
@ -1055,7 +1055,7 @@ func TestShouldNotRefreshUserGroupsFromBackend(t *testing.T) {
mock.UserProviderMock.EXPECT().GetDetails("john").Times(0)
clock := mocks.TestingClock{}
clock := utils.TestingClock{}
clock.Set(time.Now())
userSession := mock.Ctx.GetSession()
@ -1115,7 +1115,7 @@ func TestShouldNotRefreshUserGroupsFromBackendWhenDisabled(t *testing.T) {
mock.UserProviderMock.EXPECT().GetDetails("john").Times(0)
clock := mocks.TestingClock{}
clock := utils.TestingClock{}
clock.Set(time.Now())
userSession := mock.Ctx.GetSession()
@ -1161,7 +1161,7 @@ func TestShouldDestroySessionWhenUserNotExist(t *testing.T) {
mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(1)
clock := mocks.TestingClock{}
clock := utils.TestingClock{}
clock.Set(time.Now())
userSession := mock.Ctx.GetSession()
@ -1222,7 +1222,7 @@ func TestShouldGetRemovedUserGroupsFromBackend(t *testing.T) {
mock.UserProviderMock.EXPECT().GetDetails("john").Return(user, nil).Times(2)
clock := mocks.TestingClock{}
clock := utils.TestingClock{}
clock.Set(time.Now())
userSession := mock.Ctx.GetSession()
@ -1431,7 +1431,7 @@ func TestShouldNotRedirectRequestsForBypassACLWhenInactiveForTooLong(t *testing.
mock := mocks.NewMockAutheliaCtx(t)
defer mock.Close()
clock := mocks.TestingClock{}
clock := utils.TestingClock{}
clock.Set(time.Now())
past := clock.Now().Add(-1 * time.Hour)

View File

@ -19,6 +19,7 @@ import (
"github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/session"
"github.com/authelia/authelia/v4/internal/templates"
"github.com/authelia/authelia/v4/internal/utils"
)
// MockAutheliaCtx a mock of AutheliaCtx.
@ -36,33 +37,13 @@ type MockAutheliaCtx struct {
UserSession *session.UserSession
Clock TestingClock
}
// TestingClock implementation of clock for tests.
type TestingClock struct {
now time.Time
}
// Now return the stored clock.
func (dc *TestingClock) Now() time.Time {
return dc.now
}
// After return a channel receiving the time after duration has elapsed.
func (dc *TestingClock) After(d time.Duration) <-chan time.Time {
return time.After(d)
}
// Set set the time of the clock.
func (dc *TestingClock) Set(now time.Time) {
dc.now = now
Clock utils.TestingClock
}
// NewMockAutheliaCtx create an instance of AutheliaCtx mock.
func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx {
mockAuthelia := new(MockAutheliaCtx)
mockAuthelia.Clock = TestingClock{}
mockAuthelia.Clock = utils.TestingClock{}
datetime, _ := time.Parse("2006-Jan-02", "2013-Feb-03")
mockAuthelia.Clock.Set(datetime)

View File

@ -13,6 +13,7 @@ import (
"github.com/authelia/authelia/v4/internal/mocks"
"github.com/authelia/authelia/v4/internal/model"
"github.com/authelia/authelia/v4/internal/regulation"
"github.com/authelia/authelia/v4/internal/utils"
)
type RegulatorSuite struct {
@ -22,7 +23,7 @@ type RegulatorSuite struct {
ctrl *gomock.Controller
storageMock *mocks.MockStorage
config schema.RegulationConfiguration
clock mocks.TestingClock
clock utils.TestingClock
}
func (s *RegulatorSuite) SetupTest() {

View File

@ -20,3 +20,23 @@ func (RealClock) Now() time.Time {
func (RealClock) After(d time.Duration) <-chan time.Time {
return time.After(d)
}
// TestingClock implementation of clock for tests.
type TestingClock struct {
now time.Time
}
// Now return the stored clock.
func (dc *TestingClock) Now() time.Time {
return dc.now
}
// After return a channel receiving the time after duration has elapsed.
func (dc *TestingClock) After(d time.Duration) <-chan time.Time {
return time.After(d)
}
// Set set the time of the clock.
func (dc *TestingClock) Set(now time.Time) {
dc.now = now
}

View File

@ -8,24 +8,9 @@ import (
)
const (
windows = "windows"
testStringInput = "abcdefghijkl"
// RFC3339Zero is the default value for time.Time.Unix().
RFC3339Zero = int64(-62135596800)
// TLS13 is the textual representation of TLS 1.3.
TLS13 = "1.3"
// TLS12 is the textual representation of TLS 1.2.
TLS12 = "1.2"
// TLS11 is the textual representation of TLS 1.1.
TLS11 = "1.1"
// TLS10 is the textual representation of TLS 1.0.
TLS10 = "1.0"
clean = "clean"
tagged = "tagged"
unknown = "unknown"
@ -84,10 +69,6 @@ const (
Month = Year / 12
)
const (
errFmtLinuxNotFound = "open %s: no such file or directory"
)
var (
standardDurationUnits = []string{"ns", "us", "µs", "μs", "ms", "s", "m", "h"}
reDurationSeconds = regexp.MustCompile(`^\d+$`)
@ -110,6 +91,12 @@ const (
HoursInYear = HoursInDay * 365
)
const (
// timeUnixEpochAsMicrosoftNTEpoch represents the unix epoch as a Microsoft NT Epoch.
// The Microsoft NT Epoch is ticks since Jan 1, 1601 (1 tick is 100ns).
timeUnixEpochAsMicrosoftNTEpoch uint64 = 116444736000000000
)
const (
// CharSetAlphabeticLower are literally just valid alphabetic lowercase printable ASCII chars.
CharSetAlphabeticLower = "abcdefghijklmnopqrstuvwxyz"
@ -155,5 +142,7 @@ var htmlEscaper = strings.NewReplacer(
// ErrTimeoutReached error thrown when a timeout is reached.
var ErrTimeoutReached = errors.New("timeout reached")
// ErrTLSVersionNotSupported returned when an unknown TLS version supplied.
var ErrTLSVersionNotSupported = errors.New("supplied tls version isn't supported")
const (
windows = "windows"
errFmtLinuxNotFound = "open %s: no such file or directory"
)

View File

@ -0,0 +1,5 @@
package utils
const (
testStringInput = "abcdefghijkl"
)

View File

@ -67,3 +67,8 @@ func ParseDurationString(input string) (duration time.Duration, err error) {
return time.ParseDuration(out)
}
// UnixNanoTimeToMicrosoftNTEpoch converts a unix timestamp in nanosecond format to win32 epoch format.
func UnixNanoTimeToMicrosoftNTEpoch(nano int64) (t uint64) {
return uint64(nano/100) + timeUnixEpochAsMicrosoftNTEpoch
}

View File

@ -122,3 +122,11 @@ func TestShouldTimeIntervalsMakeSense(t *testing.T) {
assert.Equal(t, Year, Day*365)
assert.Equal(t, Month, Year/12)
}
func TestShouldConvertKnownUnixNanoTimeToKnownWin32Epoch(t *testing.T) {
exampleNanoTime := int64(1626234411 * 1000000000)
win32Epoch := uint64(132707080110000000)
assert.Equal(t, win32Epoch, UnixNanoTimeToMicrosoftNTEpoch(exampleNanoTime))
assert.Equal(t, timeUnixEpochAsMicrosoftNTEpoch, UnixNanoTimeToMicrosoftNTEpoch(0))
}