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.
This commit is contained in:
Ilya Kogan 2021-12-02 09:28:58 -05:00 committed by GitHub
parent fdd5211253
commit 40404ff41d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 597 additions and 219 deletions

1
go.mod
View file

@ -33,6 +33,7 @@ require (
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect
golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect
golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558 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 google.golang.org/appengine v1.6.7 // indirect
gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b
gopkg.in/square/go-jose.v2 v2.5.1 // indirect gopkg.in/square/go-jose.v2 v2.5.1 // indirect

2
go.sum
View file

@ -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-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-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-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-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-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

View file

@ -1,5 +1,11 @@
package constants package constants
const (
OCTop = "top"
OCDomain = "domain"
OCNSContainer = "nsContainer"
)
const ( const (
OCGroup = "group" OCGroup = "group"
OCGroupOfUniqueNames = "groupOfUniqueNames" OCGroupOfUniqueNames = "groupOfUniqueNames"
@ -19,3 +25,42 @@ const (
OUGroups = "groups" OUGroups = "groups"
OUVirtualGroups = "virtual-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,
}
}

View file

@ -2,14 +2,20 @@ package ldap
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"strings"
"sync" "sync"
"github.com/go-openapi/strfmt" "github.com/go-openapi/strfmt"
"github.com/nmcclain/ldap"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"goauthentik.io/api" "goauthentik.io/api"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ldap/bind" "goauthentik.io/internal/outpost/ldap/bind"
ldapConstants "goauthentik.io/internal/outpost/ldap/constants"
"goauthentik.io/internal/outpost/ldap/flags" "goauthentik.io/internal/outpost/ldap/flags"
"goauthentik.io/internal/outpost/ldap/search" "goauthentik.io/internal/outpost/ldap/search"
"goauthentik.io/internal/outpost/ldap/utils"
) )
type ProviderInstance struct { type ProviderInstance struct {
@ -50,6 +56,10 @@ func (pi *ProviderInstance) GetBaseGroupDN() string {
return pi.GroupDN return pi.GroupDN
} }
func (pi *ProviderInstance) GetBaseVirtualGroupDN() string {
return pi.VirtualGroupDN
}
func (pi *ProviderInstance) GetBaseUserDN() string { func (pi *ProviderInstance) GetBaseUserDN() string {
return pi.UserDN return pi.UserDN
} }
@ -82,3 +92,77 @@ func (pi *ProviderInstance) GetFlowSlug() string {
func (pi *ProviderInstance) GetSearchAllowedGroups() []*strfmt.UUID { func (pi *ProviderInstance) GetSearchAllowedGroups() []*strfmt.UUID {
return pi.searchAllowedGroups 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
}

View file

@ -4,16 +4,15 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/nmcclain/ldap" "github.com/nmcclain/ldap"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"goauthentik.io/api" "goauthentik.io/api"
"goauthentik.io/internal/outpost/ldap/constants" "goauthentik.io/internal/outpost/ldap/constants"
"goauthentik.io/internal/outpost/ldap/flags"
"goauthentik.io/internal/outpost/ldap/group" "goauthentik.io/internal/outpost/ldap/group"
"goauthentik.io/internal/outpost/ldap/metrics" "goauthentik.io/internal/outpost/ldap/metrics"
"goauthentik.io/internal/outpost/ldap/search" "goauthentik.io/internal/outpost/ldap/search"
@ -35,26 +34,11 @@ func NewDirectSearcher(si server.LDAPServerInstance) *DirectSearcher {
return ds 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) { func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, error) {
accsp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.check_access") 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{} filterOC, err := ldap.GetFilterObjectClass(req.Filter)
filterEntity, err := ldap.GetFilterObjectClass(req.Filter)
if err != nil { if err != nil {
metrics.RequestsRejected.With(prometheus.Labels{ metrics.RequestsRejected.With(prometheus.Labels{
"outpost_name": ds.si.GetOutpostName(), "outpost_name": ds.si.GetOutpostName(),
@ -75,7 +59,7 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult,
}).Inc() }).Inc()
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", req.BindDN) 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{ metrics.RequestsRejected.With(prometheus.Labels{
"outpost_name": ds.si.GetOutpostName(), "outpost_name": ds.si.GetOutpostName(),
"type": "search", "type": "search",
@ -98,15 +82,6 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult,
}).Inc() }).Inc()
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied") 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() accsp.Finish()
parsedFilter, err := ldap.CompileFilter(req.Filter) 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) 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 // Create a custom client to set additional headers
c := api.NewAPIClient(ds.si.GetAPIClient().GetConfig()) c := api.NewAPIClient(ds.si.GetAPIClient().GetConfig())
c.GetConfig().AddDefaultHeader("X-authentik-outpost-ldap-query", req.Filter) c.GetConfig().AddDefaultHeader("X-authentik-outpost-ldap-query", req.Filter)
switch filterEntity { scope := req.SearchRequest.Scope
default: needUsers, needGroups := ds.si.GetNeededObjects(scope, req.BaseDN, filterOC)
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)
gEntries := make([]*ldap.Entry, 0) if scope >= 0 && req.BaseDN == baseDN {
uEntries := make([]*ldap.Entry, 0) if utils.IncludeObjectClass(filterOC, constants.GetDomainOCs()) {
entries = append(entries, ds.si.GetBaseEntry())
}
go func() { scope -= 1 // Bring it from WholeSubtree to SingleLevel and so on
defer wg.Done() }
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") gapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_group")
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()), parsedFilter, false) searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()), parsedFilter, false)
if skip { if skip {
req.Log().Trace("Skip backend request") 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() gapisp.Finish()
if err != nil { if err != nil {
req.Log().WithError(err).Warning("failed to get groups") 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 { if !flags.CanSearch {
gEntries = append(gEntries, group.FromAPIGroup(g, ds.si).Entry()) 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 {
go func() { if u.Pk == flags.UserPk {
defer wg.Done() g.Results[i].UsersObj = []api.GroupMember{u}
uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") break
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
} }
for _, u := range users.Results { groups = &g.Results
uEntries = append(uEntries, group.FromAPIUser(u, ds.si).Entry())
} return nil
}() })
wg.Wait() }
entries = append(gEntries, uEntries...)
case "": err = errs.Wait()
fallthrough
case constants.OCOrgPerson: if err != nil {
fallthrough return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, err
case constants.OCInetOrgPerson: }
fallthrough
case constants.OCAKUser: if scope >= 0 && (req.BaseDN == ds.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ds.si.GetBaseUserDN())) {
fallthrough singleu := strings.HasSuffix(req.BaseDN, ","+ds.si.GetBaseUserDN())
case constants.OCUser:
uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") if !singleu && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) {
searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseUserDN(), constants.OUUsers))
if skip { scope -= 1
req.Log().Trace("Skip backend request")
return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil
} }
users, _, err := searchReq.Execute()
uapisp.Finish()
if err != nil { if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetUserOCs()) {
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("API Error: %s", err) 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 return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil
} }

View file

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

View file

@ -11,11 +11,11 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"goauthentik.io/api" "goauthentik.io/api"
"goauthentik.io/internal/outpost/ldap/constants" "goauthentik.io/internal/outpost/ldap/constants"
"goauthentik.io/internal/outpost/ldap/flags"
"goauthentik.io/internal/outpost/ldap/group" "goauthentik.io/internal/outpost/ldap/group"
"goauthentik.io/internal/outpost/ldap/metrics" "goauthentik.io/internal/outpost/ldap/metrics"
"goauthentik.io/internal/outpost/ldap/search" "goauthentik.io/internal/outpost/ldap/search"
"goauthentik.io/internal/outpost/ldap/server" "goauthentik.io/internal/outpost/ldap/server"
"goauthentik.io/internal/outpost/ldap/utils"
) )
type MemorySearcher struct { type MemorySearcher struct {
@ -37,29 +37,11 @@ func NewMemorySearcher(si server.LDAPServerInstance) *MemorySearcher {
return ms 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) { func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, error) {
accsp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.check_access") 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{} filterOC, err := ldap.GetFilterObjectClass(req.Filter)
filterEntity, err := ldap.GetFilterObjectClass(req.Filter)
if err != nil { if err != nil {
metrics.RequestsRejected.With(prometheus.Labels{ metrics.RequestsRejected.With(prometheus.Labels{
"outpost_name": ms.si.GetOutpostName(), "outpost_name": ms.si.GetOutpostName(),
@ -80,7 +62,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
}).Inc() }).Inc()
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", req.BindDN) 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{ metrics.RequestsRejected.With(prometheus.Labels{
"outpost_name": ms.si.GetOutpostName(), "outpost_name": ms.si.GetOutpostName(),
"type": "search", "type": "search",
@ -103,52 +85,132 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
}).Inc() }).Inc()
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied") 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() accsp.Finish()
switch filterEntity { entries := make([]*ldap.Entry, 0)
default:
metrics.RequestsRejected.With(prometheus.Labels{ scope := req.SearchRequest.Scope
"outpost_name": ms.si.GetOutpostName(), needUsers, needGroups := ms.si.GetNeededObjects(scope, req.BaseDN, filterOC)
"type": "search",
"reason": "unhandled_filter_type", if scope >= 0 && req.BaseDN == baseDN {
"dn": req.BindDN, if utils.IncludeObjectClass(filterOC, constants.GetDomainOCs()) {
"client": req.RemoteAddr(), entries = append(entries, ms.si.GetBaseEntry())
}).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())
} }
for _, u := range ms.users {
entries = append(entries, group.FromAPIUser(u, ms.si).Entry()) scope -= 1 // Bring it from WholeSubtree to SingleLevel and so on
} }
case "":
fallthrough var users *[]api.User
case constants.OCOrgPerson: var groups []*group.LDAPGroup
fallthrough
case constants.OCInetOrgPerson: if needUsers {
fallthrough if flags.CanSearch {
case constants.OCAKUser: users = &ms.users
fallthrough } else {
case constants.OCUser: if flags.UserInfo == nil {
for _, u := range ms.users { for i, u := range ms.users {
entries = append(entries, ms.si.UserEntry(u)) 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 return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil
} }

View file

@ -1,6 +1,8 @@
package search package search
import "github.com/nmcclain/ldap" import (
"github.com/nmcclain/ldap"
)
type Searcher interface { type Searcher interface {
Search(req *Request) (ldap.ServerSearchResult, error) Search(req *Request) (ldap.ServerSearchResult, error)

View file

@ -19,6 +19,7 @@ type LDAPServerInstance interface {
GetBaseDN() string GetBaseDN() string
GetBaseGroupDN() string GetBaseGroupDN() string
GetBaseVirtualGroupDN() string
GetBaseUserDN() string GetBaseUserDN() string
GetUserDN(string) string GetUserDN(string) string
@ -32,4 +33,7 @@ type LDAPServerInstance interface {
GetFlags(string) (flags.UserFlags, bool) GetFlags(string) (flags.UserFlags, bool)
SetFlags(string, flags.UserFlags) SetFlags(string, flags.UserFlags)
GetBaseEntry() *ldap.Entry
GetNeededObjects(int, string, string) (bool, bool)
} }

View file

@ -5,6 +5,7 @@ import (
"github.com/nmcclain/ldap" "github.com/nmcclain/ldap"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
ldapConstants "goauthentik.io/internal/outpost/ldap/constants"
) )
func BoolToString(in bool) string { func BoolToString(in bool) string {
@ -84,3 +85,35 @@ func MustHaveAttribute(attrs []*ldap.EntryAttribute, name string, value []string
} }
return attrs 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
}

View file

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

View file

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

View file

@ -47,6 +47,7 @@ module.exports = {
"services/proxmox-ve/index", "services/proxmox-ve/index",
"services/rancher/index", "services/rancher/index",
"services/sentry/index", "services/sentry/index",
"services/sssd/index",
"services/sonarr/index", "services/sonarr/index",
"services/tautulli/index", "services/tautulli/index",
"services/ubuntu-landscape/index", "services/ubuntu-landscape/index",