From 40404ff41d6327eced46b3e9c3a91c9e23abe535 Mon Sep 17 00:00:00 2001 From: Ilya Kogan <1840337+ikogan@users.noreply.github.com> Date: Thu, 2 Dec 2021 09:28:58 -0500 Subject: [PATCH] outposts/ldap: Rework/improve LDAP search logic. (#1687) * outposts/ldap: Refactor searching so we key primarily off base dn * docs: Updating guides on sssd and the ldap outpost. --- go.mod | 1 + go.sum | 2 + internal/outpost/ldap/constants/constants.go | 45 ++++ internal/outpost/ldap/instance.go | 84 ++++++ internal/outpost/ldap/search/direct/direct.go | 252 +++++++++++------- internal/outpost/ldap/search/memory/base.go | 54 ---- internal/outpost/ldap/search/memory/memory.go | 190 ++++++++----- internal/outpost/ldap/search/searcher.go | 4 +- internal/outpost/ldap/server/base.go | 4 + internal/outpost/ldap/utils/utils.go | 33 +++ website/docs/providers/ldap.md | 5 + website/integrations/services/sssd/index.md | 141 ++++++++++ website/sidebarsIntegrations.js | 1 + 13 files changed, 597 insertions(+), 219 deletions(-) delete mode 100644 internal/outpost/ldap/search/memory/base.go create mode 100644 website/integrations/services/sssd/index.md diff --git a/go.mod b/go.mod index 5418b95ad..66d5e72d3 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b gopkg.in/square/go-jose.v2 v2.5.1 // indirect diff --git a/go.sum b/go.sum index 457f683a7..0279435df 100644 --- a/go.sum +++ b/go.sum @@ -672,6 +672,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/internal/outpost/ldap/constants/constants.go b/internal/outpost/ldap/constants/constants.go index d791544d9..f4a5a612d 100644 --- a/internal/outpost/ldap/constants/constants.go +++ b/internal/outpost/ldap/constants/constants.go @@ -1,5 +1,11 @@ package constants +const ( + OCTop = "top" + OCDomain = "domain" + OCNSContainer = "nsContainer" +) + const ( OCGroup = "group" OCGroupOfUniqueNames = "groupOfUniqueNames" @@ -19,3 +25,42 @@ const ( OUGroups = "groups" OUVirtualGroups = "virtual-groups" ) + +func GetDomainOCs() map[string]bool { + return map[string]bool{ + OCTop: true, + OCDomain: true, + } +} + +func GetContainerOCs() map[string]bool { + return map[string]bool{ + OCTop: true, + OCNSContainer: true, + } +} + +func GetUserOCs() map[string]bool { + return map[string]bool{ + OCUser: true, + OCOrgPerson: true, + OCInetOrgPerson: true, + OCAKUser: true, + } +} + +func GetGroupOCs() map[string]bool { + return map[string]bool{ + OCGroup: true, + OCGroupOfUniqueNames: true, + OCAKGroup: true, + } +} + +func GetVirtualGroupOCs() map[string]bool { + return map[string]bool{ + OCGroup: true, + OCGroupOfUniqueNames: true, + OCAKVirtualGroup: true, + } +} diff --git a/internal/outpost/ldap/instance.go b/internal/outpost/ldap/instance.go index 21352518f..1e1d40a4b 100644 --- a/internal/outpost/ldap/instance.go +++ b/internal/outpost/ldap/instance.go @@ -2,14 +2,20 @@ package ldap import ( "crypto/tls" + "fmt" + "strings" "sync" "github.com/go-openapi/strfmt" + "github.com/nmcclain/ldap" log "github.com/sirupsen/logrus" "goauthentik.io/api" + "goauthentik.io/internal/constants" "goauthentik.io/internal/outpost/ldap/bind" + ldapConstants "goauthentik.io/internal/outpost/ldap/constants" "goauthentik.io/internal/outpost/ldap/flags" "goauthentik.io/internal/outpost/ldap/search" + "goauthentik.io/internal/outpost/ldap/utils" ) type ProviderInstance struct { @@ -50,6 +56,10 @@ func (pi *ProviderInstance) GetBaseGroupDN() string { return pi.GroupDN } +func (pi *ProviderInstance) GetBaseVirtualGroupDN() string { + return pi.VirtualGroupDN +} + func (pi *ProviderInstance) GetBaseUserDN() string { return pi.UserDN } @@ -82,3 +92,77 @@ func (pi *ProviderInstance) GetFlowSlug() string { 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 (build %s)", constants.VERSION, constants.BUILD())}, + }, + }, + } +} + +func (pi *ProviderInstance) GetNeededObjects(scope int, baseDN string, filterOC string) (bool, bool) { + needUsers := false + needGroups := false + + // We only want to load users/groups if we're actually going to be asked + // for at least one user or group based on the search's base DN and scope. + // + // If our requested base DN doesn't match any of the container DNs, then + // we're probably loading a user or group. If it does, then make sure our + // scope will eventually take us to users or groups. + if (baseDN == pi.BaseDN || strings.HasSuffix(baseDN, pi.UserDN)) && utils.IncludeObjectClass(filterOC, ldapConstants.GetUserOCs()) { + if baseDN != pi.UserDN && baseDN != pi.BaseDN || + baseDN == pi.BaseDN && scope > 1 || + baseDN == pi.UserDN && scope > 0 { + needUsers = true + } + } + + if (baseDN == pi.BaseDN || strings.HasSuffix(baseDN, pi.GroupDN)) && utils.IncludeObjectClass(filterOC, ldapConstants.GetGroupOCs()) { + if baseDN != pi.GroupDN && baseDN != pi.BaseDN || + baseDN == pi.BaseDN && scope > 1 || + baseDN == pi.GroupDN && scope > 0 { + needGroups = true + } + } + + if (baseDN == pi.BaseDN || strings.HasSuffix(baseDN, pi.VirtualGroupDN)) && utils.IncludeObjectClass(filterOC, ldapConstants.GetVirtualGroupOCs()) { + if baseDN != pi.VirtualGroupDN && baseDN != pi.BaseDN || + baseDN == pi.BaseDN && scope > 1 || + baseDN == pi.VirtualGroupDN && scope > 0 { + needUsers = true + } + } + + return needUsers, needGroups +} diff --git a/internal/outpost/ldap/search/direct/direct.go b/internal/outpost/ldap/search/direct/direct.go index eda11b2bb..40efcee65 100644 --- a/internal/outpost/ldap/search/direct/direct.go +++ b/internal/outpost/ldap/search/direct/direct.go @@ -4,16 +4,15 @@ import ( "errors" "fmt" "strings" - "sync" log "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" "github.com/getsentry/sentry-go" "github.com/nmcclain/ldap" "github.com/prometheus/client_golang/prometheus" "goauthentik.io/api" "goauthentik.io/internal/outpost/ldap/constants" - "goauthentik.io/internal/outpost/ldap/flags" "goauthentik.io/internal/outpost/ldap/group" "goauthentik.io/internal/outpost/ldap/metrics" "goauthentik.io/internal/outpost/ldap/search" @@ -35,26 +34,11 @@ func NewDirectSearcher(si server.LDAPServerInstance) *DirectSearcher { return ds } -func (ds *DirectSearcher) SearchMe(req *search.Request, f flags.UserFlags) (ldap.ServerSearchResult, error) { - if f.UserInfo == nil { - u, _, err := ds.si.GetAPIClient().CoreApi.CoreUsersRetrieve(req.Context(), f.UserPk).Execute() - if err != nil { - req.Log().WithError(err).Warning("Failed to get user info") - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("failed to get userinfo") - } - f.UserInfo = &u - } - entries := make([]*ldap.Entry, 1) - entries[0] = ds.si.UserEntry(*f.UserInfo) - return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil -} - func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, error) { accsp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.check_access") - baseDN := strings.ToLower("," + ds.si.GetBaseDN()) + baseDN := strings.ToLower(ds.si.GetBaseDN()) - entries := []*ldap.Entry{} - filterEntity, err := ldap.GetFilterObjectClass(req.Filter) + filterOC, err := ldap.GetFilterObjectClass(req.Filter) if err != nil { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ds.si.GetOutpostName(), @@ -75,7 +59,7 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, }).Inc() return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", req.BindDN) } - if !strings.HasSuffix(req.BindDN, baseDN) { + if !strings.HasSuffix(req.BindDN, ","+baseDN) { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ds.si.GetOutpostName(), "type": "search", @@ -98,15 +82,6 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, }).Inc() return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied") } - - if req.Scope == ldap.ScopeBaseObject { - req.Log().Debug("base scope, showing domain info") - return ds.SearchBase(req, flags.CanSearch) - } - if !flags.CanSearch { - req.Log().Debug("User can't search, showing info about user") - return ds.SearchMe(req, flags) - } accsp.Finish() parsedFilter, err := ldap.CompileFilter(req.Filter) @@ -121,99 +96,176 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", req.Filter) } + entries := make([]*ldap.Entry, 0) + // Create a custom client to set additional headers c := api.NewAPIClient(ds.si.GetAPIClient().GetConfig()) c.GetConfig().AddDefaultHeader("X-authentik-outpost-ldap-query", req.Filter) - switch filterEntity { - default: - metrics.RequestsRejected.With(prometheus.Labels{ - "outpost_name": ds.si.GetOutpostName(), - "type": "search", - "reason": "unhandled_filter_type", - "dn": req.BindDN, - "client": req.RemoteAddr(), - }).Inc() - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: unhandled filter type: %s [%s]", filterEntity, req.Filter) - case constants.OCGroupOfUniqueNames: - fallthrough - case constants.OCAKGroup: - fallthrough - case constants.OCAKVirtualGroup: - fallthrough - case constants.OCGroup: - wg := sync.WaitGroup{} - wg.Add(2) + scope := req.SearchRequest.Scope + needUsers, needGroups := ds.si.GetNeededObjects(scope, req.BaseDN, filterOC) - gEntries := make([]*ldap.Entry, 0) - uEntries := make([]*ldap.Entry, 0) + if scope >= 0 && req.BaseDN == baseDN { + if utils.IncludeObjectClass(filterOC, constants.GetDomainOCs()) { + entries = append(entries, ds.si.GetBaseEntry()) + } - go func() { - defer wg.Done() + scope -= 1 // Bring it from WholeSubtree to SingleLevel and so on + } + + var users *[]api.User + var groups *[]api.Group + + errs, _ := errgroup.WithContext(req.Context()) + + if needUsers { + errs.Go(func() error { + if flags.CanSearch { + uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") + searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) + + if skip { + req.Log().Trace("Skip backend request") + return nil + } + + u, _, e := searchReq.Execute() + uapisp.Finish() + + if err != nil { + req.Log().WithError(err).Warning("failed to get users") + return e + } + + users = &u.Results + } else { + if flags.UserInfo == nil { + uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") + u, _, err := c.CoreApi.CoreUsersRetrieve(req.Context(), flags.UserPk).Execute() + uapisp.Finish() + + if err != nil { + req.Log().WithError(err).Warning("Failed to get user info") + return fmt.Errorf("failed to get userinfo") + } + + flags.UserInfo = &u + } + + u := make([]api.User, 1) + u[0] = *flags.UserInfo + + users = &u + } + + return nil + }) + } + + if needGroups { + errs.Go(func() error { gapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_group") searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()), parsedFilter, false) if skip { req.Log().Trace("Skip backend request") - return + return nil } - groups, _, err := searchReq.Execute() + + if !flags.CanSearch { + // If they can't search, filter all groups by those they're a member of + searchReq = searchReq.MembersByPk([]int32{flags.UserPk}) + } + + g, _, err := searchReq.Execute() gapisp.Finish() if err != nil { req.Log().WithError(err).Warning("failed to get groups") - return + return err } - req.Log().WithField("count", len(groups.Results)).Trace("Got results from API") + req.Log().WithField("count", len(g.Results)).Trace("Got results from API") - for _, g := range groups.Results { - gEntries = append(gEntries, group.FromAPIGroup(g, ds.si).Entry()) - } - }() - - go func() { - defer wg.Done() - uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") - searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) - if skip { - req.Log().Trace("Skip backend request") - return - } - users, _, err := searchReq.Execute() - uapisp.Finish() - if err != nil { - req.Log().WithError(err).Warning("failed to get users") - return + if !flags.CanSearch { + for i, results := range g.Results { + // If they can't search, remove any users from the group results except the one we're looking for. + g.Results[i].Users = []int32{flags.UserPk} + for _, u := range results.UsersObj { + if u.Pk == flags.UserPk { + g.Results[i].UsersObj = []api.GroupMember{u} + break + } + } + } } - for _, u := range users.Results { - uEntries = append(uEntries, group.FromAPIUser(u, ds.si).Entry()) - } - }() - wg.Wait() - entries = append(gEntries, uEntries...) - case "": - fallthrough - case constants.OCOrgPerson: - fallthrough - case constants.OCInetOrgPerson: - fallthrough - case constants.OCAKUser: - fallthrough - case constants.OCUser: - uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") - searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) - if skip { - req.Log().Trace("Skip backend request") - return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil + groups = &g.Results + + return nil + }) + } + + err = errs.Wait() + + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, err + } + + if scope >= 0 && (req.BaseDN == ds.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ds.si.GetBaseUserDN())) { + singleu := strings.HasSuffix(req.BaseDN, ","+ds.si.GetBaseUserDN()) + + if !singleu && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseUserDN(), constants.OUUsers)) + scope -= 1 } - users, _, err := searchReq.Execute() - uapisp.Finish() - if err != nil { - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("API Error: %s", err) + if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetUserOCs()) { + for _, u := range *users { + entry := ds.si.UserEntry(u) + if req.BaseDN == entry.DN || !singleu { + entries = append(entries, entry) + } + } } - for _, u := range users.Results { - entries = append(entries, ds.si.UserEntry(u)) + + scope += 1 // Return the scope to what it was before we descended + } + + if scope >= 0 && (req.BaseDN == ds.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ds.si.GetBaseGroupDN())) { + singleg := strings.HasSuffix(req.BaseDN, ","+ds.si.GetBaseGroupDN()) + + if !singleg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseGroupDN(), constants.OUGroups)) + scope -= 1 + } + + if scope >= 0 && groups != nil && utils.IncludeObjectClass(filterOC, constants.GetGroupOCs()) { + for _, g := range *groups { + entry := group.FromAPIGroup(g, ds.si).Entry() + if req.BaseDN == entry.DN || !singleg { + entries = append(entries, entry) + } + } + } + + scope += 1 // Return the scope to what it was before we descended + } + + if scope >= 0 && (req.BaseDN == ds.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ds.si.GetBaseVirtualGroupDN())) { + singlevg := strings.HasSuffix(req.BaseDN, ","+ds.si.GetBaseVirtualGroupDN()) + + if !singlevg || utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseVirtualGroupDN(), constants.OUVirtualGroups)) + scope -= 1 + } + + if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetVirtualGroupOCs()) { + for _, u := range *users { + entry := group.FromAPIUser(u, ds.si).Entry() + if req.BaseDN == entry.DN || !singlevg { + entries = append(entries, entry) + } + } } } + return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil } diff --git a/internal/outpost/ldap/search/memory/base.go b/internal/outpost/ldap/search/memory/base.go deleted file mode 100644 index 123d4a7ad..000000000 --- a/internal/outpost/ldap/search/memory/base.go +++ /dev/null @@ -1,54 +0,0 @@ -package memory - -import ( - "fmt" - - "github.com/nmcclain/ldap" - "goauthentik.io/internal/constants" - "goauthentik.io/internal/outpost/ldap/search" -) - -func (ms *MemorySearcher) SearchBase(req *search.Request, authz bool) (ldap.ServerSearchResult, error) { - dn := "" - if authz { - dn = req.SearchRequest.BaseDN - } - return ldap.ServerSearchResult{ - Entries: []*ldap.Entry{ - { - DN: dn, - Attributes: []*ldap.EntryAttribute{ - { - Name: "distinguishedName", - Values: []string{ms.si.GetBaseDN()}, - }, - { - Name: "objectClass", - Values: []string{"top", "domain"}, - }, - { - Name: "supportedLDAPVersion", - Values: []string{"3"}, - }, - { - Name: "namingContexts", - Values: []string{ - ms.si.GetBaseDN(), - ms.si.GetBaseUserDN(), - ms.si.GetBaseGroupDN(), - }, - }, - { - Name: "vendorName", - Values: []string{"goauthentik.io"}, - }, - { - Name: "vendorVersion", - Values: []string{fmt.Sprintf("authentik LDAP Outpost Version %s (build %s)", constants.VERSION, constants.BUILD())}, - }, - }, - }, - }, - Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess, - }, nil -} diff --git a/internal/outpost/ldap/search/memory/memory.go b/internal/outpost/ldap/search/memory/memory.go index bb77b5610..c58bd9f40 100644 --- a/internal/outpost/ldap/search/memory/memory.go +++ b/internal/outpost/ldap/search/memory/memory.go @@ -11,11 +11,11 @@ import ( log "github.com/sirupsen/logrus" "goauthentik.io/api" "goauthentik.io/internal/outpost/ldap/constants" - "goauthentik.io/internal/outpost/ldap/flags" "goauthentik.io/internal/outpost/ldap/group" "goauthentik.io/internal/outpost/ldap/metrics" "goauthentik.io/internal/outpost/ldap/search" "goauthentik.io/internal/outpost/ldap/server" + "goauthentik.io/internal/outpost/ldap/utils" ) type MemorySearcher struct { @@ -37,29 +37,11 @@ func NewMemorySearcher(si server.LDAPServerInstance) *MemorySearcher { return ms } -func (ms *MemorySearcher) SearchMe(req *search.Request, f flags.UserFlags) (ldap.ServerSearchResult, error) { - if f.UserInfo == nil { - for _, u := range ms.users { - if u.Pk == f.UserPk { - f.UserInfo = &u - } - } - if f.UserInfo == nil { - req.Log().WithField("pk", f.UserPk).Warning("User with pk is not in local cache") - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("failed to get userinfo") - } - } - entries := make([]*ldap.Entry, 1) - entries[0] = ms.si.UserEntry(*f.UserInfo) - return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil -} - func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, error) { accsp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.check_access") - baseDN := strings.ToLower("," + ms.si.GetBaseDN()) + baseDN := strings.ToLower(ms.si.GetBaseDN()) - entries := []*ldap.Entry{} - filterEntity, err := ldap.GetFilterObjectClass(req.Filter) + filterOC, err := ldap.GetFilterObjectClass(req.Filter) if err != nil { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ms.si.GetOutpostName(), @@ -80,7 +62,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, }).Inc() return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", req.BindDN) } - if !strings.HasSuffix(req.BindDN, baseDN) { + if !strings.HasSuffix(req.BindDN, ","+baseDN) { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ms.si.GetOutpostName(), "type": "search", @@ -103,52 +85,132 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, }).Inc() return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied") } - - if req.Scope == ldap.ScopeBaseObject { - req.Log().Debug("base scope, showing domain info") - return ms.SearchBase(req, flags.CanSearch) - } - if !flags.CanSearch { - req.Log().Debug("User can't search, showing info about user") - return ms.SearchMe(req, flags) - } accsp.Finish() - switch filterEntity { - default: - metrics.RequestsRejected.With(prometheus.Labels{ - "outpost_name": ms.si.GetOutpostName(), - "type": "search", - "reason": "unhandled_filter_type", - "dn": req.BindDN, - "client": req.RemoteAddr(), - }).Inc() - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: unhandled filter type: %s [%s]", filterEntity, req.Filter) - case constants.OCGroupOfUniqueNames: - fallthrough - case constants.OCAKGroup: - fallthrough - case constants.OCAKVirtualGroup: - fallthrough - case constants.OCGroup: - for _, g := range ms.groups { - entries = append(entries, group.FromAPIGroup(g, ms.si).Entry()) + entries := make([]*ldap.Entry, 0) + + scope := req.SearchRequest.Scope + needUsers, needGroups := ms.si.GetNeededObjects(scope, req.BaseDN, filterOC) + + if scope >= 0 && req.BaseDN == baseDN { + if utils.IncludeObjectClass(filterOC, constants.GetDomainOCs()) { + entries = append(entries, ms.si.GetBaseEntry()) } - for _, u := range ms.users { - entries = append(entries, group.FromAPIUser(u, ms.si).Entry()) - } - case "": - fallthrough - case constants.OCOrgPerson: - fallthrough - case constants.OCInetOrgPerson: - fallthrough - case constants.OCAKUser: - fallthrough - case constants.OCUser: - for _, u := range ms.users { - entries = append(entries, ms.si.UserEntry(u)) + + scope -= 1 // Bring it from WholeSubtree to SingleLevel and so on + } + + var users *[]api.User + var groups []*group.LDAPGroup + + if needUsers { + if flags.CanSearch { + users = &ms.users + } else { + if flags.UserInfo == nil { + for i, u := range ms.users { + if u.Pk == flags.UserPk { + flags.UserInfo = &ms.users[i] + } + } + + if flags.UserInfo == nil { + req.Log().WithField("pk", flags.UserPk).Warning("User with pk is not in local cache") + err = fmt.Errorf("failed to get userinfo") + } + } + + u := make([]api.User, 1) + u[0] = *flags.UserInfo + + users = &u } } + + if needGroups { + groups = make([]*group.LDAPGroup, 0) + + for _, g := range ms.groups { + if flags.CanSearch { + groups = append(groups, group.FromAPIGroup(g, ms.si)) + } else { + // If the user cannot search, we're going to only return + // the groups they're in _and_ only return themselves + // as a member. + for _, u := range g.UsersObj { + if flags.UserPk == u.Pk { + // TODO: Is there a better way to clone this object? + fg := api.NewGroup(g.Pk, g.Name, g.Parent, g.ParentName, []int32{flags.UserPk}, []api.GroupMember{u}) + fg.SetAttributes(*g.Attributes) + fg.SetIsSuperuser(*g.IsSuperuser) + groups = append(groups, group.FromAPIGroup(*fg, ms.si)) + break + } + } + } + } + } + + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, err + } + + if scope >= 0 && (req.BaseDN == ms.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ms.si.GetBaseUserDN())) { + singleu := strings.HasSuffix(req.BaseDN, ","+ms.si.GetBaseUserDN()) + + if !singleu && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseUserDN(), constants.OUUsers)) + scope -= 1 + } + + if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetUserOCs()) { + for _, u := range *users { + entry := ms.si.UserEntry(u) + if req.BaseDN == entry.DN || !singleu { + entries = append(entries, entry) + } + } + } + + scope += 1 // Return the scope to what it was before we descended + } + + if scope >= 0 && (req.BaseDN == ms.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ms.si.GetBaseGroupDN())) { + singleg := strings.HasSuffix(req.BaseDN, ","+ms.si.GetBaseGroupDN()) + + if !singleg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseGroupDN(), constants.OUGroups)) + scope -= 1 + } + + if scope >= 0 && groups != nil && utils.IncludeObjectClass(filterOC, constants.GetGroupOCs()) { + for _, g := range groups { + if req.BaseDN == g.DN || !singleg { + entries = append(entries, g.Entry()) + } + } + } + + scope += 1 // Return the scope to what it was before we descended + } + + if scope >= 0 && (req.BaseDN == ms.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ms.si.GetBaseVirtualGroupDN())) { + singlevg := strings.HasSuffix(req.BaseDN, ","+ms.si.GetBaseVirtualGroupDN()) + + if !singlevg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseVirtualGroupDN(), constants.OUVirtualGroups)) + scope -= 1 + } + + if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetVirtualGroupOCs()) { + for _, u := range *users { + entry := group.FromAPIUser(u, ms.si).Entry() + if req.BaseDN == entry.DN || !singlevg { + entries = append(entries, entry) + } + } + } + } + return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil } diff --git a/internal/outpost/ldap/search/searcher.go b/internal/outpost/ldap/search/searcher.go index 5adb6d2f5..b9394a212 100644 --- a/internal/outpost/ldap/search/searcher.go +++ b/internal/outpost/ldap/search/searcher.go @@ -1,6 +1,8 @@ package search -import "github.com/nmcclain/ldap" +import ( + "github.com/nmcclain/ldap" +) type Searcher interface { Search(req *Request) (ldap.ServerSearchResult, error) diff --git a/internal/outpost/ldap/server/base.go b/internal/outpost/ldap/server/base.go index 623796441..4317a383d 100644 --- a/internal/outpost/ldap/server/base.go +++ b/internal/outpost/ldap/server/base.go @@ -19,6 +19,7 @@ type LDAPServerInstance interface { GetBaseDN() string GetBaseGroupDN() string + GetBaseVirtualGroupDN() string GetBaseUserDN() string GetUserDN(string) string @@ -32,4 +33,7 @@ type LDAPServerInstance interface { GetFlags(string) (flags.UserFlags, bool) SetFlags(string, flags.UserFlags) + + GetBaseEntry() *ldap.Entry + GetNeededObjects(int, string, string) (bool, bool) } diff --git a/internal/outpost/ldap/utils/utils.go b/internal/outpost/ldap/utils/utils.go index ad725b42f..7bcba5f02 100644 --- a/internal/outpost/ldap/utils/utils.go +++ b/internal/outpost/ldap/utils/utils.go @@ -5,6 +5,7 @@ import ( "github.com/nmcclain/ldap" log "github.com/sirupsen/logrus" + ldapConstants "goauthentik.io/internal/outpost/ldap/constants" ) func BoolToString(in bool) string { @@ -84,3 +85,35 @@ func MustHaveAttribute(attrs []*ldap.EntryAttribute, name string, value []string } return attrs } + +func IncludeObjectClass(searchOC string, ocs map[string]bool) bool { + if searchOC == "" { + return true + } + + return ocs[searchOC] +} + +func GetContainerEntry(filterOC string, dn string, ou string) *ldap.Entry { + if IncludeObjectClass(filterOC, ldapConstants.GetContainerOCs()) { + return &ldap.Entry{ + DN: dn, + Attributes: []*ldap.EntryAttribute{ + { + Name: "distinguishedName", + Values: []string{dn}, + }, + { + Name: "objectClass", + Values: []string{"top", "nsContainer"}, + }, + { + Name: "commonName", + Values: []string{ou}, + }, + }, + } + } + + return nil +} diff --git a/website/docs/providers/ldap.md b/website/docs/providers/ldap.md index 63fe73764..5cfdd0897 100644 --- a/website/docs/providers/ldap.md +++ b/website/docs/providers/ldap.md @@ -73,3 +73,8 @@ Starting with 2021.9.1, custom attributes will override the inbuilt attributes. You can also configure SSL for your LDAP Providers by selecting a certificate and a server name in the provider settings. This enables you to bind on port 636 using LDAPS, StartTLS is not supported. + +## Integrations + +See the integration guide for [sssd](../../integrations/services/sssd/index) for +an example guide. diff --git a/website/integrations/services/sssd/index.md b/website/integrations/services/sssd/index.md new file mode 100644 index 000000000..7e4221890 --- /dev/null +++ b/website/integrations/services/sssd/index.md @@ -0,0 +1,141 @@ +--- +title: sssd +--- + +:::info +This feature is still in technical preview, so please report any +Bugs you run into on [GitHub](https://github.com/goauthentik/authentik/issues) +::: + +## What is sssd + +From https://sssd.io/ + +:::note +**SSSD** is an acronym for System Security Services Daemon. It is the client component of centralized identity management solutions such as FreeIPA, 389 Directory Server, Microsoft Active Directory, OpenLDAP and other directory servers. The client serves and caches the information stored in the remote directory server and provides identity, authentication and authorization services to the host machine. +::: + +Note that Authentik supports _only_ user and group objects. As +a consequence, it cannot be used to provide automount or sudo +configuration nor can it provide netgroups or services to `nss`. +Kerberos is also not supported. + +## Preperation + +The following placeholders will be used: + +- `authentik.company` is the FQDN of the authentik install. +- `ldap.baseDN` is the Base DN you configure in the LDAP provider. +- `ldap.domain` is (typically) an FQDN for your domain. Usually + it is just the components of your base DN. For example, if + `ldap.baseDN` is `dc=ldap,dc=goauthentik,dc=io` then the domain + might be `ldap.goauthentik.io`. +- `ldap.searchGroup` is the "Search Group" that can can see all + users and groups in Authentik. +- `sssd.serviceAccount` is a service account created in Authentik +- `sssd.serviceAccountToken` is the service account token generated + by Authentik. + +Create an LDAP Provider if you don't already have one setup. +This guide assumes you will be running with TLS and that you've +correctly setup certificates both in Authentik and on the host +running sssd. See the [ldap provider docs](../../../docs/providers/ldap) for setting up SSL on the Authentik side. + +Remember the Base DN you have configured for the provider as you'll +need it in the sssd configuration. + +Create a new service account for all of your hosts to use to connect +to LDAP and perform searches. Make sure this service account is added +to `ldap.searchGroup`. + +## Deployment + +Create an outpost deployment for the provider you've created above, as described [here](../../../docs/outposts/outposts). Deploy this Outpost either on the same host or a different host that your +host(s) running sssd can access. + +The outpost will connect to authentik and configure itself. + +## Client Configuration + +First, install the necessary sssd packages on your host. Very likely +the package is just `sssd`. + +:::note +This guide well help you configure the `sssd.conf` for LDAP only. You +will likely need to perform other tasks for a usable setup +like setting up automounted or autocreated home directories that +are beyond the scope of this guide. See the "additional resources" +section for some help. +::: + +Create a file at `/etc/sssd/sssd.conf` with contents similar to +the following: + +```ini +[nss] +filter_groups = root +filter_users = root +reconnection_retries = 3 + +[sssd] +config_file_version = 2 +reconnection_retries = 3 +sbus_timeout = 30 +domains = ${ldap.domain} +services = nss, pam, ssh + +[pam] +reconnection_retries = 3 + +[domain/${ldap.domain}] +cache_credentials = True +id_provider = ldap +chpass_provider = ldap +auth_provider = ldap +access_provider = ldap +ldap_uri = ldaps://${authentik.company}:636 + +ldap_schema = rfc2307bis +ldap_search_base = ${ldap.baseDN} +ldap_user_search_base = ou=users,${ldap.baseDN} +ldap_group_search_base = ${ldap.baseDN} + +ldap_user_object_class = user +ldap_user_name = cn +ldap_group_object_class = group +ldap_group_name = cn + +# Optionally, filter logins to only a specific group +#ldap_access_order = filter +#ldap_access_filter = memberOf=cn=authentik Admins,ou=groups,${ldap.baseDN} + +ldap_default_bind_dn = cn=${sssd.serviceAccount},ou=users,${ldap.baseDN} +ldap_default_authtok = ${sssd.serviceAccountToken} +``` + +You should now be able to start sssd; however, the system may not +yet be setup to use it. Depending on your platform, you may need to +use `authconfig` or `pam-auth-update` to configure your system. See +the additional resources section for details. + +:::note +You can store SSH authorized keys in LDAP by adding the +`sshPublicKey` attribute to any user with their public key as +the value. +::: + +## Additional Resources + +The setup of sssd may vary based on Linux distribution and version, +here are some resources that can help you get this setup: + +:::note +Authentik is providing a simple LDAP server, not an Active Directory +domain. Be sure you're looking at the correct sections in these guides. +::: + +- https://sssd.io/docs/quick-start.html#quick-start-ldap +- https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html/system-level_authentication_guide/configuring_services +- https://ubuntu.com/server/docs/service-sssd +- https://manpages.debian.org/unstable/sssd-ldap/sssd-ldap.5.en.html +- https://wiki.archlinux.org/title/LDAP_authentication diff --git a/website/sidebarsIntegrations.js b/website/sidebarsIntegrations.js index 3aae6f1e5..52a3f4191 100644 --- a/website/sidebarsIntegrations.js +++ b/website/sidebarsIntegrations.js @@ -47,6 +47,7 @@ module.exports = { "services/proxmox-ve/index", "services/rancher/index", "services/sentry/index", + "services/sssd/index", "services/sonarr/index", "services/tautulli/index", "services/ubuntu-landscape/index",