From 181b59528b03427e055a12bb70acfea916daa62d Mon Sep 17 00:00:00 2001 From: Firehawk Date: Sat, 11 Jul 2026 14:18:10 +0930 Subject: [PATCH] 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)} + /> + + + + + ) +}