mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Fix LDAP login lookup and cache probing
This commit is contained in:
parent
e8cd4ed04e
commit
dd1fc43762
@ -89,6 +89,23 @@ func cmpKey() string {
|
||||
return consts.DefaultEncryptionKey
|
||||
}
|
||||
func keyTo32Bytes(input string) []byte { s := sha256.Sum256([]byte(input)); return s[:] }
|
||||
func applyDefaults(src *Source) {
|
||||
if src.UserNameAttribute == "" {
|
||||
src.UserNameAttribute = "uid"
|
||||
}
|
||||
if src.DisplayNameAttribute == "" {
|
||||
src.DisplayNameAttribute = "cn"
|
||||
}
|
||||
if src.EmailAttribute == "" {
|
||||
src.EmailAttribute = "mail"
|
||||
}
|
||||
if src.GroupNameAttribute == "" {
|
||||
src.GroupNameAttribute = "cn"
|
||||
}
|
||||
if src.GroupMemberAttribute == "" {
|
||||
src.GroupMemberAttribute = "member"
|
||||
}
|
||||
}
|
||||
func (s *Store) Load(ctx context.Context) (Config, error) {
|
||||
var c Config
|
||||
b, err := os.ReadFile(s.path)
|
||||
@ -111,6 +128,7 @@ func (s *Store) Load(ctx context.Context) (Config, error) {
|
||||
c.Sources[i].BindPassword = p
|
||||
}
|
||||
}
|
||||
applyDefaults(&c.Sources[i])
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@ -130,21 +148,7 @@ func (s *Store) Save(ctx context.Context, c Config) error {
|
||||
if out.Sources[i].ID == "" {
|
||||
out.Sources[i].ID = id.NewRandom()
|
||||
}
|
||||
if out.Sources[i].UserNameAttribute == "" {
|
||||
out.Sources[i].UserNameAttribute = "uid"
|
||||
}
|
||||
if out.Sources[i].DisplayNameAttribute == "" {
|
||||
out.Sources[i].DisplayNameAttribute = "cn"
|
||||
}
|
||||
if out.Sources[i].EmailAttribute == "" {
|
||||
out.Sources[i].EmailAttribute = "mail"
|
||||
}
|
||||
if out.Sources[i].GroupNameAttribute == "" {
|
||||
out.Sources[i].GroupNameAttribute = "cn"
|
||||
}
|
||||
if out.Sources[i].GroupMemberAttribute == "" {
|
||||
out.Sources[i].GroupMemberAttribute = "member"
|
||||
}
|
||||
applyDefaults(&out.Sources[i])
|
||||
if out.Sources[i].BindPassword != "" {
|
||||
enc, err := utils.Encrypt(ctx, s.key, out.Sources[i].BindPassword)
|
||||
if err != nil {
|
||||
@ -190,9 +194,11 @@ func Authenticate(ctx context.Context, ds model.DataStore, sourceID, username, p
|
||||
}
|
||||
u, err := authLDAP(ctx, ds, src, username, password)
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
log.Debug(ctx, "LDAP user not found in source", "source", src.Name, "username", username)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn(ctx, "LDAP authentication failed", "source", src.Name, "username", username, err)
|
||||
return nil, nil
|
||||
}
|
||||
return u, nil
|
||||
@ -215,6 +221,7 @@ func authInternal(ctx context.Context, ds model.DataStore, username, password st
|
||||
}
|
||||
|
||||
func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, password string) (*model.User, error) {
|
||||
applyDefaults(&src)
|
||||
if password == "" {
|
||||
return nil, ErrBadPassword
|
||||
}
|
||||
@ -248,7 +255,9 @@ func lookupAndBind(src Source, username, password string) (DiscoveredUser, error
|
||||
}
|
||||
defer l.Close()
|
||||
if src.StartTLS {
|
||||
_ = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}) //nolint:gosec
|
||||
if err = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}); err != nil { //nolint:gosec
|
||||
return DiscoveredUser{}, err
|
||||
}
|
||||
}
|
||||
if src.DirectBindDNTemplate != "" {
|
||||
dn := fmt.Sprintf(src.DirectBindDNTemplate, ldap.EscapeFilter(username))
|
||||
@ -262,11 +271,8 @@ func lookupAndBind(src Source, username, password string) (DiscoveredUser, error
|
||||
return DiscoveredUser{}, err
|
||||
}
|
||||
}
|
||||
filt := src.UserFilter
|
||||
if filt == "" {
|
||||
filt = "(%s=%s)"
|
||||
}
|
||||
filt = fmt.Sprintf(filt, src.UserNameAttribute, ldap.EscapeFilter(username))
|
||||
filt := loginUserFilter(src, username)
|
||||
log.Debug("LDAP searching user", "source", src.Name, "username", username, "filter", filt, "baseDN", src.UserBaseDN)
|
||||
req := ldap.NewSearchRequest(src.UserBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, filt, []string{src.UserNameAttribute, src.DisplayNameAttribute, src.EmailAttribute, "memberOf"}, nil)
|
||||
res, err := l.Search(req)
|
||||
if err != nil || len(res.Entries) == 0 {
|
||||
@ -334,14 +340,63 @@ func first(v ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func loginUserFilter(src Source, username string) string {
|
||||
attr := first(src.UserNameAttribute, "uid")
|
||||
escapedUsername := ldap.EscapeFilter(username)
|
||||
if src.UserFilter == "" {
|
||||
return fmt.Sprintf("(%s=%s)", attr, escapedUsername)
|
||||
}
|
||||
if placeholders := strings.Count(src.UserFilter, "%s"); placeholders > 0 {
|
||||
if placeholders == 1 {
|
||||
return fmt.Sprintf(src.UserFilter, escapedUsername)
|
||||
}
|
||||
return fmt.Sprintf(src.UserFilter, attr, escapedUsername)
|
||||
}
|
||||
return fmt.Sprintf("(&%s(%s=%s))", src.UserFilter, attr, escapedUsername)
|
||||
}
|
||||
|
||||
func dedupeStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
key := strings.ToLower(value)
|
||||
if value == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func userNames(users []DiscoveredUser) []string {
|
||||
names := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
names = append(names, first(user.UserName, user.DN))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func groupNames(groups []DiscoveredGroup) []string {
|
||||
names := make([]string, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
names = append(names, first(group.Name, group.DN))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestAndCache(ctx context.Context, src Source) (Source, error) {
|
||||
applyDefaults(&src)
|
||||
src.Cache = Cache{}
|
||||
l, err := ldap.DialURL(src.URL)
|
||||
if err != nil {
|
||||
return src, err
|
||||
}
|
||||
defer l.Close()
|
||||
if src.StartTLS {
|
||||
_ = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}) //nolint:gosec
|
||||
if err = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}); err != nil { //nolint:gosec
|
||||
return src, err
|
||||
}
|
||||
}
|
||||
if src.BindDN != "" {
|
||||
if err = l.Bind(src.BindDN, src.BindPassword); err != nil {
|
||||
@ -354,10 +409,13 @@ func TestAndCache(ctx context.Context, src Source) (Source, error) {
|
||||
uf = "(objectClass=person)"
|
||||
}
|
||||
ur := ldap.NewSearchRequest(src.UserBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 60, false, uf, attrs, nil)
|
||||
users, _ := l.Search(ur)
|
||||
users, err := l.Search(ur)
|
||||
if err != nil {
|
||||
return src, err
|
||||
}
|
||||
grps := map[string][]string{}
|
||||
for _, e := range users.Entries {
|
||||
src.Cache.Users = append(src.Cache.Users, DiscoveredUser{DN: e.DN, UserName: e.GetAttributeValue(src.UserNameAttribute), Name: e.GetAttributeValue(src.DisplayNameAttribute), Email: e.GetAttributeValue(src.EmailAttribute), Groups: e.GetAttributeValues("memberOf")})
|
||||
src.Cache.Users = append(src.Cache.Users, DiscoveredUser{DN: e.DN, UserName: e.GetAttributeValue(src.UserNameAttribute), Name: e.GetAttributeValue(src.DisplayNameAttribute), Email: e.GetAttributeValue(src.EmailAttribute), Groups: dedupeStrings(e.GetAttributeValues("memberOf"))})
|
||||
}
|
||||
gf := src.GroupFilter
|
||||
if gf == "" {
|
||||
@ -365,9 +423,17 @@ func TestAndCache(ctx context.Context, src Source) (Source, error) {
|
||||
}
|
||||
if src.GroupBaseDN != "" {
|
||||
gr := ldap.NewSearchRequest(src.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 60, false, gf, []string{src.GroupNameAttribute, src.GroupMemberAttribute}, nil)
|
||||
gres, _ := l.Search(gr)
|
||||
gres, err := l.Search(gr)
|
||||
if err != nil {
|
||||
return src, err
|
||||
}
|
||||
seenGroups := map[string]bool{}
|
||||
for _, e := range gres.Entries {
|
||||
m := e.GetAttributeValues(src.GroupMemberAttribute)
|
||||
if seenGroups[e.DN] {
|
||||
continue
|
||||
}
|
||||
seenGroups[e.DN] = true
|
||||
m := dedupeStrings(e.GetAttributeValues(src.GroupMemberAttribute))
|
||||
grps[e.DN] = m
|
||||
src.Cache.Groups = append(src.Cache.Groups, DiscoveredGroup{DN: e.DN, Name: e.GetAttributeValue(src.GroupNameAttribute), Members: m})
|
||||
}
|
||||
@ -378,9 +444,10 @@ func TestAndCache(ctx context.Context, src Source) (Source, error) {
|
||||
src.Cache.Users[i].Groups = append(src.Cache.Users[i].Groups, g)
|
||||
}
|
||||
}
|
||||
src.Cache.Users[i].Groups = dedupeStrings(src.Cache.Users[i].Groups)
|
||||
}
|
||||
now := time.Now()
|
||||
src.LastSyncAt = &now
|
||||
log.Info(ctx, "LDAP test/cache completed", "source", src.Name, "users", len(src.Cache.Users), "groups", len(src.Cache.Groups))
|
||||
log.Info(ctx, "LDAP test/cache completed", "source", src.Name, "users", len(src.Cache.Users), "groups", len(src.Cache.Groups), "matchedUsers", strings.Join(userNames(src.Cache.Users), ","), "matchedGroups", strings.Join(groupNames(src.Cache.Groups), ","))
|
||||
return src, nil
|
||||
}
|
||||
|
||||
57
core/ldapauth/ldapauth_test.go
Normal file
57
core/ldapauth/ldapauth_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package ldapauth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoginUserFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("defaults to username attribute equality", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := loginUserFilter(Source{UserNameAttribute: "uid"}, "firehawk")
|
||||
want := "(uid=firehawk)"
|
||||
if got != want {
|
||||
t.Fatalf("loginUserFilter() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves explicit placeholder filters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(&(objectClass=person)(%s=%s))"}, "firehawk")
|
||||
want := "(&(objectClass=person)(uid=firehawk))"
|
||||
if got != want {
|
||||
t.Fatalf("loginUserFilter() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("supports one-placeholder username filters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(uid=%s)"}, "firehawk")
|
||||
want := "(uid=firehawk)"
|
||||
if got != want {
|
||||
t.Fatalf("loginUserFilter() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adds username assertion to discovery filters", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(objectClass=person)"}, "firehawk")
|
||||
want := "(&(objectClass=person)(uid=firehawk))"
|
||||
if got != want {
|
||||
t.Fatalf("loginUserFilter() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDedupeStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := dedupeStrings([]string{"cn=users", "CN=users", "", "cn=admins"})
|
||||
want := []string{"cn=users", "cn=admins"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("dedupeStrings() = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("dedupeStrings() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -286,6 +286,26 @@ const SourceWizard = ({ initialSource, onCancel, onSave, onTest, testing }) => {
|
||||
Cached users: {source.cache?.users?.length || 0} · Cached groups:{' '}
|
||||
{source.cache?.groups?.length || 0}
|
||||
</Typography>
|
||||
{!!source.cache?.users?.length && (
|
||||
<Typography variant="body2" style={{ marginTop: 8 }}>
|
||||
Matched users:{' '}
|
||||
{source.cache.users
|
||||
.slice(0, 10)
|
||||
.map((user) => user.userName || user.dn)
|
||||
.join(', ')}
|
||||
{source.cache.users.length > 10 ? '…' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
{!!source.cache?.groups?.length && (
|
||||
<Typography variant="body2" style={{ marginTop: 8 }}>
|
||||
Matched groups:{' '}
|
||||
{source.cache.groups
|
||||
.slice(0, 10)
|
||||
.map((group) => group.name || group.dn)
|
||||
.join(', ')}
|
||||
{source.cache.groups.length > 10 ? '…' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user