From 54ef88a6fa2c742469ce49ffd289a00e90c9e905 Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 8 Jun 2023 15:16:40 +0200 Subject: [PATCH] providers/ldap: rework Schema and DSE (#5838) * rework Root DSE Signed-off-by: Jens Langhammer * always parse filter objectClass Signed-off-by: Jens Langhammer * start adding LDAP Schema Signed-off-by: Jens Langhammer * add more schema Signed-off-by: Jens Langhammer * update schema more Signed-off-by: Jens Langhammer * fix cn for schema Signed-off-by: Jens Langhammer * only include main DN in namingContexts Signed-off-by: Jens Langhammer * use schema from gh Signed-off-by: Jens Langhammer * add description Signed-off-by: Jens Langhammer * add response filtering Signed-off-by: Jens Langhammer * fix response filtering Signed-off-by: Jens Langhammer * don't return rootDSE entry when searching for singleLevel Signed-off-by: Jens Langhammer * remove currentTime Signed-off-by: Jens Langhammer * fix attribute filtering Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * set SINGLE-VALUE Signed-off-by: Jens Langhammer * fix numbers Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- internal/outpost/ldap/bind/binder.go | 2 +- internal/outpost/ldap/constants/constants.go | 11 ++- internal/outpost/ldap/instance.go | 40 -------- internal/outpost/ldap/search.go | 61 ++++++------ internal/outpost/ldap/search/direct/base.go | 35 ++++--- internal/outpost/ldap/search/direct/direct.go | 41 ++++---- internal/outpost/ldap/search/direct/schema.go | 96 +++++++++++++++++++ internal/outpost/ldap/search/memory/memory.go | 50 +++++----- internal/outpost/ldap/search/request.go | 46 +++++++-- internal/outpost/ldap/search/searcher.go | 2 + internal/outpost/ldap/search_route.go | 84 ++++++++++++++++ internal/outpost/ldap/server/base.go | 3 +- tests/e2e/test_provider_ldap.py | 86 ++++++++--------- 13 files changed, 369 insertions(+), 188 deletions(-) create mode 100644 internal/outpost/ldap/search/direct/schema.go create mode 100644 internal/outpost/ldap/search_route.go diff --git a/internal/outpost/ldap/bind/binder.go b/internal/outpost/ldap/bind/binder.go index 4644b66e4..dee7695bb 100644 --- a/internal/outpost/ldap/bind/binder.go +++ b/internal/outpost/ldap/bind/binder.go @@ -7,7 +7,7 @@ import ( ) type Binder interface { - GetUsername(string) (string, error) + GetUsername(dn string) (string, error) Bind(username string, req *Request) (ldap.LDAPResultCode, error) Unbind(username string, req *Request) (ldap.LDAPResultCode, error) TimerFlowCacheExpiry(context.Context) diff --git a/internal/outpost/ldap/constants/constants.go b/internal/outpost/ldap/constants/constants.go index 8f5cdf415..cfa85711e 100644 --- a/internal/outpost/ldap/constants/constants.go +++ b/internal/outpost/ldap/constants/constants.go @@ -1,9 +1,18 @@ package constants +const OC = "objectClass" + const ( OCTop = "top" OCDomain = "domain" OCNSContainer = "nsContainer" + OCSubSchema = "subschema" +) + +const ( + SearchAttributeNone = "1.1" + SearchAttributeAllUser = "*" + SearchAttributeAllOperational = "+" ) const ( @@ -20,7 +29,7 @@ const ( OCOrgPerson = "organizationalPerson" OCInetOrgPerson = "inetOrgPerson" OCAKUser = "goauthentik.io/ldap/user" - OCPosixAccount = "posixAccount" + OCPosixAccount = "posixAccount" ) const ( diff --git a/internal/outpost/ldap/instance.go b/internal/outpost/ldap/instance.go index 6014e7ccc..23fbc9fb5 100644 --- a/internal/outpost/ldap/instance.go +++ b/internal/outpost/ldap/instance.go @@ -2,16 +2,13 @@ package ldap import ( "crypto/tls" - "fmt" "strings" "sync" - "beryju.io/ldap" "github.com/go-openapi/strfmt" log "github.com/sirupsen/logrus" "goauthentik.io/api/v3" - "goauthentik.io/internal/constants" "goauthentik.io/internal/outpost/ldap/bind" ldapConstants "goauthentik.io/internal/outpost/ldap/constants" "goauthentik.io/internal/outpost/ldap/flags" @@ -107,43 +104,6 @@ func (pi *ProviderInstance) GetSearchAllowedGroups() []*strfmt.UUID { return pi.searchAllowedGroups } -func (pi *ProviderInstance) GetBaseEntry() *ldap.Entry { - return &ldap.Entry{ - DN: pi.GetBaseDN(), - Attributes: []*ldap.EntryAttribute{ - { - Name: "distinguishedName", - Values: []string{pi.GetBaseDN()}, - }, - { - Name: "objectClass", - Values: []string{ldapConstants.OCTop, ldapConstants.OCDomain}, - }, - { - Name: "supportedLDAPVersion", - Values: []string{"3"}, - }, - { - Name: "namingContexts", - Values: []string{ - pi.GetBaseDN(), - pi.GetBaseUserDN(), - pi.GetBaseGroupDN(), - pi.GetBaseVirtualGroupDN(), - }, - }, - { - Name: "vendorName", - Values: []string{"goauthentik.io"}, - }, - { - Name: "vendorVersion", - Values: []string{fmt.Sprintf("authentik LDAP Outpost Version %s", constants.FullVersion())}, - }, - }, - } -} - func (pi *ProviderInstance) GetNeededObjects(scope int, baseDN string, filterOC string) (bool, bool) { needUsers := false needGroups := false diff --git a/internal/outpost/ldap/search.go b/internal/outpost/ldap/search.go index 59b29969c..856b8ccbd 100644 --- a/internal/outpost/ldap/search.go +++ b/internal/outpost/ldap/search.go @@ -1,15 +1,13 @@ package ldap import ( - "errors" "net" - "strings" "beryju.io/ldap" "github.com/getsentry/sentry-go" - goldap "github.com/go-ldap/ldap/v3" "github.com/prometheus/client_golang/prometheus" log "github.com/sirupsen/logrus" + "goauthentik.io/internal/outpost/ldap/constants" "goauthentik.io/internal/outpost/ldap/metrics" "goauthentik.io/internal/outpost/ldap/search" ) @@ -36,38 +34,20 @@ func (ls *LDAPServer) Search(bindDN string, searchReq ldap.SearchRequest, conn n sentry.CaptureException(err.(error)) }() - if searchReq.BaseDN == "" { - return ldap.ServerSearchResult{ - Entries: []*ldap.Entry{ - { - DN: "", - Attributes: []*ldap.EntryAttribute{ - { - Name: "objectClass", - Values: []string{"top", "OpenLDAProotDSE"}, - }, - { - Name: "subschemaSubentry", - Values: []string{"cn=subschema"}, - }, - }, - }, - }, - Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess, - }, nil + selectedProvider := ls.providerForRequest(req) + if selectedProvider == nil { + return ls.fallbackRootDSE(req) } - bd, err := goldap.ParseDN(strings.ToLower(searchReq.BaseDN)) + selectedApp = selectedProvider.GetAppSlug() + result, err := ls.searchRoute(req, selectedProvider) if err != nil { - req.Log().WithError(err).Info("failed to parse basedn") - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, errors.New("invalid DN") - } - for _, provider := range ls.providers { - providerBase, _ := goldap.ParseDN(strings.ToLower(provider.BaseDN)) - if providerBase.AncestorOf(bd) || providerBase.Equal(bd) { - selectedApp = provider.GetAppSlug() - return provider.searcher.Search(req) - } + return result, nil } + return ls.filterResultAttributes(req, result), nil +} + +func (ls *LDAPServer) fallbackRootDSE(req *search.Request) (ldap.ServerSearchResult, error) { + req.Log().Trace("returning fallback Root DSE") return ldap.ServerSearchResult{ Entries: []*ldap.Entry{ { @@ -75,15 +55,30 @@ func (ls *LDAPServer) Search(bindDN string, searchReq ldap.SearchRequest, conn n Attributes: []*ldap.EntryAttribute{ { Name: "objectClass", - Values: []string{"top", "OpenLDAProotDSE"}, + Values: []string{constants.OCTop}, + }, + { + Name: "entryDN", + Values: []string{""}, }, { Name: "subschemaSubentry", Values: []string{"cn=subschema"}, }, + { + Name: "namingContexts", + Values: []string{}, + }, + { + Name: "description", + Values: []string{ + "This LDAP server requires an authenticated session.", + }, + }, }, }, }, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess, }, nil + } diff --git a/internal/outpost/ldap/search/direct/base.go b/internal/outpost/ldap/search/direct/base.go index 4af0e1816..87eadd715 100644 --- a/internal/outpost/ldap/search/direct/base.go +++ b/internal/outpost/ldap/search/direct/base.go @@ -2,40 +2,51 @@ package direct import ( "fmt" + "strings" "beryju.io/ldap" "goauthentik.io/internal/constants" + ldapConstants "goauthentik.io/internal/outpost/ldap/constants" "goauthentik.io/internal/outpost/ldap/search" ) -func (ds *DirectSearcher) SearchBase(req *search.Request, authz bool) (ldap.ServerSearchResult, error) { - dn := "" - if authz { - dn = req.SearchRequest.BaseDN +func (ds *DirectSearcher) SearchBase(req *search.Request) (ldap.ServerSearchResult, error) { + if req.Scope == ldap.ScopeSingleLevel { + return ldap.ServerSearchResult{ + ResultCode: ldap.LDAPResultNoSuchObject, + }, nil } return ldap.ServerSearchResult{ Entries: []*ldap.Entry{ { - DN: dn, + DN: "", Attributes: []*ldap.EntryAttribute{ { - Name: "distinguishedName", - Values: []string{ds.si.GetBaseDN()}, + Name: "objectClass", + Values: []string{ldapConstants.OCTop}, }, { - Name: "objectClass", - Values: []string{"top", "domain"}, + Name: "entryDN", + Values: []string{""}, }, { Name: "supportedLDAPVersion", Values: []string{"3"}, }, + { + Name: "subschemaSubentry", + Values: []string{"cn=subschema"}, + }, { Name: "namingContexts", Values: []string{ - ds.si.GetBaseDN(), - ds.si.GetBaseUserDN(), - ds.si.GetBaseGroupDN(), + strings.ToLower(ds.si.GetBaseDN()), + }, + }, + { + Name: "rootDomainNamingContext", + Values: []string{ + strings.ToLower(ds.si.GetBaseDN()), }, }, { diff --git a/internal/outpost/ldap/search/direct/direct.go b/internal/outpost/ldap/search/direct/direct.go index c4a38db96..b82b0ed09 100644 --- a/internal/outpost/ldap/search/direct/direct.go +++ b/internal/outpost/ldap/search/direct/direct.go @@ -31,7 +31,6 @@ func NewDirectSearcher(si server.LDAPServerInstance) *DirectSearcher { si: si, log: log.WithField("logger", "authentik.outpost.ldap.searcher.direct"), } - ds.log.Info("initialised direct searcher") return ds } @@ -39,16 +38,6 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, accsp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.check_access") baseDN := ds.si.GetBaseDN() - filterOC, err := ldap.GetFilterObjectClass(req.Filter) - if err != nil { - metrics.RequestsRejected.With(prometheus.Labels{ - "outpost_name": ds.si.GetOutpostName(), - "type": "search", - "reason": "filter_parse_fail", - "app": ds.si.GetAppSlug(), - }).Inc() - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", req.Filter) - } if len(req.BindDN) < 1 { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ds.si.GetOutpostName(), @@ -99,11 +88,17 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, c.GetConfig().AddDefaultHeader("X-authentik-outpost-ldap-query", req.Filter) scope := req.SearchRequest.Scope - needUsers, needGroups := ds.si.GetNeededObjects(scope, req.BaseDN, filterOC) + needUsers, needGroups := ds.si.GetNeededObjects(scope, req.BaseDN, req.FilterObjectClass) if scope >= 0 && strings.EqualFold(req.BaseDN, baseDN) { - if utils.IncludeObjectClass(filterOC, constants.GetDomainOCs()) { - entries = append(entries, ds.si.GetBaseEntry()) + if utils.IncludeObjectClass(req.FilterObjectClass, constants.GetDomainOCs()) { + rootEntries, _ := ds.SearchBase(req) + // Since `SearchBase` returns entries for the root DN, we need to go through the + // entries and update the base DN + for _, e := range rootEntries.Entries { + e.DN = ds.si.GetBaseDN() + entries = append(entries, e) + } } scope -= 1 // Bring it from WholeSubtree to SingleLevel and so on @@ -197,12 +192,12 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, if scope >= 0 && (strings.EqualFold(req.BaseDN, ds.si.GetBaseDN()) || utils.HasSuffixNoCase(req.BaseDN, ds.si.GetBaseUserDN())) { singleu := utils.HasSuffixNoCase(req.BaseDN, ","+ds.si.GetBaseUserDN()) - if !singleu && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { - entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseUserDN(), constants.OUUsers)) + if !singleu && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(req.FilterObjectClass, ds.si.GetBaseUserDN(), constants.OUUsers)) scope -= 1 } - if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetUserOCs()) { + if scope >= 0 && users != nil && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetUserOCs()) { for _, u := range *users { entry := ds.si.UserEntry(u) if strings.EqualFold(req.BaseDN, entry.DN) || !singleu { @@ -217,12 +212,12 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, if scope >= 0 && (strings.EqualFold(req.BaseDN, ds.si.GetBaseDN()) || utils.HasSuffixNoCase(req.BaseDN, ds.si.GetBaseGroupDN())) { singleg := utils.HasSuffixNoCase(req.BaseDN, ","+ds.si.GetBaseGroupDN()) - if !singleg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { - entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseGroupDN(), constants.OUGroups)) + if !singleg && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(req.FilterObjectClass, ds.si.GetBaseGroupDN(), constants.OUGroups)) scope -= 1 } - if scope >= 0 && groups != nil && utils.IncludeObjectClass(filterOC, constants.GetGroupOCs()) { + if scope >= 0 && groups != nil && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetGroupOCs()) { for _, g := range *groups { entry := group.FromAPIGroup(g, ds.si).Entry() if strings.EqualFold(req.BaseDN, entry.DN) || !singleg { @@ -237,12 +232,12 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, if scope >= 0 && (strings.EqualFold(req.BaseDN, ds.si.GetBaseDN()) || utils.HasSuffixNoCase(req.BaseDN, ds.si.GetBaseVirtualGroupDN())) { singlevg := utils.HasSuffixNoCase(req.BaseDN, ","+ds.si.GetBaseVirtualGroupDN()) - if !singlevg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { - entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseVirtualGroupDN(), constants.OUVirtualGroups)) + if !singlevg && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(req.FilterObjectClass, ds.si.GetBaseVirtualGroupDN(), constants.OUVirtualGroups)) scope -= 1 } - if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetVirtualGroupOCs()) { + if scope >= 0 && users != nil && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetVirtualGroupOCs()) { for _, u := range *users { entry := group.FromAPIUser(u, ds.si).Entry() if strings.EqualFold(req.BaseDN, entry.DN) || !singlevg { diff --git a/internal/outpost/ldap/search/direct/schema.go b/internal/outpost/ldap/search/direct/schema.go new file mode 100644 index 000000000..a024667ae --- /dev/null +++ b/internal/outpost/ldap/search/direct/schema.go @@ -0,0 +1,96 @@ +package direct + +import ( + "beryju.io/ldap" + "goauthentik.io/internal/outpost/ldap/constants" + "goauthentik.io/internal/outpost/ldap/search" +) + +func (ds *DirectSearcher) SearchSubschema(req *search.Request) (ldap.ServerSearchResult, error) { + return ldap.ServerSearchResult{ + Entries: []*ldap.Entry{ + { + DN: "cn=subschema", + Attributes: []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{"subschema"}, + }, + { + Name: constants.OC, + Values: []string{constants.OCTop, "subSchema"}, + }, + { + Name: "ldapSyntaxes", + Values: []string{ + "( 1.3.6.1.4.1.1466.115.121.1.40 DESC 'Octet String' )", + "( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )", + "( 1.3.6.1.4.1.1466.115.121.1.7 DESC 'Boolean' )", + }, + }, + { + Name: "objectClasses", + Values: []string{ + "( 2.5.6.0 NAME 'top' ABSTRACT MUST ( objectClass ) MAY (cn $ description $ displayName $ memberOf $ name ) )", + "( 2.5.6.6 NAME 'person' SUP top STRUCTURAL MUST ( cn ) MAY (sn $ telephoneNumber ) )", + "( 2.5.6.7 NAME 'organizationalPerson' SUP person STRUCTURAL MAY (c $ l $ o $ ou $ title $ givenName $ co $ department $ company $ division $ mail $ mobile $ telephoneNumber ) )", + "( 2.5.6.9 NAME 'groupOfNames' SUP top STRUCTURAL MUST (cn $ member ) MAY (o $ ou ) )", + "( 1.2.840.113556.1.5.9 NAME 'user' SUP organizationalPerson STRUCTURAL MAY ( name $ displayName $ uid $ mail ) )", + "( 1.3.6.1.1.1.2.0 NAME 'posixAccount' SUP top AUXILIARY MAY (cn $ description $ homeDirectory $ uid $ uidNumber $ gidNumber ) )", + "( 2.16.840.1.113730.3.2.2 NAME 'inetOrgPerson' AUX ( posixAccount ) MUST ( sAMAccountName ) MAY ( uidNumber $ gidNumber ))", + // Custom attributes + // Temporarily use 1.3.6.1.4.1.26027.1.1 as a base + // https://docs.oracle.com/cd/E19450-01/820-6169/working-with-object-identifiers.html#obtaining-a-base-oid + "( 1.3.6.1.4.1.26027.1.1.1 NAME 'goauthentik.io/ldap/user' SUP organizationalPerson STRUCTURAL MAY ( ak-active $ sAMAccountName $ goauthentikio-user-sources $ goauthentik.io/user/sources $ goauthentik.io/ldap/active $ goauthentik.io/ldap/superuser $ goauthentikio-user-override-ips $ goauthentikio-user-service-account ) )", + }, + }, + { + Name: "attributeTypes", + Values: []string{ + "( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )", + "( 1.3.6.1.4.1.1466.101.120.5 NAME 'namingContexts' DESC 'RFC4512: naming contexts' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 USAGE dSAOperation )", + "( 2.5.18.10 NAME 'subschemaSubentry' DESC 'RFC4512: name of controlling subschema entry' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )", + "( 1.3.6.1.4.1.1466.101.120.15 NAME 'supportedLDAPVersion' DESC 'RFC4512: supported LDAP versions' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 USAGE dSAOperation )", + "( 1.3.6.1.1.20 NAME 'entryDN' DESC 'DN of the entry' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )", + "( 1.3.6.1.1.4 NAME 'vendorName' DESC 'RFC3045: name of implementation vendor' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE NO-USER-MODIFICATION USAGE dSAOperation )", + "( 1.3.6.1.1.5 NAME 'vendorVersion' DESC 'RFC3045: version of implementation' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE NO-USER-MODIFICATION USAGE dSAOperation )", + "( 0.9.2342.19200300.100.1.1 NAME 'uid' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 0.9.2342.19200300.100.1.3 NAME 'mail' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 0.9.2342.19200300.100.1.41 NAME 'mobile' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 1.2.840.113556.1.2.102 NAME 'memberOf' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' NO-USER-MODIFICATION )", + "( 1.2.840.113556.1.2.13 NAME 'displayName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 1.2.840.113556.1.4.1 NAME 'name' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE NO-USER-MODIFICATION )", + "( 1.2.840.113556.1.2.131 NAME 'co' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 1.2.840.113556.1.2.141 NAME 'department' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 1.2.840.113556.1.2.146 NAME 'company' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 1.2.840.113556.1.4.44 NAME 'homeDirectory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 1.2.840.113556.1.4.221 NAME 'sAMAccountName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 1.2.840.113556.1.4.261 NAME 'division' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 1.3.6.1.1.1.1.0 NAME 'uidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )", + "( 1.3.6.1.1.1.1.1 NAME 'gidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )", + "( 2.5.4.6 NAME 'c' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 2.5.4.7 NAME 'l' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 2.5.4.10 NAME 'o' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )", + "( 2.5.4.11 NAME 'ou' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )", + "( 2.5.4.20 NAME 'telephoneNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 2.5.4.42 NAME 'givenName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 2.5.4.0 NAME 'objectClass' SYNTAX '1.3.6.1.4.1.1466.115.121.1.38' NO-USER-MODIFICATION )", + "( 2.5.4.3 NAME 'cn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 2.5.4.4 NAME 'sn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 2.5.4.12 NAME 'title' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", + "( 2.5.4.13 NAME 'description' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )", + "( 2.5.4.31 NAME 'member' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' )", + // Custom attributes + // Temporarily use 1.3.6.1.4.1.26027.1.1 as a base + // https://docs.oracle.com/cd/E19450-01/820-6169/working-with-object-identifiers.html#obtaining-a-base-oid + "( 1.3.6.1.4.1.26027.1.1.2 NAME ( 'goauthentik.io/ldap/superuser' 'ak-superuser' ) SYNTAX '1.3.6.1.4.1.1466.115.121.1.7' SINGLE-VALUE )", + "( 1.3.6.1.4.1.26027.1.1.3 NAME ( 'goauthentik.io/ldap/active' 'ak-active' ) SYNTAX '1.3.6.1.4.1.1466.115.121.1.7' SINGLE-VALUE )", + "( 1.3.6.1.4.1.26027.1.1.4 NAME 'goauthentikio-user-override-ips' SYNTAX '1.3.6.1.4.1.1466.115.121.1.7' SINGLE-VALUE )", + "( 1.3.6.1.4.1.26027.1.1.5 NAME 'goauthentikio-user-service-account' SYNTAX '1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE' )", + }, + }, + }, + }, + }, + }, nil +} diff --git a/internal/outpost/ldap/search/memory/memory.go b/internal/outpost/ldap/search/memory/memory.go index 08011a6d6..1706493a1 100644 --- a/internal/outpost/ldap/search/memory/memory.go +++ b/internal/outpost/ldap/search/memory/memory.go @@ -15,6 +15,7 @@ import ( "goauthentik.io/internal/outpost/ldap/group" "goauthentik.io/internal/outpost/ldap/metrics" "goauthentik.io/internal/outpost/ldap/search" + "goauthentik.io/internal/outpost/ldap/search/direct" "goauthentik.io/internal/outpost/ldap/server" "goauthentik.io/internal/outpost/ldap/utils" "goauthentik.io/internal/outpost/ldap/utils/paginator" @@ -23,6 +24,7 @@ import ( type MemorySearcher struct { si server.LDAPServerInstance log *log.Entry + ds *direct.DirectSearcher users []api.User groups []api.Group @@ -32,6 +34,7 @@ func NewMemorySearcher(si server.LDAPServerInstance) *MemorySearcher { ms := &MemorySearcher{ si: si, log: log.WithField("logger", "authentik.outpost.ldap.searcher.memory"), + ds: direct.NewDirectSearcher(si), } ms.log.Debug("initialised memory searcher") ms.users = paginator.FetchUsers(ms.si.GetAPIClient().CoreApi.CoreUsersList(context.TODO())) @@ -39,20 +42,18 @@ func NewMemorySearcher(si server.LDAPServerInstance) *MemorySearcher { return ms } +func (ms *MemorySearcher) SearchBase(req *search.Request) (ldap.ServerSearchResult, error) { + return ms.ds.SearchBase(req) +} + +func (ms *MemorySearcher) SearchSubschema(req *search.Request) (ldap.ServerSearchResult, error) { + return ms.ds.SearchSubschema(req) +} + func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, error) { accsp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.check_access") baseDN := ms.si.GetBaseDN() - filterOC, err := ldap.GetFilterObjectClass(req.Filter) - if err != nil { - metrics.RequestsRejected.With(prometheus.Labels{ - "outpost_name": ms.si.GetOutpostName(), - "type": "search", - "reason": "filter_parse_fail", - "app": ms.si.GetAppSlug(), - }).Inc() - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", req.Filter) - } if len(req.BindDN) < 1 { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ms.si.GetOutpostName(), @@ -88,11 +89,15 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, entries := make([]*ldap.Entry, 0) scope := req.SearchRequest.Scope - needUsers, needGroups := ms.si.GetNeededObjects(scope, req.BaseDN, filterOC) + needUsers, needGroups := ms.si.GetNeededObjects(scope, req.BaseDN, req.FilterObjectClass) if scope >= 0 && strings.EqualFold(req.BaseDN, baseDN) { - if utils.IncludeObjectClass(filterOC, constants.GetDomainOCs()) { - entries = append(entries, ms.si.GetBaseEntry()) + if utils.IncludeObjectClass(req.FilterObjectClass, constants.GetDomainOCs()) { + rootEntries, _ := ms.SearchBase(req) + for _, e := range rootEntries.Entries { + e.DN = ms.si.GetBaseDN() + entries = append(entries, e) + } } scope -= 1 // Bring it from WholeSubtree to SingleLevel and so on @@ -100,6 +105,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, var users *[]api.User var groups []*group.LDAPGroup + var err error if needUsers { if flags.CanSearch { @@ -159,12 +165,12 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, if scope >= 0 && (strings.EqualFold(req.BaseDN, ms.si.GetBaseDN()) || utils.HasSuffixNoCase(req.BaseDN, ms.si.GetBaseUserDN())) { singleu := utils.HasSuffixNoCase(req.BaseDN, ","+ms.si.GetBaseUserDN()) - if !singleu && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { - entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseUserDN(), constants.OUUsers)) + if !singleu && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(req.FilterObjectClass, ms.si.GetBaseUserDN(), constants.OUUsers)) scope -= 1 } - if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetUserOCs()) { + if scope >= 0 && users != nil && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetUserOCs()) { for _, u := range *users { entry := ms.si.UserEntry(u) if strings.EqualFold(req.BaseDN, entry.DN) || !singleu { @@ -179,12 +185,12 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, if scope >= 0 && (strings.EqualFold(req.BaseDN, ms.si.GetBaseDN()) || utils.HasSuffixNoCase(req.BaseDN, ms.si.GetBaseGroupDN())) { singleg := utils.HasSuffixNoCase(req.BaseDN, ","+ms.si.GetBaseGroupDN()) - if !singleg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { - entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseGroupDN(), constants.OUGroups)) + if !singleg && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(req.FilterObjectClass, ms.si.GetBaseGroupDN(), constants.OUGroups)) scope -= 1 } - if scope >= 0 && groups != nil && utils.IncludeObjectClass(filterOC, constants.GetGroupOCs()) { + if scope >= 0 && groups != nil && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetGroupOCs()) { for _, g := range groups { if strings.EqualFold(req.BaseDN, g.DN) || !singleg { entries = append(entries, g.Entry()) @@ -198,12 +204,12 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, if scope >= 0 && (strings.EqualFold(req.BaseDN, ms.si.GetBaseDN()) || utils.HasSuffixNoCase(req.BaseDN, ms.si.GetBaseVirtualGroupDN())) { singlevg := utils.HasSuffixNoCase(req.BaseDN, ","+ms.si.GetBaseVirtualGroupDN()) - if !singlevg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { - entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseVirtualGroupDN(), constants.OUVirtualGroups)) + if !singlevg && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(req.FilterObjectClass, ms.si.GetBaseVirtualGroupDN(), constants.OUVirtualGroups)) scope -= 1 } - if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetVirtualGroupOCs()) { + if scope >= 0 && users != nil && utils.IncludeObjectClass(req.FilterObjectClass, constants.GetVirtualGroupOCs()) { for _, u := range *users { entry := group.FromAPIUser(u, ms.si).Entry() if strings.EqualFold(req.BaseDN, entry.DN) || !singlevg { diff --git a/internal/outpost/ldap/search/request.go b/internal/outpost/ldap/search/request.go index fd4097527..7475c5d95 100644 --- a/internal/outpost/ldap/search/request.go +++ b/internal/outpost/ldap/search/request.go @@ -15,8 +15,9 @@ import ( type Request struct { ldap.SearchRequest - BindDN string - log *log.Entry + BindDN string + FilterObjectClass string + log *log.Entry id string conn net.Conn @@ -40,13 +41,26 @@ func NewRequest(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (*Re }) span.SetTag("ldap_filter", searchReq.Filter) span.SetTag("ldap_base_dn", searchReq.BaseDN) + l := log.WithFields(log.Fields{ + "bindDN": bindDN, + "baseDN": searchReq.BaseDN, + "requestId": rid, + "scope": ldap.ScopeMap[searchReq.Scope], + "client": utils.GetIP(conn.RemoteAddr()), + "filter": searchReq.Filter, + }) + filterOC, err := ldap.GetFilterObjectClass(searchReq.Filter) + if err != nil && len(searchReq.Filter) > 0 { + l.WithError(err).WithField("objectClass", filterOC).Warning("invalid filter object class") + } return &Request{ - SearchRequest: searchReq, - BindDN: bindDN, - conn: conn, - log: log.WithField("bindDN", bindDN).WithField("requestId", rid).WithField("scope", ldap.ScopeMap[searchReq.Scope]).WithField("client", utils.GetIP(conn.RemoteAddr())).WithField("filter", searchReq.Filter).WithField("baseDN", searchReq.BaseDN), - id: rid, - ctx: span.Context(), + SearchRequest: searchReq, + BindDN: bindDN, + FilterObjectClass: filterOC, + conn: conn, + log: l, + id: rid, + ctx: span.Context(), }, span } @@ -61,3 +75,19 @@ func (r *Request) Log() *log.Entry { func (r *Request) RemoteAddr() string { return utils.GetIP(r.conn.RemoteAddr()) } + +func (r *Request) FilterLDAPAttributes(res ldap.ServerSearchResult, cb func(attr *ldap.EntryAttribute) bool) ldap.ServerSearchResult { + for _, e := range res.Entries { + newAttrs := []*ldap.EntryAttribute{} + for _, attr := range e.Attributes { + include := cb(attr) + if include { + newAttrs = append(newAttrs, attr) + } else { + r.Log().WithField("key", attr.Name).Trace("filtering out field based on LDAP request") + } + } + e.Attributes = newAttrs + } + return res +} diff --git a/internal/outpost/ldap/search/searcher.go b/internal/outpost/ldap/search/searcher.go index d3490b221..e938cc819 100644 --- a/internal/outpost/ldap/search/searcher.go +++ b/internal/outpost/ldap/search/searcher.go @@ -6,4 +6,6 @@ import ( type Searcher interface { Search(req *Request) (ldap.ServerSearchResult, error) + SearchBase(req *Request) (ldap.ServerSearchResult, error) + SearchSubschema(req *Request) (ldap.ServerSearchResult, error) } diff --git a/internal/outpost/ldap/search_route.go b/internal/outpost/ldap/search_route.go new file mode 100644 index 000000000..c91982348 --- /dev/null +++ b/internal/outpost/ldap/search_route.go @@ -0,0 +1,84 @@ +package ldap + +import ( + "strings" + + "beryju.io/ldap" + goldap "github.com/go-ldap/ldap/v3" + "goauthentik.io/internal/outpost/ldap/constants" + "goauthentik.io/internal/outpost/ldap/search" +) + +func (ls *LDAPServer) providerForRequest(req *search.Request) *ProviderInstance { + parsedBaseDN, err := goldap.ParseDN(strings.ToLower(req.BaseDN)) + if err != nil { + req.Log().WithError(err).Info("failed to parse base DN") + return nil + } + parsedBindDN, err := goldap.ParseDN(strings.ToLower(req.BindDN)) + if err != nil { + req.Log().WithError(err).Info("failed to parse bind DN") + return nil + } + var selectedProvider *ProviderInstance + longestMatch := 0 + for _, provider := range ls.providers { + providerBase, _ := goldap.ParseDN(strings.ToLower(provider.BaseDN)) + // Try to match the provider primarily based on the search request's base DN + baseDNMatches := providerBase.AncestorOf(parsedBaseDN) || providerBase.Equal(parsedBaseDN) + // But also try to match the provider based on the bind DN + bindDNMatches := providerBase.AncestorOf(parsedBindDN) || providerBase.Equal(parsedBindDN) + if baseDNMatches || bindDNMatches { + // Only select the provider if it's a more precise match than previously + if len(provider.BaseDN) > longestMatch { + req.Log().WithField("provider", provider.BaseDN).Trace("selecting provider for search request") + selectedProvider = provider + longestMatch = len(provider.BaseDN) + } + } + } + return selectedProvider +} + +func (ls *LDAPServer) searchRoute(req *search.Request, pi *ProviderInstance) (ldap.ServerSearchResult, error) { + // Route based on the base DN + if len(req.BaseDN) == 0 { + req.Log().Trace("routing to base") + return pi.searcher.SearchBase(req) + } + if strings.EqualFold(req.BaseDN, "cn=subschema") || req.FilterObjectClass == constants.OCSubSchema { + req.Log().Trace("routing to subschema") + return pi.searcher.SearchSubschema(req) + } + req.Log().Trace("routing to default") + return pi.searcher.Search(req) +} + +func (ls *LDAPServer) filterResultAttributes(req *search.Request, result ldap.ServerSearchResult) ldap.ServerSearchResult { + allowedAttributes := []string{} + if len(req.Attributes) == 1 && req.Attributes[0] == constants.SearchAttributeNone { + allowedAttributes = []string{"objectClass"} + } + if len(req.Attributes) > 0 { + // Only strictly filter allowed attributes if we haven't already narrowed the attributes + // down + if len(allowedAttributes) < 1 { + allowedAttributes = req.Attributes + } + // Filter LDAP returned attributes by search requested attributes, taking "1.1" + // into consideration + return req.FilterLDAPAttributes(result, func(attr *ldap.EntryAttribute) bool { + for _, allowed := range allowedAttributes { + if allowed == constants.SearchAttributeAllUser || + allowed == constants.SearchAttributeAllOperational { + return true + } + if strings.EqualFold(allowed, attr.Name) { + return true + } + } + return false + }) + } + return result +} diff --git a/internal/outpost/ldap/server/base.go b/internal/outpost/ldap/server/base.go index 0650598dc..d6227e77e 100644 --- a/internal/outpost/ldap/server/base.go +++ b/internal/outpost/ldap/server/base.go @@ -35,6 +35,5 @@ type LDAPServerInstance interface { GetFlags(dn string) *flags.UserFlags SetFlags(dn string, flags *flags.UserFlags) - GetBaseEntry() *ldap.Entry - GetNeededObjects(int, string, string) (bool, bool) + GetNeededObjects(scope int, baseDN string, filterOC string) (bool, bool) } diff --git a/tests/e2e/test_provider_ldap.py b/tests/e2e/test_provider_ldap.py index e22e68596..061ebef75 100644 --- a/tests/e2e/test_provider_ldap.py +++ b/tests/e2e/test_provider_ldap.py @@ -238,88 +238,82 @@ class TestProviderLDAP(SeleniumTestCase): { "dn": f"cn={o_user.username},ou=users,dc=ldap,dc=goauthentik,dc=io", "attributes": { - "cn": [o_user.username], - "sAMAccountName": [o_user.username], - "uid": [o_user.uid], - "name": [o_user.name], - "displayName": [o_user.name], - "sn": [o_user.name], - "mail": [""], + "cn": o_user.username, + "sAMAccountName": o_user.username, + "uid": o_user.uid, + "name": o_user.name, + "displayName": o_user.name, + "sn": o_user.name, + "mail": "", "objectClass": [ "user", "organizationalPerson", "inetOrgPerson", "goauthentik.io/ldap/user", ], - "uidNumber": [str(2000 + o_user.pk)], - "gidNumber": [str(2000 + o_user.pk)], + "uidNumber": 2000 + o_user.pk, + "gidNumber": 2000 + o_user.pk, "memberOf": [], - "homeDirectory": [ - f"/home/{o_user.username}", - ], - "ak-active": ["true"], - "ak-superuser": ["false"], - "goauthentikio-user-override-ips": ["true"], - "goauthentikio-user-service-account": ["true"], + "homeDirectory": f"/home/{o_user.username}", + "ak-active": True, + "ak-superuser": False, + "goauthentikio-user-override-ips": True, + "goauthentikio-user-service-account": True, }, "type": "searchResEntry", }, { "dn": f"cn={embedded_account.username},ou=users,dc=ldap,dc=goauthentik,dc=io", "attributes": { - "cn": [embedded_account.username], - "sAMAccountName": [embedded_account.username], - "uid": [embedded_account.uid], - "name": [embedded_account.name], - "displayName": [embedded_account.name], - "sn": [embedded_account.name], - "mail": [""], + "cn": embedded_account.username, + "sAMAccountName": embedded_account.username, + "uid": embedded_account.uid, + "name": embedded_account.name, + "displayName": embedded_account.name, + "sn": embedded_account.name, + "mail": "", "objectClass": [ "user", "organizationalPerson", "inetOrgPerson", "goauthentik.io/ldap/user", ], - "uidNumber": [str(2000 + embedded_account.pk)], - "gidNumber": [str(2000 + embedded_account.pk)], + "uidNumber": 2000 + embedded_account.pk, + "gidNumber": 2000 + embedded_account.pk, "memberOf": [], - "homeDirectory": [ - f"/home/{embedded_account.username}", - ], - "ak-active": ["true"], - "ak-superuser": ["false"], - "goauthentikio-user-override-ips": ["true"], - "goauthentikio-user-service-account": ["true"], + "homeDirectory": f"/home/{embedded_account.username}", + "ak-active": True, + "ak-superuser": False, + "goauthentikio-user-override-ips": True, + "goauthentikio-user-service-account": True, }, "type": "searchResEntry", }, { "dn": f"cn={self.user.username},ou=users,dc=ldap,dc=goauthentik,dc=io", "attributes": { - "cn": [self.user.username], - "sAMAccountName": [self.user.username], - "uid": [self.user.uid], - "name": [self.user.name], - "displayName": [self.user.name], - "sn": [self.user.name], - "mail": [self.user.email], + "cn": self.user.username, + "sAMAccountName": self.user.username, + "uid": self.user.uid, + "name": self.user.name, + "displayName": self.user.name, + "sn": self.user.name, + "mail": self.user.email, "objectClass": [ "user", "organizationalPerson", "inetOrgPerson", "goauthentik.io/ldap/user", ], - "uidNumber": [str(2000 + self.user.pk)], - "gidNumber": [str(2000 + self.user.pk)], + "uidNumber": 2000 + self.user.pk, + "gidNumber": 2000 + self.user.pk, "memberOf": [ f"cn={group.name},ou=groups,dc=ldap,dc=goauthentik,dc=io" for group in self.user.ak_groups.all() ], - "homeDirectory": [ - f"/home/{self.user.username}", - ], - "ak-active": ["true"], - "ak-superuser": ["true"], + "homeDirectory": f"/home/{self.user.username}", + "ak-active": True, + "ak-superuser": True, "extraAttribute": ["bar"], }, "type": "searchResEntry",