diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go new file mode 100644 index 000000000..030dff047 --- /dev/null +++ b/core/ldapauth/ldapauth.go @@ -0,0 +1,464 @@ +package ldapauth + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + ldap "github.com/go-ldap/ldap/v3" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils" +) + +type Config struct { + Sources []Source `json:"sources"` +} +type Source struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + URL string `json:"url"` + StartTLS bool `json:"startTLS"` + InsecureSkipVerify bool `json:"insecureSkipVerify"` + BindDN string `json:"bindDN"` + BindPassword string `json:"bindPassword,omitempty"` + UserBaseDN string `json:"userBaseDN"` + UserFilter string `json:"userFilter"` + UserNameAttribute string `json:"userNameAttribute"` + DisplayNameAttribute string `json:"displayNameAttribute"` + EmailAttribute string `json:"emailAttribute"` + GroupBaseDN string `json:"groupBaseDN"` + GroupFilter string `json:"groupFilter"` + GroupNameAttribute string `json:"groupNameAttribute"` + GroupMemberAttribute string `json:"groupMemberAttribute"` + RequiredGroupDNs []string `json:"requiredGroupDNs"` + AdminGroupDNs []string `json:"adminGroupDNs"` + DirectBindDNTemplate string `json:"directBindDNTemplate"` + LastSyncAt *time.Time `json:"lastSyncAt,omitempty"` + Cache Cache `json:"cache,omitempty"` +} +type Cache struct { + Users []DiscoveredUser `json:"users,omitempty"` + Groups []DiscoveredGroup `json:"groups,omitempty"` +} +type DiscoveredUser struct { + DN string `json:"dn"` + UserName string `json:"userName"` + Name string `json:"name"` + Email string `json:"email"` + Groups []string `json:"groups,omitempty"` +} +type DiscoveredGroup struct { + DN string `json:"dn"` + Name string `json:"name"` + Members []string `json:"members,omitempty"` +} +type AuthSource struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +var ErrUserNotFound = errors.New("ldap user not found") +var ErrBadPassword = errors.New("ldap invalid password") + +type Store struct { + path string + key []byte +} + +func NewStore() *Store { + return &Store{path: filepath.Join(conf.Server.DataFolder.String(), "ldap.json"), key: keyTo32Bytes(cmpKey())} +} +func cmpKey() string { + if conf.Server.PasswordEncryptionKey != "" { + return conf.Server.PasswordEncryptionKey + } + 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) + if errors.Is(err, os.ErrNotExist) { + return c, nil + } + if err != nil { + return c, err + } + if len(b) == 0 { + return c, nil + } + err = json.Unmarshal(b, &c) + if err != nil { + return c, err + } + for i := range c.Sources { + if c.Sources[i].BindPassword != "" { + p, e := utils.Decrypt(ctx, s.key, c.Sources[i].BindPassword) + if e != nil { + return c, fmt.Errorf("failed to decrypt bind password for source %s: %w", c.Sources[i].Name, e) + } + c.Sources[i].BindPassword = p + } + applyDefaults(&c.Sources[i]) + } + return c, nil +} +func (s *Store) Save(ctx context.Context, c Config) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { + return err + } + var out Config + b, err := json.Marshal(c) + if err != nil { + return err + } + if err := json.Unmarshal(b, &out); err != nil { + return err + } + for i := range out.Sources { + if out.Sources[i].ID == "" { + out.Sources[i].ID = id.NewRandom() + } + applyDefaults(&out.Sources[i]) + if out.Sources[i].BindPassword != "" { + enc, err := utils.Encrypt(ctx, s.key, out.Sources[i].BindPassword) + if err != nil { + return err + } + out.Sources[i].BindPassword = enc + } + } + b, err = json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, b, 0600) +} +func Sources(ctx context.Context) []AuthSource { + cfg, _ := NewStore().Load(ctx) + res := []AuthSource{{ID: "internal", Name: "Internal", Type: "internal"}} + for _, src := range cfg.Sources { + if src.Enabled { + res = append(res, AuthSource{ID: src.ID, Name: src.Name, Type: "ldap"}) + } + } + return res +} + +func Authenticate(ctx context.Context, ds model.DataStore, sourceID, username, password string) (*model.User, error) { + if sourceID == "" || sourceID == "internal" { + u, found, err := authInternal(ctx, ds, username, password, sourceID == "") + if err != nil || found { + return u, err + } + if sourceID == "internal" { + return nil, nil + } + } + cfg, _ := NewStore().Load(ctx) + for _, src := range cfg.Sources { + if !src.Enabled { + continue + } + if sourceID != "" && sourceID != "internal" && sourceID != src.ID && sourceID != src.Name { + continue + } + 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 + } + return nil, nil +} +func authInternal(ctx context.Context, ds model.DataStore, username, password string, skipExternal bool) (*model.User, bool, error) { + u, err := ds.User(ctx).FindByUsernameWithPassword(username) + if errors.Is(err, model.ErrNotFound) { + return nil, false, nil + } + if err != nil { + return nil, true, err + } + if skipExternal && u.AuthSource != "" { + log.Debug(ctx, "Skipping internal password check for externally managed user", "username", username, "authSource", u.AuthSource, "authSourceId", u.AuthSourceID) + return nil, false, nil + } + if u.Password != password { + return nil, true, nil + } + _ = ds.User(ctx).UpdateLastLoginAt(u.ID) + return u, true, nil +} + +func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, password string) (*model.User, error) { + applyDefaults(&src) + if password == "" { + return nil, ErrBadPassword + } + du, err := lookupAndBind(src, username, password) + if err != nil { + return nil, err + } + if !allowed(src, du) { + return nil, ErrBadPassword + } + repo := ds.User(ctx) + u, err := repo.FindByUsername(du.UserName) + if errors.Is(err, model.ErrNotFound) { + u = &model.User{ID: id.NewRandom(), UserName: du.UserName, NewPassword: consts.PasswordAutogenPrefix + id.NewRandom()} + } else if err != nil { + return nil, err + } + u.Name = first(du.Name, du.UserName) + u.Email = du.Email + u.IsAdmin = memberAny(du.Groups, src.AdminGroupDNs) + u.AuthSource = "ldap" + u.AuthSourceID = first(src.ID, src.Name) + u.ExternalSync = true + if err := repo.Put(u); err != nil { + return nil, err + } + _ = repo.UpdateLastLoginAt(u.ID) + return repo.FindByUsernameWithPassword(u.UserName) +} +func lookupAndBind(src Source, username, password string) (DiscoveredUser, error) { + l, err := ldap.DialURL(src.URL) + if err != nil { + return DiscoveredUser{}, err + } + defer l.Close() + if src.StartTLS { + 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)) + if err = l.Bind(dn, password); err != nil { + return DiscoveredUser{}, ErrBadPassword + } + return DiscoveredUser{DN: dn, UserName: username}, nil + } + if src.BindDN != "" { + if err = l.Bind(src.BindDN, src.BindPassword); err != nil { + return DiscoveredUser{}, err + } + } + 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 { + return DiscoveredUser{}, ErrUserNotFound + } + e := res.Entries[0] + if err = l.Bind(e.DN, password); err != nil { + return DiscoveredUser{}, ErrBadPassword + } + du := DiscoveredUser{DN: e.DN, UserName: first(e.GetAttributeValue(src.UserNameAttribute), username), Name: e.GetAttributeValue(src.DisplayNameAttribute), Email: e.GetAttributeValue(src.EmailAttribute), Groups: e.GetAttributeValues("memberOf")} + du.Groups = append(du.Groups, groupsForUser(src, e.DN, du.UserName)...) + return du, nil +} +func groupsForUser(src Source, userDN, username string) []string { + if src.GroupBaseDN == "" { + return nil + } + l, err := ldap.DialURL(src.URL) + if err != nil { + return nil + } + defer l.Close() + if src.StartTLS { + if err = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}); err != nil { //nolint:gosec + return nil + } + } + if src.BindDN != "" { + if err = l.Bind(src.BindDN, src.BindPassword); err != nil { + return nil + } + } + gf := src.GroupFilter + if gf == "" { + gf = "(|(objectClass=groupOfNames)(objectClass=groupOfUniqueNames)(objectClass=group))" + } + req := ldap.NewSearchRequest(src.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 30, false, gf, []string{src.GroupMemberAttribute}, nil) + res, err := l.Search(req) + if err != nil { + return nil + } + var out []string + for _, g := range res.Entries { + members := g.GetAttributeValues(src.GroupMemberAttribute) + if slices.Contains(members, userDN) || slices.Contains(members, username) { + out = append(out, g.DN) + } + } + return out +} +func allowed(src Source, u DiscoveredUser) bool { + return len(src.RequiredGroupDNs) == 0 || memberAny(u.Groups, src.RequiredGroupDNs) || memberAny(u.Groups, src.AdminGroupDNs) +} +func memberAny(a, b []string) bool { + for _, x := range a { + for _, y := range b { + if strings.EqualFold(x, y) { + return true + } + } + } + return false +} +func first(v ...string) string { + for _, s := range v { + if s != "" { + return s + } + } + 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 strings.Contains(src.UserFilter, "%s") { + return strings.ReplaceAll(src.UserFilter, "%s", 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 { + 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 { + return src, err + } + } + attrs := []string{src.UserNameAttribute, src.DisplayNameAttribute, src.EmailAttribute, "memberOf"} + uf := src.UserFilter + if uf == "" { + uf = "(objectClass=person)" + } + ur := ldap.NewSearchRequest(src.UserBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 60, false, uf, attrs, nil) + 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: dedupeStrings(e.GetAttributeValues("memberOf"))}) + } + gf := src.GroupFilter + if gf == "" { + gf = "(|(objectClass=groupOfNames)(objectClass=groupOfUniqueNames)(objectClass=group))" + } + if src.GroupBaseDN != "" { + gr := ldap.NewSearchRequest(src.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 60, false, gf, []string{src.GroupNameAttribute, src.GroupMemberAttribute}, nil) + gres, err := l.Search(gr) + if err != nil { + return src, err + } + seenGroups := map[string]bool{} + for _, e := range gres.Entries { + 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}) + } + } + for i, u := range src.Cache.Users { + for g, m := range grps { + if slices.Contains(m, u.DN) || slices.Contains(m, u.UserName) { + 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), "matchedUsers", strings.Join(userNames(src.Cache.Users), ","), "matchedGroups", strings.Join(groupNames(src.Cache.Groups), ",")) + return src, nil +} diff --git a/core/ldapauth/ldapauth_test.go b/core/ldapauth/ldapauth_test.go new file mode 100644 index 000000000..b6f27d294 --- /dev/null +++ b/core/ldapauth/ldapauth_test.go @@ -0,0 +1,86 @@ +package ldapauth + +import ( + "testing" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" +) + +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"}, "directory-user") + want := "(uid=directory-user)" + if got != want { + t.Fatalf("loginUserFilter() = %q, want %q", got, want) + } + }) + + t.Run("replaces every username placeholder", func(t *testing.T) { + t.Parallel() + got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(|(uid=%s)(mail=%s))"}, "directory-user") + want := "(|(uid=directory-user)(mail=directory-user))" + 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)"}, "directory-user") + want := "(uid=directory-user)" + 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)"}, "directory-user") + want := "(&(objectClass=person)(uid=directory-user))" + 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) + } + } +} + +func TestAuthInternalSkipsExternalUsersForFallback(t *testing.T) { + t.Parallel() + + repo := tests.CreateMockUserRepo() + repo.Data["directory-user"] = &model.User{ID: "1", UserName: "directory-user", Password: "generated", AuthSource: "ldap", AuthSourceID: "freeipa"} + ds := &tests.MockDataStore{MockedUser: repo} + + user, found, err := authInternal(t.Context(), ds, "directory-user", "ldap-password", true) + if err != nil { + t.Fatalf("authInternal() unexpected error: %v", err) + } + if found || user != nil { + t.Fatalf("authInternal() found=%v user=%#v, want external user to be skipped for fallback", found, user) + } + + user, found, err = authInternal(t.Context(), ds, "directory-user", "ldap-password", false) + if err != nil { + t.Fatalf("authInternal() unexpected error: %v", err) + } + if !found || user != nil { + t.Fatalf("authInternal() found=%v user=%#v, want explicit internal auth to stop", found, user) + } +} diff --git a/db/migrations/20260711130000_add_user_auth_source.sql b/db/migrations/20260711130000_add_user_auth_source.sql new file mode 100644 index 000000000..164034f20 --- /dev/null +++ b/db/migrations/20260711130000_add_user_auth_source.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Add LDAP ownership metadata to users. +-- +goose StatementBegin +alter table user add column auth_source varchar(32) default '' not null; +alter table user add column auth_source_id varchar(255) default '' not null; +-- +goose StatementEnd + +-- +goose Down +-- Roll back the LDAP ownership metadata added in the Up migration. +-- +goose StatementBegin +alter table user drop column auth_source; +alter table user drop column auth_source_id; +-- +goose StatementEnd diff --git a/go.mod b/go.mod index 5488b41e4..0e49191c7 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect @@ -83,6 +84,8 @@ require ( github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/ebitengine/purego v0.10.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-ldap/ldap/v3 v3.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect diff --git a/go.sum b/go.sum index 29983a27d..a61975cc9 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= +github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= @@ -71,6 +73,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= @@ -79,6 +83,8 @@ github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= +github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= +github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= diff --git a/model/user.go b/model/user.go index 1c8541ccf..95e30a6d8 100644 --- a/model/user.go +++ b/model/user.go @@ -10,6 +10,8 @@ type User struct { Name string `structs:"name" json:"name"` Email string `structs:"email" json:"email"` IsAdmin bool `structs:"is_admin" json:"isAdmin"` + AuthSource string `structs:"auth_source" json:"authSource,omitempty"` + AuthSourceID string `structs:"auth_source_id" json:"authSourceId,omitempty"` LastLoginAt *time.Time `structs:"last_login_at" json:"lastLoginAt"` LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` @@ -25,6 +27,9 @@ type User struct { NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec // If changing the password, this is also required CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"` + + // ExternalSync allows external auth providers to update read-only identity fields for managed users. + ExternalSync bool `structs:"-" json:"-"` } func (u User) HasLibraryAccess(libraryID int) bool { diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 9decff4e5..e996e7c32 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -116,6 +116,9 @@ func (r *userRepository) Put(u *model.User) error { u.ID = id.NewRandom() } u.UpdatedAt = time.Now() + if err := r.preserveExternalAuthSource(u); err != nil { + return err + } if u.NewPassword != "" { _ = r.encryptPassword(u) } @@ -165,6 +168,46 @@ func (r *userRepository) Put(u *model.User) error { return nil } +func (r *userRepository) preserveExternalAuthSource(u *model.User) error { + if u.ID == "" { + return nil + } + existing, err := r.Get(u.ID) + if errors.Is(err, model.ErrNotFound) { + return nil + } + if err != nil { + return err + } + if existing.AuthSource == "" { + return nil + } + if u.AuthSource == "" { + u.AuthSource = existing.AuthSource + u.AuthSourceID = existing.AuthSourceID + } + if u.ExternalSync { + return nil + } + validation := &rest.ValidationError{Errors: map[string]string{}} + if u.NewPassword != "" { + validation.Errors["password"] = "resources.user.validation.externalFieldReadOnly" + } + if !strings.EqualFold(u.UserName, existing.UserName) { + validation.Errors["userName"] = "resources.user.validation.externalFieldReadOnly" + } + if u.Name != existing.Name { + validation.Errors["name"] = "resources.user.validation.externalFieldReadOnly" + } + if u.Email != existing.Email { + validation.Errors["email"] = "resources.user.validation.externalFieldReadOnly" + } + if len(validation.Errors) > 0 { + return validation + } + return nil +} + func (r *userRepository) FindFirstAdmin() (*model.User, error) { sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) var usr dbUser diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 6f8ab9161..88d4d9127 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -70,6 +70,50 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) + It("does not update managed fields for LDAP-sourced users", func() { + ldapUser := model.User{ + ID: "ldap-user", + UserName: "ldap_user", + Name: "LDAP User", + Email: "ldap@example.com", + NewPassword: "generated", + AuthSource: "ldap", + AuthSourceID: "ldap01", + } + Expect(repo.Put(&ldapUser)).To(Succeed()) + + ldapUser.UserName = "renamed" + ldapUser.Name = "Renamed User" + ldapUser.Email = "renamed@example.com" + ldapUser.NewPassword = "newpass" + err := repo.Put(&ldapUser) + var verr *rest.ValidationError + Expect(errors.As(err, &verr)).To(BeTrue()) + Expect(verr.Errors).To(HaveKeyWithValue("userName", "resources.user.validation.externalFieldReadOnly")) + Expect(verr.Errors).To(HaveKeyWithValue("name", "resources.user.validation.externalFieldReadOnly")) + Expect(verr.Errors).To(HaveKeyWithValue("email", "resources.user.validation.externalFieldReadOnly")) + Expect(verr.Errors).To(HaveKeyWithValue("password", "resources.user.validation.externalFieldReadOnly")) + + actual, err := repo.FindByUsernameWithPassword("ldap_user") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.UserName).To(Equal("ldap_user")) + Expect(actual.Name).To(Equal("LDAP User")) + Expect(actual.Email).To(Equal("ldap@example.com")) + Expect(actual.Password).To(Equal("generated")) + + actual.Name = "Synced User" + actual.Email = "synced@example.com" + actual.AuthSource = "" + actual.AuthSourceID = "" + actual.ExternalSync = true + Expect(repo.Put(actual)).To(Succeed()) + actual, err = repo.FindByUsername("ldap_user") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Name).To(Equal("Synced User")) + Expect(actual.Email).To(Equal("synced@example.com")) + Expect(actual.AuthSource).To(Equal("ldap")) + Expect(actual.AuthSourceID).To(Equal("ldap01")) + }) }) Describe("validatePasswordChange", func() { diff --git a/server/auth.go b/server/auth.go index 6a25f1406..ad8a37743 100644 --- a/server/auth.go +++ b/server/auth.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ldapauth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -35,19 +36,19 @@ var ( func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - username, password, err := getCredentialsFromBody(r) + username, password, authSource, err := getCredentialsFromBody(r) if err != nil { log.Error(r, "Parsing request body", err) _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) return } - doLogin(ds, username, password, w, r) + doLogin(ds, username, password, authSource, w, r) } } -func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) { - user, err := validateLogin(ds.User(r.Context()), username, password) +func doLogin(ds model.DataStore, username string, password string, authSource string, w http.ResponseWriter, r *http.Request) { + user, err := ldapauth.Authenticate(r.Context(), ds, authSource, username, password) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again") return @@ -94,7 +95,7 @@ func buildAuthPayload(user *model.User) map[string]any { return payload } -func getCredentialsFromBody(r *http.Request) (username string, password string, err error) { +func getCredentialsFromBody(r *http.Request) (username string, password string, authSource string, err error) { data := make(map[string]string) decoder := json.NewDecoder(r.Body) if err = decoder.Decode(&data); err != nil { @@ -104,12 +105,13 @@ func getCredentialsFromBody(r *http.Request) (username string, password string, } username = data["username"] password = data["password"] - return username, password, nil + authSource = data["authSource"] + return username, password, authSource, nil } func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - username, password, err := getCredentialsFromBody(r) + username, password, _, err := getCredentialsFromBody(r) if err != nil { log.Error(r, "parsing request body", err) _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) @@ -129,7 +131,7 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) return } - doLogin(ds, username, password, w, r) + doLogin(ds, username, password, "internal", w, r) } } @@ -372,3 +374,9 @@ func validateIPAgainstList(ip string, comaSeparatedList string) bool { return false } + +func authSources() func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + _ = rest.RespondWithJSON(w, http.StatusOK, ldapauth.Sources(r.Context())) + } +} diff --git a/server/nativeapi/ldap.go b/server/nativeapi/ldap.go new file mode 100644 index 000000000..891676aff --- /dev/null +++ b/server/nativeapi/ldap.go @@ -0,0 +1,48 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/ldapauth" +) + +func (api *Router) addLDAPRoute(r chi.Router) { + r.Route("/ldap", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + cfg, err := ldapauth.NewStore().Load(r.Context()) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"id": "ldap", "sources": cfg.Sources}) + }) + r.Put("/", func(w http.ResponseWriter, r *http.Request) { + var cfg ldapauth.Config + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + if err := ldapauth.NewStore().Save(r.Context(), cfg); err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"id": "ldap", "sources": cfg.Sources}) + }) + r.Post("/test", func(w http.ResponseWriter, r *http.Request) { + var src ldapauth.Source + if err := json.NewDecoder(r.Body).Decode(&src); err != nil { + _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + src, err := ldapauth.TestAndCache(r.Context(), src) + if err != nil { + _ = rest.RespondWithError(w, http.StatusBadGateway, err.Error()) + return + } + _ = rest.RespondWithJSON(w, http.StatusOK, src) + }) + }) +} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..df05c062f 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -91,6 +91,7 @@ func (api *Router) routes() http.Handler { api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) + api.addLDAPRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/server.go b/server/server.go index b05c20cc5..24316c754 100644 --- a/server/server.go +++ b/server/server.go @@ -217,6 +217,7 @@ func (s *Server) mountAuthenticationRoutes() chi.Router { r.Post("/login", login(s.ds)) } r.Post("/createAdmin", createAdmin(s.ds)) + r.Get("/sources", authSources()) }) } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 837852d18..cb8f969a1 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ldapauth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -126,21 +127,28 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { salt, _ := p.String("s") jwt, _ := p.String("jwt") - usr, err = ds.User(ctx).FindByUsernameWithPassword(username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - return - } - switch { - case errors.Is(err, model.ErrNotFound): - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - case err != nil: - log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - default: - err = validateCredentials(usr, pass, token, salt, jwt) - if err != nil { - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + if pass != "" && token == "" && jwt == "" { + usr, err = ldapauth.Authenticate(ctx, ds, "", username, pass) + if usr == nil && err == nil { + err = model.ErrInvalidAuth } + } else { + usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + if errors.Is(err, context.Canceled) { + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + return + } + switch { + case errors.Is(err, model.ErrNotFound): + err = model.ErrInvalidAuth + case err != nil: + log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + default: + err = validateCredentials(usr, pass, token, salt, jwt) + } + } + if err != nil { + log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) } } diff --git a/ui/src/App.jsx b/ui/src/App.jsx index d10aa5a33..e210f94cd 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -49,6 +49,8 @@ import SharePlayer from './share/SharePlayer' import { HTML5Backend } from 'react-dnd-html5-backend' import { DndProvider } from 'react-dnd' import missing from './missing/index.js' +import { LdapList } from './ldap' +import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet' import { useEffect } from 'react' const history = createHashHistory() @@ -166,6 +168,15 @@ const Admin = (props) => { options={{ subMenu: 'settings' }} /> ) : null, + permissions === 'admin' ? ( + + ) : null, + permissions === 'admin' && config.pluginsEnabled ? ( { + login: ({ username, password, authSource }) => { let url = baseUrl('/auth/login') if (config.firstTime) { url = baseUrl('/auth/createAdmin') } const request = new Request(url, { method: 'POST', - body: JSON.stringify({ username, password }), + body: JSON.stringify({ username, password, authSource }), headers: new Headers({ 'Content-Type': 'application/json' }), }) return fetch(request) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index c0e226453..6c14527b8 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -167,13 +167,15 @@ "deleted": "User deleted" }, "validation": { - "librariesRequired": "At least one library must be selected for non-admin users" + "librariesRequired": "At least one library must be selected for non-admin users", + "externalFieldReadOnly": "This field is managed by an external authentication source." }, "message": { "listenBrainzToken": "Enter your ListenBrainz user token.", "clickHereForToken": "Click here to get your token", "selectAllLibraries": "Select all libraries", - "adminAutoLibraries": "Admin users automatically have access to all libraries" + "adminAutoLibraries": "Admin users automatically have access to all libraries", + "externalPasswordReadOnly": "Username, display name, email, and password changes are disabled because this user is managed by an external authentication source." } }, "player": { @@ -412,6 +414,10 @@ "configKey": "key", "configValue": "value" } + }, + "ldap": { + "name": "LDAP |||| LDAP", + "fields": {} } }, "ra": { diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx index a7763cff3..e8be65f1e 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react' +import React, { useState, useCallback, useEffect } from 'react' import PropTypes from 'prop-types' import { Field, Form } from 'react-final-form' import { useDispatch } from 'react-redux' @@ -9,6 +9,9 @@ import CircularProgress from '@material-ui/core/CircularProgress' import Link from '@material-ui/core/Link' import TextField from '@material-ui/core/TextField' import { ThemeProvider, makeStyles } from '@material-ui/core/styles' +import Tabs from '@material-ui/core/Tabs' +import Tab from '@material-ui/core/Tab' +import { baseUrl } from '../utils' import { createMuiTheme, useLogin, @@ -110,7 +113,14 @@ const renderInput = ({ /> ) -const FormLogin = ({ loading, handleSubmit, validate }) => { +const FormLogin = ({ + loading, + handleSubmit, + validate, + authSources = [], + authSource = 'internal', + setAuthSource = () => {}, +}) => { const translate = useTranslate() const classes = useStyles() @@ -143,6 +153,24 @@ const FormLogin = ({ loading, handleSubmit, validate }) => { dangerouslySetInnerHTML={{ __html: config.welcomeMessage }} /> )} + {authSources.length > 1 && ( + setAuthSource(value)} + indicatorColor="primary" + textColor="primary" + variant="scrollable" + scrollButtons="auto" + > + {authSources.map((source) => ( + + ))} + + )}
{ const Login = ({ location }) => { const [loading, setLoading] = useState(false) + const [authSources, setAuthSources] = useState([ + { id: 'internal', name: 'Internal' }, + ]) + const [authSource, setAuthSource] = useState('internal') const translate = useTranslate() const notify = useNotify() const login = useLogin() const dispatch = useDispatch() + useEffect(() => { + fetch(baseUrl('/auth/sources')) + .then((response) => (response.ok ? response.json() : [])) + .then((sources) => { + if (sources.length) { + setAuthSources(sources) + setAuthSource(sources[0].id) + } + }) + .catch(() => {}) + }, []) + const handleSubmit = useCallback( (auth) => { setLoading(true) dispatch(clearQueue()) - login(auth, location.state ? location.state.nextPathname : '/').catch( - (error) => { - setLoading(false) - notify( - typeof error === 'string' - ? error - : typeof error === 'undefined' || !error.message - ? 'ra.auth.sign_in_error' - : error.message, - 'warning', - ) - }, - ) + login( + { ...auth, authSource }, + location.state ? location.state.nextPathname : '/', + ).catch((error) => { + setLoading(false) + notify( + typeof error === 'string' + ? error + : typeof error === 'undefined' || !error.message + ? 'ra.auth.sign_in_error' + : error.message, + 'warning', + ) + }) }, - [dispatch, login, notify, setLoading, location], + [authSource, dispatch, login, notify, setLoading, location], ) const validateLogin = useCallback( @@ -390,6 +435,9 @@ const Login = ({ location }) => { handleSubmit={handleSubmit} validate={validateLogin} loading={loading} + authSources={authSources} + authSource={authSource} + setAuthSource={setAuthSource} /> ) } diff --git a/ui/src/ldap/index.jsx b/ui/src/ldap/index.jsx new file mode 100644 index 000000000..9646443ec --- /dev/null +++ b/ui/src/ldap/index.jsx @@ -0,0 +1,541 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { + Button, + Card, + CardActions, + CardContent, + Checkbox, + Chip, + Divider, + FormControl, + FormControlLabel, + InputLabel, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, + MenuItem, + Select, + Step, + StepLabel, + Stepper, + TextField, + Typography, +} from '@material-ui/core' +import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward' +import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward' +import DeleteIcon from '@material-ui/icons/Delete' +import EditIcon from '@material-ui/icons/Edit' +import { useNotify } from 'react-admin' +import httpClient from '../dataProvider/httpClient' +import { REST_URL } from '../consts' + +const emptySource = { + name: '', + enabled: true, + url: 'ldap://ldap.example.org:389', + startTLS: false, + insecureSkipVerify: false, + bindDN: '', + bindPassword: '', + userBaseDN: '', + userFilter: '(%s=%s)', + userNameAttribute: 'uid', + displayNameAttribute: 'cn', + emailAttribute: 'mail', + groupBaseDN: '', + groupFilter: '(|(objectClass=groupOfNames)(objectClass=group))', + groupNameAttribute: 'cn', + groupMemberAttribute: 'member', + requiredGroupDNs: [], + adminGroupDNs: [], + directBindDNTemplate: '', + cache: { users: [], groups: [] }, +} + +const steps = ['Server', 'Bind & filters', 'Fetch users/groups', 'Map access'] + +const groupLabel = (group) => group.name || group.dn + +const uniqueByDN = (groups = []) => { + const seen = new Set() + return groups.filter((group) => { + if (!group.dn || seen.has(group.dn)) { + return false + } + seen.add(group.dn) + return true + }) +} + +const textField = (source, setSource, key, label, props = {}) => ( + setSource({ ...source, [key]: event.target.value })} + fullWidth + margin="normal" + variant="outlined" + {...props} + /> +) + +const groupSelect = (source, setSource, key, label, groups) => ( + + {label} + + +) + +const SourceWizard = ({ initialSource, onCancel, onSave, onTest, testing }) => { + const notify = useNotify() + const [activeStep, setActiveStep] = useState(0) + const [source, setSource] = useState({ ...emptySource, ...initialSource }) + const groups = useMemo(() => uniqueByDN(source.cache?.groups), [source.cache]) + + const validateStep = () => { + if (activeStep === 0 && (!source.name || !source.url)) { + notify('LDAP name and URL are required', 'warning') + return false + } + if (activeStep === 1 && (!source.userBaseDN || !source.userNameAttribute)) { + notify('User base DN and username attribute are required', 'warning') + return false + } + return true + } + + const next = () => { + if (validateStep()) { + setActiveStep(activeStep + 1) + } + } + + const testSource = () => { + onTest(source).then((testedSource) => { + setSource({ ...source, ...testedSource }) + setActiveStep(3) + }) + } + + return ( + + + + {source.id ? 'Edit LDAP Server' : 'Add LDAP Server'} + + + {steps.map((label) => ( + + {label} + + ))} + + + {activeStep === 0 && ( + <> + {textField(source, setSource, 'name', 'Display name')} + {textField(source, setSource, 'url', 'LDAP URL', { + helperText: + 'Example: ldap://ldap.example.org:389 or ldaps://ldap.example.org:636', + })} + + setSource({ ...source, enabled: event.target.checked }) + } + /> + } + label="Enabled" + /> + + setSource({ ...source, startTLS: event.target.checked }) + } + /> + } + label="Use StartTLS" + /> + + setSource({ + ...source, + insecureSkipVerify: event.target.checked, + }) + } + /> + } + label="Skip TLS certificate verification" + /> + + )} + + {activeStep === 1 && ( + <> + Service account bind + {textField(source, setSource, 'bindDN', 'Bind DN', { + helperText: + 'Leave blank for anonymous bind if your LDAP server allows it.', + })} + {textField(source, setSource, 'bindPassword', 'Bind password', { + type: 'password', + })} + {textField( + source, + setSource, + 'directBindDNTemplate', + 'Direct user bind DN template', + { + helperText: + 'Optional. Example: uid=%s,ou=users,dc=example,dc=org. Service-account search bind is preferred.', + }, + )} + + Users + {textField(source, setSource, 'userBaseDN', 'User base DN')} + {textField(source, setSource, 'userFilter', 'User filter', { + helperText: + 'Use %s placeholders for attribute and escaped username, e.g. (%s=%s).', + })} + {textField( + source, + setSource, + 'userNameAttribute', + 'Username attribute', + )} + {textField( + source, + setSource, + 'displayNameAttribute', + 'Display name attribute', + )} + {textField(source, setSource, 'emailAttribute', 'Email attribute')} + + Groups + {textField(source, setSource, 'groupBaseDN', 'Group base DN')} + {textField(source, setSource, 'groupFilter', 'Group filter')} + {textField( + source, + setSource, + 'groupNameAttribute', + 'Group name attribute', + )} + {textField( + source, + setSource, + 'groupMemberAttribute', + 'Group member attribute', + { + helperText: + 'Use member for OpenLDAP groupOfNames. FreeIPA memberOf is collected from user entries automatically.', + }, + )} + + )} + + {activeStep === 2 && ( + <> + + Test the LDAP connection and service-account bind, then fetch + users, groups, and memberships for interactive mapping. + + + + Cached users: {source.cache?.users?.length || 0} · Cached groups:{' '} + {source.cache?.groups?.length || 0} + + {!!source.cache?.users?.length && ( + + Matched users:{' '} + {source.cache.users + .slice(0, 10) + .map((user) => user.userName || user.dn) + .join(', ')} + {source.cache.users.length > 10 ? '…' : ''} + + )} + {!!source.cache?.groups?.length && ( + + Matched groups:{' '} + {source.cache.groups + .slice(0, 10) + .map((group) => group.name || group.dn) + .join(', ')} + {source.cache.groups.length > 10 ? '…' : ''} + + )} + + )} + + {activeStep === 3 && ( + <> + + Select the groups that are allowed to log in. Admin groups also + grant Navidrome administrator access. + + {groups.length === 0 ? ( + + No groups have been discovered yet. Go back and fetch directory + data before mapping groups. + + ) : ( + <> + {groupSelect( + source, + setSource, + 'requiredGroupDNs', + 'Allowed login groups', + groups, + )} + {groupSelect( + source, + setSource, + 'adminGroupDNs', + 'Admin groups', + groups, + )} + + )} + + Preview: {source.cache?.users?.length || 0} users and{' '} + {source.cache?.groups?.length || 0} groups cached for this source. + + + )} + + + + {activeStep > 0 && ( + + )} + {activeStep < steps.length - 1 ? ( + + ) : ( + + )} + + + ) +} + +export const LdapList = () => { + const notify = useNotify() + const [sources, setSources] = useState([]) + const [editingIndex, setEditingIndex] = useState(null) + const [loading, setLoading] = useState(false) + const [testing, setTesting] = useState(false) + + const load = () => { + setLoading(true) + httpClient(`${REST_URL}/ldap`) + .then(({ json }) => setSources(json.sources || [])) + .catch(() => notify('Could not load LDAP configuration', 'warning')) + .finally(() => setLoading(false)) + } + + useEffect(load, [notify]) + + const saveSources = (nextSources) => { + setLoading(true) + return httpClient(`${REST_URL}/ldap`, { + method: 'PUT', + body: JSON.stringify({ sources: nextSources }), + }) + .then(({ json }) => { + setSources(json.sources || nextSources) + notify('LDAP configuration saved') + }) + .catch((e) => { + notify(`Could not save LDAP configuration: ${e.message}`, 'warning') + throw e + }) + .finally(() => setLoading(false)) + } + + const saveSource = (source) => { + const nextSources = [...sources] + if (editingIndex === 'new') { + nextSources.push(source) + } else { + nextSources[editingIndex] = source + } + saveSources(nextSources).then(() => setEditingIndex(null)) + } + + const moveSource = (index, direction) => { + const target = index + direction + if (target < 0 || target >= sources.length) { + return + } + const nextSources = [...sources] + const movedSource = nextSources[index] + nextSources[index] = nextSources[target] + nextSources[target] = movedSource + saveSources(nextSources) + } + + const deleteSource = (index) => { + const nextSources = sources.filter( + (_, sourceIndex) => sourceIndex !== index, + ) + saveSources(nextSources) + } + + const testSource = (source) => { + setTesting(true) + return httpClient(`${REST_URL}/ldap/test`, { + method: 'POST', + body: JSON.stringify(source), + }) + .then(({ json }) => { + notify( + `LDAP test found ${json.cache?.users?.length || 0} users and ${json.cache?.groups?.length || 0} groups`, + ) + return json + }) + .catch((e) => { + notify(`LDAP test failed: ${e.message}`, 'warning') + throw e + }) + .finally(() => setTesting(false)) + } + + if (editingIndex !== null) { + return ( + setEditingIndex(null)} + onSave={saveSource} + onTest={testSource} + testing={testing} + /> + ) + } + + return ( + + + + LDAP Authentication + + + LDAP sources are tried in the order shown below after internal auth + for external clients. Use the arrow buttons to change fallback + priority. + + {sources.length === 0 ? ( + + No LDAP servers configured yet. + + ) : ( + + {sources.map((source, index) => ( + + + + + + + + + + ))} + + )} + + + + + + ) +} diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index d8302a9f9..92fb56aae 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -48,14 +48,16 @@ const UserToolbar = ({ showDelete, ...props }) => ( const CurrentPasswordInput = ({ formData, isMyself, ...rest }) => { const { permissions } = usePermissions() - return formData.changePassword && (isMyself || permissions !== 'admin') ? ( + return formData.changePassword && + !formData.authSource && + (isMyself || permissions !== 'admin') ? ( ) : null } const NewPasswordInput = ({ formData, ...rest }) => { const translate = useTranslate() - return formData.changePassword ? ( + return formData.changePassword && !formData.authSource ? ( { validate={validateForm} > {permissions === 'admin' && ( - + + {({ formData }) => ( + + )} + )} - - - + + {({ formData }) => ( + + )} + + + {({ formData }) => ( + + )} + + + {({ formData }) => + formData.authSource ? ( + + {translate('resources.user.message.externalPasswordReadOnly')} + + ) : ( + + ) + } + {(formDataProps) => (