From 181b59528b03427e055a12bb70acfea916daa62d Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sat, 11 Jul 2026 14:18:10 +0930 Subject: [PATCH 01/10] Add LDAP authentication support --- core/ldapauth/ldapauth.go | 386 +++++++++++++++++++++++++++++++++ go.mod | 3 + go.sum | 6 + server/auth.go | 24 +- server/nativeapi/ldap.go | 48 ++++ server/nativeapi/native_api.go | 1 + server/server.go | 1 + server/subsonic/middlewares.go | 7 +- ui/src/App.jsx | 11 + ui/src/authProvider.js | 4 +- ui/src/i18n/en.json | 4 + ui/src/layout/Login.jsx | 77 +++++-- ui/src/ldap/index.jsx | 102 +++++++++ 13 files changed, 647 insertions(+), 27 deletions(-) create mode 100644 core/ldapauth/ldapauth.go create mode 100644 server/nativeapi/ldap.go create mode 100644 ui/src/ldap/index.jsx diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go new file mode 100644 index 000000000..6c6087303 --- /dev/null +++ b/core/ldapauth/ldapauth.go @@ -0,0 +1,386 @@ +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 (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 != "" { + if p, e := utils.Decrypt(ctx, s.key, c.Sources[i].BindPassword); e == nil { + c.Sources[i].BindPassword = p + } + } + } + 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() + } + 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" + } + 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) + 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) { + continue + } + if err != nil { + return nil, nil + } + return u, nil + } + return nil, nil +} +func authInternal(ctx context.Context, ds model.DataStore, username, password string) (*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 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) { + 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) + 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{}, ErrUserNotFound + } + defer l.Close() + if src.StartTLS { + _ = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}) //nolint:gosec + } + 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 := src.UserFilter + if filt == "" { + filt = "(%s=%s)" + } + filt = fmt.Sprintf(filt, src.UserNameAttribute, ldap.EscapeFilter(username)) + 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)...) + return du, nil +} +func groupsForUser(src Source, userDN string) []string { + if src.GroupBaseDN == "" { + return nil + } + l, err := ldap.DialURL(src.URL) + if err != nil { + return nil + } + defer l.Close() + if src.StartTLS { + _ = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}) //nolint:gosec + } + if src.BindDN != "" { + _ = l.Bind(src.BindDN, src.BindPassword) + } + 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 { + if slices.Contains(g.GetAttributeValues(src.GroupMemberAttribute), userDN) { + 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 TestAndCache(ctx context.Context, src Source) (Source, error) { + 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 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, _ := l.Search(ur) + 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")}) + } + 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, _ := l.Search(gr) + for _, e := range gres.Entries { + m := 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) { + src.Cache.Users[i].Groups = append(src.Cache.Users[i].Groups, g) + } + } + } + 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)) + return src, nil +} diff --git a/go.mod b/go.mod index 71aabdcd7..a60afb76a 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 ec532b0a0..3de4b0660 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= @@ -73,6 +75,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= @@ -81,6 +85,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/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 669c4d7b5..4859d5160 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -90,6 +90,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..1d21fdbcc 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" @@ -133,7 +134,11 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } switch { case errors.Is(err, model.ErrNotFound): - 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) + } else { + err = model.ErrInvalidAuth + } case err != nil: log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) default: 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 74fb23ab9..4c5e57cb7 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -411,6 +411,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..938531f14 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, + 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( diff --git a/ui/src/ldap/index.jsx b/ui/src/ldap/index.jsx new file mode 100644 index 000000000..be7d8e236 --- /dev/null +++ b/ui/src/ldap/index.jsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react' +import { + Button, + Card, + CardContent, + TextField, + Typography, +} from '@material-ui/core' +import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet' +import { useNotify } from 'react-admin' +import httpClient from '../dataProvider/httpClient' +import { REST_URL } from '../consts' + +const defaultConfig = { sources: [] } + +export const LdapList = () => { + const notify = useNotify() + const [text, setText] = useState(JSON.stringify(defaultConfig, null, 2)) + const [loading, setLoading] = useState(false) + + useEffect(() => { + httpClient(`${REST_URL}/ldap`) + .then(({ json }) => + setText(JSON.stringify({ sources: json.sources || [] }, null, 2)), + ) + .catch(() => notify('Could not load LDAP configuration', 'warning')) + }, [notify]) + + const save = () => { + setLoading(true) + httpClient(`${REST_URL}/ldap`, { method: 'PUT', body: text }) + .then(({ json }) => { + setText(JSON.stringify({ sources: json.sources || [] }, null, 2)) + notify('LDAP configuration saved') + }) + .catch((e) => + notify(`Could not save LDAP configuration: ${e.message}`, 'warning'), + ) + .finally(() => setLoading(false)) + } + + const test = () => { + const cfg = JSON.parse(text) + const source = cfg.sources?.[0] + if (!source) { + notify('Add at least one LDAP source to test', 'warning') + return + } + setLoading(true) + 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`, + ), + ) + .catch((e) => notify(`LDAP test failed: ${e.message}`, 'warning')) + .finally(() => setLoading(false)) + } + + return ( + + + + LDAP Authentication + + + Configure LDAP sources as JSON. Sources are evaluated after internal + auth for external clients; the login page shows enabled sources as + tabs. + + setText(e.target.value)} + /> + + + + + ) +} From 95e83659df278c80189adcf45743292de7428bd7 Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sat, 11 Jul 2026 17:19:41 +0930 Subject: [PATCH 02/10] Add LDAP demo docker compose --- .../docker-compose/docker-compose-ldap.yml | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 contrib/docker-compose/docker-compose-ldap.yml diff --git a/contrib/docker-compose/docker-compose-ldap.yml b/contrib/docker-compose/docker-compose-ldap.yml new file mode 100644 index 000000000..069da4f2a --- /dev/null +++ b/contrib/docker-compose/docker-compose-ldap.yml @@ -0,0 +1,64 @@ +version: '3.6' + +# Standalone demo stack for trying Navidrome with LDAP authentication. +# Start it from this directory with: +# docker compose -f docker-compose-ldap.yml up -d +# +# After creating the first local Navidrome admin at http://localhost:4533, +# open Settings -> LDAP and add a source similar to: +# { +# "sources": [{ +# "name": "Demo LDAP", +# "enabled": true, +# "url": "ldap://openldap:1389", +# "bindDN": "cn=admin,dc=example,dc=org", +# "bindPassword": "adminpassword", +# "userBaseDN": "ou=users,dc=example,dc=org", +# "userFilter": "(%s=%s)", +# "userNameAttribute": "uid", +# "displayNameAttribute": "cn", +# "emailAttribute": "mail", +# "groupBaseDN": "ou=groups,dc=example,dc=org", +# "groupFilter": "(objectClass=groupOfNames)", +# "groupNameAttribute": "cn", +# "groupMemberAttribute": "member", +# "adminGroupDNs": ["cn=admins,ou=groups,dc=example,dc=org"] +# }] +# } + +volumes: + navidrome_data: + openldap_data: + +services: + navidrome: + container_name: "navidrome-ldap-demo" + image: deluan/navidrome:latest + restart: unless-stopped + read_only: true + ports: + - "4533:4533" + volumes: + - "navidrome_data:/data" + # Bind your local music folder here when testing playback: + # - "/mnt/music:/music:ro" + depends_on: + - openldap + + openldap: + container_name: "navidrome-openldap-demo" + image: bitnami/openldap:2.6 + restart: unless-stopped + environment: + LDAP_ROOT: "dc=example,dc=org" + LDAP_ADMIN_USERNAME: "admin" + LDAP_ADMIN_PASSWORD: "adminpassword" + LDAP_USERS: "alice,bob" + LDAP_PASSWORDS: "alicepassword,bobpassword" + LDAP_GROUP: "users" + ports: + # Exposed only for local ldapsearch/debugging; Navidrome uses the service + # name openldap:1389 on the compose network. + - "1389:1389" + volumes: + - "openldap_data:/bitnami/openldap" From 64d517232e0c01a347c5c487141d07774abea3eb Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sat, 11 Jul 2026 17:20:00 +0930 Subject: [PATCH 03/10] Improve LDAP source configuration UI --- .../docker-compose/docker-compose-ldap.yml | 64 --- ui/src/ldap/index.jsx | 531 ++++++++++++++++-- 2 files changed, 475 insertions(+), 120 deletions(-) delete mode 100644 contrib/docker-compose/docker-compose-ldap.yml diff --git a/contrib/docker-compose/docker-compose-ldap.yml b/contrib/docker-compose/docker-compose-ldap.yml deleted file mode 100644 index 069da4f2a..000000000 --- a/contrib/docker-compose/docker-compose-ldap.yml +++ /dev/null @@ -1,64 +0,0 @@ -version: '3.6' - -# Standalone demo stack for trying Navidrome with LDAP authentication. -# Start it from this directory with: -# docker compose -f docker-compose-ldap.yml up -d -# -# After creating the first local Navidrome admin at http://localhost:4533, -# open Settings -> LDAP and add a source similar to: -# { -# "sources": [{ -# "name": "Demo LDAP", -# "enabled": true, -# "url": "ldap://openldap:1389", -# "bindDN": "cn=admin,dc=example,dc=org", -# "bindPassword": "adminpassword", -# "userBaseDN": "ou=users,dc=example,dc=org", -# "userFilter": "(%s=%s)", -# "userNameAttribute": "uid", -# "displayNameAttribute": "cn", -# "emailAttribute": "mail", -# "groupBaseDN": "ou=groups,dc=example,dc=org", -# "groupFilter": "(objectClass=groupOfNames)", -# "groupNameAttribute": "cn", -# "groupMemberAttribute": "member", -# "adminGroupDNs": ["cn=admins,ou=groups,dc=example,dc=org"] -# }] -# } - -volumes: - navidrome_data: - openldap_data: - -services: - navidrome: - container_name: "navidrome-ldap-demo" - image: deluan/navidrome:latest - restart: unless-stopped - read_only: true - ports: - - "4533:4533" - volumes: - - "navidrome_data:/data" - # Bind your local music folder here when testing playback: - # - "/mnt/music:/music:ro" - depends_on: - - openldap - - openldap: - container_name: "navidrome-openldap-demo" - image: bitnami/openldap:2.6 - restart: unless-stopped - environment: - LDAP_ROOT: "dc=example,dc=org" - LDAP_ADMIN_USERNAME: "admin" - LDAP_ADMIN_PASSWORD: "adminpassword" - LDAP_USERS: "alice,bob" - LDAP_PASSWORDS: "alicepassword,bobpassword" - LDAP_GROUP: "users" - ports: - # Exposed only for local ldapsearch/debugging; Navidrome uses the service - # name openldap:1389 on the compose network. - - "1389:1389" - volumes: - - "openldap_data:/bitnami/openldap" diff --git a/ui/src/ldap/index.jsx b/ui/src/ldap/index.jsx index be7d8e236..2b1432a79 100644 --- a/ui/src/ldap/index.jsx +++ b/ui/src/ldap/index.jsx @@ -1,63 +1,447 @@ -import React, { useEffect, useState } from 'react' +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 SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet' +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 defaultConfig = { sources: [] } +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} + + + )} + + {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 [text, setText] = useState(JSON.stringify(defaultConfig, null, 2)) + const [sources, setSources] = useState([]) + const [editingIndex, setEditingIndex] = useState(null) const [loading, setLoading] = useState(false) + const [testing, setTesting] = useState(false) - useEffect(() => { - httpClient(`${REST_URL}/ldap`) - .then(({ json }) => - setText(JSON.stringify({ sources: json.sources || [] }, null, 2)), - ) - .catch(() => notify('Could not load LDAP configuration', 'warning')) - }, [notify]) - - const save = () => { + const load = () => { setLoading(true) - httpClient(`${REST_URL}/ldap`, { method: 'PUT', body: text }) - .then(({ json }) => { - setText(JSON.stringify({ sources: json.sources || [] }, null, 2)) - notify('LDAP configuration saved') - }) - .catch((e) => - notify(`Could not save LDAP configuration: ${e.message}`, 'warning'), - ) + httpClient(`${REST_URL}/ldap`) + .then(({ json }) => setSources(json.sources || [])) + .catch(() => notify('Could not load LDAP configuration', 'warning')) .finally(() => setLoading(false)) } - const test = () => { - const cfg = JSON.parse(text) - const source = cfg.sources?.[0] - if (!source) { - notify('Add at least one LDAP source to test', 'warning') + 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 } - setLoading(true) - httpClient(`${REST_URL}/ldap/test`, { + 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 }) => + .then(({ json }) => { notify( `LDAP test found ${json.cache?.users?.length || 0} users and ${json.cache?.groups?.length || 0} groups`, - ), - ) - .catch((e) => notify(`LDAP test failed: ${e.message}`, 'warning')) - .finally(() => setLoading(false)) + ) + 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 ( @@ -67,36 +451,71 @@ export const LdapList = () => { LDAP Authentication - Configure LDAP sources as JSON. Sources are evaluated after internal - auth for external clients; the login page shows enabled sources as - tabs. + LDAP sources are tried in the order shown below after internal auth + for external clients. Use the arrow buttons to change fallback + priority. - setText(e.target.value)} - /> + {sources.length === 0 ? ( + + No LDAP servers configured yet. + + ) : ( + + {sources.map((source, index) => ( + + + + + + + + + + ))} + + )} + + - - + ) } From e8cd4ed04ef9a00b84a7e86d98b59724d741c7f5 Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sat, 11 Jul 2026 20:37:33 +0930 Subject: [PATCH 04/10] Fix LDAP login auth source tabs rendering --- ui/src/layout/Login.jsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx index 938531f14..e8be65f1e 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -117,9 +117,9 @@ const FormLogin = ({ loading, handleSubmit, validate, - authSources, - authSource, - setAuthSource, + authSources = [], + authSource = 'internal', + setAuthSource = () => {}, }) => { const translate = useTranslate() const classes = useStyles() @@ -435,6 +435,9 @@ const Login = ({ location }) => { handleSubmit={handleSubmit} validate={validateLogin} loading={loading} + authSources={authSources} + authSource={authSource} + setAuthSource={setAuthSource} /> ) } From dd1fc437621a7272393ede6a523521df73a5e661 Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sat, 11 Jul 2026 22:21:50 +0930 Subject: [PATCH 05/10] Fix LDAP login lookup and cache probing --- core/ldapauth/ldapauth.go | 121 +++++++++++++++++++++++++-------- core/ldapauth/ldapauth_test.go | 57 ++++++++++++++++ ui/src/ldap/index.jsx | 20 ++++++ 3 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 core/ldapauth/ldapauth_test.go diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go index 6c6087303..e5a4dd09b 100644 --- a/core/ldapauth/ldapauth.go +++ b/core/ldapauth/ldapauth.go @@ -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 } diff --git a/core/ldapauth/ldapauth_test.go b/core/ldapauth/ldapauth_test.go new file mode 100644 index 000000000..d1d10d615 --- /dev/null +++ b/core/ldapauth/ldapauth_test.go @@ -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) + } + } +} diff --git a/ui/src/ldap/index.jsx b/ui/src/ldap/index.jsx index 2b1432a79..9646443ec 100644 --- a/ui/src/ldap/index.jsx +++ b/ui/src/ldap/index.jsx @@ -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} + {!!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 ? '…' : ''} + + )} )} From 3c337767f1141d3afb84ff6ba6cdc0227410207d Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sun, 12 Jul 2026 01:59:54 +0930 Subject: [PATCH 06/10] Protect LDAP-managed user credentials --- core/ldapauth/ldapauth.go | 2 ++ .../20260711130000_add_user_auth_source.sql | 11 ++++++++ model/user.go | 2 ++ persistence/user_repository.go | 27 +++++++++++++++++++ persistence/user_repository_test.go | 21 +++++++++++++++ ui/src/i18n/en.json | 6 +++-- ui/src/user/UserEdit.jsx | 22 ++++++++++++--- 7 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 db/migrations/20260711130000_add_user_auth_source.sql diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go index e5a4dd09b..2be1831ad 100644 --- a/core/ldapauth/ldapauth.go +++ b/core/ldapauth/ldapauth.go @@ -242,6 +242,8 @@ func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, pas 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) if err := repo.Put(u); err != nil { return nil, err } 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..13f2b730a --- /dev/null +++ b/db/migrations/20260711130000_add_user_auth_source.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +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 +-- +goose StatementBegin +alter table user drop column auth_source; +alter table user drop column auth_source_id; +-- +goose StatementEnd diff --git a/model/user.go b/model/user.go index 1c8541ccf..71a4caf9e 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"` diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 9decff4e5..37f7bf448 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,30 @@ 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.NewPassword != "" { + return &rest.ValidationError{Errors: map[string]string{"password": "resources.user.validation.externalPasswordReadOnly"}} + } + if u.AuthSource == "" { + u.AuthSource = existing.AuthSource + u.AuthSourceID = existing.AuthSourceID + } + 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..69ca64abf 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -70,6 +70,27 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) + It("does not update password for LDAP-sourced users", func() { + ldapUser := model.User{ + ID: "ldap-user", + UserName: "ldap_user", + Name: "LDAP User", + NewPassword: "generated", + AuthSource: "ldap", + AuthSourceID: "ldap01", + } + Expect(repo.Put(&ldapUser)).To(Succeed()) + + ldapUser.NewPassword = "newpass" + err := repo.Put(&ldapUser) + var verr *rest.ValidationError + Expect(errors.As(err, &verr)).To(BeTrue()) + Expect(verr.Errors).To(HaveKeyWithValue("password", "resources.user.validation.externalPasswordReadOnly")) + + actual, err := repo.FindByUsernameWithPassword("ldap_user") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Password).To(Equal("generated")) + }) }) Describe("validatePasswordChange", func() { diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 4c5e57cb7..25852c589 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", + "externalPasswordReadOnly": "Password cannot be changed for users 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": "Password changes are disabled because this user is managed by an external authentication source." } }, "player": { diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index d8302a9f9..aeabd1f0e 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 ? ( { {...getNameHelperText()} /> - + + {({ formData }) => + formData.authSource ? ( + + {translate('resources.user.message.externalPasswordReadOnly')} + + ) : ( + + ) + } + {(formDataProps) => ( Date: Sun, 12 Jul 2026 02:26:53 +0930 Subject: [PATCH 07/10] Protect LDAP-managed identity fields --- core/ldapauth/ldapauth.go | 1 + model/user.go | 3 +++ persistence/user_repository.go | 18 ++++++++++++- persistence/user_repository_test.go | 23 ++++++++++++++-- ui/src/i18n/en.json | 4 +-- ui/src/user/UserEdit.jsx | 41 +++++++++++++++++++++-------- 6 files changed, 74 insertions(+), 16 deletions(-) diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go index 2be1831ad..33f79f90e 100644 --- a/core/ldapauth/ldapauth.go +++ b/core/ldapauth/ldapauth.go @@ -244,6 +244,7 @@ func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, pas 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 } diff --git a/model/user.go b/model/user.go index 71a4caf9e..95e30a6d8 100644 --- a/model/user.go +++ b/model/user.go @@ -27,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 37f7bf448..abf762f95 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -182,8 +182,24 @@ func (r *userRepository) preserveExternalAuthSource(u *model.User) error { if existing.AuthSource == "" { return nil } + if u.ExternalSync { + return nil + } + validation := &rest.ValidationError{Errors: map[string]string{}} if u.NewPassword != "" { - return &rest.ValidationError{Errors: map[string]string{"password": "resources.user.validation.externalPasswordReadOnly"}} + 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 } if u.AuthSource == "" { u.AuthSource = existing.AuthSource diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 69ca64abf..e30b92e5c 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -70,26 +70,45 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) - It("does not update password for LDAP-sourced users", func() { + 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("password", "resources.user.validation.externalPasswordReadOnly")) + 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.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")) }) }) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 25852c589..36dfa7343 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -168,14 +168,14 @@ }, "validation": { "librariesRequired": "At least one library must be selected for non-admin users", - "externalPasswordReadOnly": "Password cannot be changed for users managed by an external authentication source." + "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", - "externalPasswordReadOnly": "Password changes are disabled because this user is managed by an external authentication source." + "externalPasswordReadOnly": "Username, display name, email, and password changes are disabled because this user is managed by an external authentication source." } }, "player": { diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index aeabd1f0e..92fb56aae 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -121,18 +121,37 @@ const UserEdit = (props) => { validate={validateForm} > {permissions === 'admin' && ( - + + {({ formData }) => ( + + )} + )} - - + + {({ formData }) => ( + + )} + + + {({ formData }) => ( + + )} + {({ formData }) => formData.authSource ? ( From a33e3c7f9b4485d89f7c47e4652b851443c41f32 Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sun, 12 Jul 2026 03:25:32 +0930 Subject: [PATCH 08/10] Allow LDAP fallback for managed users --- core/ldapauth/ldapauth.go | 8 ++++++-- core/ldapauth/ldapauth_test.go | 31 ++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go index 33f79f90e..9af22d4a5 100644 --- a/core/ldapauth/ldapauth.go +++ b/core/ldapauth/ldapauth.go @@ -176,7 +176,7 @@ func Sources(ctx context.Context) []AuthSource { 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) + u, found, err := authInternal(ctx, ds, username, password, sourceID == "") if err != nil || found { return u, err } @@ -205,7 +205,7 @@ func Authenticate(ctx context.Context, ds model.DataStore, sourceID, username, p } return nil, nil } -func authInternal(ctx context.Context, ds model.DataStore, username, password string) (*model.User, bool, error) { +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 @@ -213,6 +213,10 @@ func authInternal(ctx context.Context, ds model.DataStore, username, password st 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 } diff --git a/core/ldapauth/ldapauth_test.go b/core/ldapauth/ldapauth_test.go index d1d10d615..da831ff1c 100644 --- a/core/ldapauth/ldapauth_test.go +++ b/core/ldapauth/ldapauth_test.go @@ -1,6 +1,11 @@ package ldapauth -import "testing" +import ( + "testing" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" +) func TestLoginUserFilter(t *testing.T) { t.Parallel() @@ -55,3 +60,27 @@ func TestDedupeStrings(t *testing.T) { } } } + +func TestAuthInternalSkipsExternalUsersForFallback(t *testing.T) { + t.Parallel() + + repo := tests.CreateMockUserRepo() + repo.Data["firehawk"] = &model.User{ID: "1", UserName: "firehawk", Password: "generated", AuthSource: "ldap", AuthSourceID: "freeipa"} + ds := &tests.MockDataStore{MockedUser: repo} + + user, found, err := authInternal(t.Context(), ds, "firehawk", "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, "firehawk", "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) + } +} From 8963eeb95945bc7b519db2ee3421e60ab61ab425 Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sun, 12 Jul 2026 04:26:51 +0930 Subject: [PATCH 09/10] Fix LDAP auth review feedback --- core/ldapauth/ldapauth_test.go | 22 ++++++------ .../20260711130000_add_user_auth_source.sql | 2 ++ server/subsonic/middlewares.go | 35 ++++++++++--------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/core/ldapauth/ldapauth_test.go b/core/ldapauth/ldapauth_test.go index da831ff1c..76ef52e74 100644 --- a/core/ldapauth/ldapauth_test.go +++ b/core/ldapauth/ldapauth_test.go @@ -12,8 +12,8 @@ func TestLoginUserFilter(t *testing.T) { t.Run("defaults to username attribute equality", func(t *testing.T) { t.Parallel() - got := loginUserFilter(Source{UserNameAttribute: "uid"}, "firehawk") - want := "(uid=firehawk)" + got := loginUserFilter(Source{UserNameAttribute: "uid"}, "directory-user") + want := "(uid=directory-user)" if got != want { t.Fatalf("loginUserFilter() = %q, want %q", got, want) } @@ -21,8 +21,8 @@ func TestLoginUserFilter(t *testing.T) { 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))" + got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(&(objectClass=person)(%s=%s))"}, "directory-user") + want := "(&(objectClass=person)(uid=directory-user))" if got != want { t.Fatalf("loginUserFilter() = %q, want %q", got, want) } @@ -30,8 +30,8 @@ func TestLoginUserFilter(t *testing.T) { 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)" + 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) } @@ -39,8 +39,8 @@ func TestLoginUserFilter(t *testing.T) { 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))" + 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) } @@ -65,10 +65,10 @@ func TestAuthInternalSkipsExternalUsersForFallback(t *testing.T) { t.Parallel() repo := tests.CreateMockUserRepo() - repo.Data["firehawk"] = &model.User{ID: "1", UserName: "firehawk", Password: "generated", AuthSource: "ldap", AuthSourceID: "freeipa"} + 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, "firehawk", "ldap-password", true) + user, found, err := authInternal(t.Context(), ds, "directory-user", "ldap-password", true) if err != nil { t.Fatalf("authInternal() unexpected error: %v", err) } @@ -76,7 +76,7 @@ func TestAuthInternalSkipsExternalUsersForFallback(t *testing.T) { t.Fatalf("authInternal() found=%v user=%#v, want external user to be skipped for fallback", found, user) } - user, found, err = authInternal(t.Context(), ds, "firehawk", "ldap-password", false) + user, found, err = authInternal(t.Context(), ds, "directory-user", "ldap-password", false) if err != nil { t.Fatalf("authInternal() unexpected error: %v", err) } diff --git a/db/migrations/20260711130000_add_user_auth_source.sql b/db/migrations/20260711130000_add_user_auth_source.sql index 13f2b730a..164034f20 100644 --- a/db/migrations/20260711130000_add_user_auth_source.sql +++ b/db/migrations/20260711130000_add_user_auth_source.sql @@ -1,10 +1,12 @@ -- +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; diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 1d21fdbcc..cb8f969a1 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -127,25 +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): - if pass != "" && token == "" && jwt == "" { - usr, err = ldapauth.Authenticate(ctx, ds, "", username, pass) - } else { + if pass != "" && token == "" && jwt == "" { + usr, err = ldapauth.Authenticate(ctx, ds, "", username, pass) + if usr == nil && err == nil { 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) + } 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) } } From 892ba9ba92cc8d0c7b18b127e975816b2c1c6f79 Mon Sep 17 00:00:00 2001 From: Firehawk Date: Mon, 13 Jul 2026 09:28:41 +0930 Subject: [PATCH 10/10] Fix LDAP review feedback --- core/ldapauth/ldapauth.go | 32 ++++++++++++++++------------- core/ldapauth/ldapauth_test.go | 6 +++--- persistence/user_repository.go | 8 ++++---- persistence/user_repository_test.go | 4 ++++ 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go index 9af22d4a5..030dff047 100644 --- a/core/ldapauth/ldapauth.go +++ b/core/ldapauth/ldapauth.go @@ -124,9 +124,11 @@ func (s *Store) Load(ctx context.Context) (Config, error) { } for i := range c.Sources { if c.Sources[i].BindPassword != "" { - if p, e := utils.Decrypt(ctx, s.key, c.Sources[i].BindPassword); e == nil { - c.Sources[i].BindPassword = p + 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]) } @@ -258,7 +260,7 @@ func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, pas func lookupAndBind(src Source, username, password string) (DiscoveredUser, error) { l, err := ldap.DialURL(src.URL) if err != nil { - return DiscoveredUser{}, ErrUserNotFound + return DiscoveredUser{}, err } defer l.Close() if src.StartTLS { @@ -290,10 +292,10 @@ func lookupAndBind(src Source, username, password string) (DiscoveredUser, error 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.Groups = append(du.Groups, groupsForUser(src, e.DN, du.UserName)...) return du, nil } -func groupsForUser(src Source, userDN string) []string { +func groupsForUser(src Source, userDN, username string) []string { if src.GroupBaseDN == "" { return nil } @@ -303,10 +305,14 @@ func groupsForUser(src Source, userDN string) []string { } 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 nil + } } if src.BindDN != "" { - _ = l.Bind(src.BindDN, src.BindPassword) + if err = l.Bind(src.BindDN, src.BindPassword); err != nil { + return nil + } } gf := src.GroupFilter if gf == "" { @@ -319,7 +325,8 @@ func groupsForUser(src Source, userDN string) []string { } var out []string for _, g := range res.Entries { - if slices.Contains(g.GetAttributeValues(src.GroupMemberAttribute), userDN) { + members := g.GetAttributeValues(src.GroupMemberAttribute) + if slices.Contains(members, userDN) || slices.Contains(members, username) { out = append(out, g.DN) } } @@ -353,11 +360,8 @@ func loginUserFilter(src Source, username string) string { 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) + if strings.Contains(src.UserFilter, "%s") { + return strings.ReplaceAll(src.UserFilter, "%s", escapedUsername) } return fmt.Sprintf("(&%s(%s=%s))", src.UserFilter, attr, escapedUsername) } @@ -447,7 +451,7 @@ func TestAndCache(ctx context.Context, src Source) (Source, error) { } for i, u := range src.Cache.Users { for g, m := range grps { - if slices.Contains(m, u.DN) { + if slices.Contains(m, u.DN) || slices.Contains(m, u.UserName) { src.Cache.Users[i].Groups = append(src.Cache.Users[i].Groups, g) } } diff --git a/core/ldapauth/ldapauth_test.go b/core/ldapauth/ldapauth_test.go index 76ef52e74..b6f27d294 100644 --- a/core/ldapauth/ldapauth_test.go +++ b/core/ldapauth/ldapauth_test.go @@ -19,10 +19,10 @@ func TestLoginUserFilter(t *testing.T) { } }) - t.Run("preserves explicit placeholder filters", func(t *testing.T) { + t.Run("replaces every username placeholder", func(t *testing.T) { t.Parallel() - got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(&(objectClass=person)(%s=%s))"}, "directory-user") - want := "(&(objectClass=person)(uid=directory-user))" + 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) } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index abf762f95..e996e7c32 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -182,6 +182,10 @@ func (r *userRepository) preserveExternalAuthSource(u *model.User) error { if existing.AuthSource == "" { return nil } + if u.AuthSource == "" { + u.AuthSource = existing.AuthSource + u.AuthSourceID = existing.AuthSourceID + } if u.ExternalSync { return nil } @@ -201,10 +205,6 @@ func (r *userRepository) preserveExternalAuthSource(u *model.User) error { if len(validation.Errors) > 0 { return validation } - if u.AuthSource == "" { - u.AuthSource = existing.AuthSource - u.AuthSourceID = existing.AuthSourceID - } return nil } diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index e30b92e5c..88d4d9127 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -103,12 +103,16 @@ var _ = Describe("UserRepository", func() { 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")) }) })