Merge 4fb127249eb2987c77073651005e8dde73b4a136 into d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e

This commit is contained in:
Firehawk 2026-08-01 14:04:08 +09:30 committed by GitHub
commit bb41b6d6eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1427 additions and 56 deletions

464
core/ldapauth/ldapauth.go Normal file
View File

@ -0,0 +1,464 @@
package ldapauth
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
ldap "github.com/go-ldap/ldap/v3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
)
type Config struct {
Sources []Source `json:"sources"`
}
type Source struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
StartTLS bool `json:"startTLS"`
InsecureSkipVerify bool `json:"insecureSkipVerify"`
BindDN string `json:"bindDN"`
BindPassword string `json:"bindPassword,omitempty"`
UserBaseDN string `json:"userBaseDN"`
UserFilter string `json:"userFilter"`
UserNameAttribute string `json:"userNameAttribute"`
DisplayNameAttribute string `json:"displayNameAttribute"`
EmailAttribute string `json:"emailAttribute"`
GroupBaseDN string `json:"groupBaseDN"`
GroupFilter string `json:"groupFilter"`
GroupNameAttribute string `json:"groupNameAttribute"`
GroupMemberAttribute string `json:"groupMemberAttribute"`
RequiredGroupDNs []string `json:"requiredGroupDNs"`
AdminGroupDNs []string `json:"adminGroupDNs"`
DirectBindDNTemplate string `json:"directBindDNTemplate"`
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
Cache Cache `json:"cache,omitempty"`
}
type Cache struct {
Users []DiscoveredUser `json:"users,omitempty"`
Groups []DiscoveredGroup `json:"groups,omitempty"`
}
type DiscoveredUser struct {
DN string `json:"dn"`
UserName string `json:"userName"`
Name string `json:"name"`
Email string `json:"email"`
Groups []string `json:"groups,omitempty"`
}
type DiscoveredGroup struct {
DN string `json:"dn"`
Name string `json:"name"`
Members []string `json:"members,omitempty"`
}
type AuthSource struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
var ErrUserNotFound = errors.New("ldap user not found")
var ErrBadPassword = errors.New("ldap invalid password")
type Store struct {
path string
key []byte
}
func NewStore() *Store {
return &Store{path: filepath.Join(conf.Server.DataFolder.String(), "ldap.json"), key: keyTo32Bytes(cmpKey())}
}
func cmpKey() string {
if conf.Server.PasswordEncryptionKey != "" {
return conf.Server.PasswordEncryptionKey
}
return consts.DefaultEncryptionKey
}
func keyTo32Bytes(input string) []byte { s := sha256.Sum256([]byte(input)); return s[:] }
func applyDefaults(src *Source) {
if src.UserNameAttribute == "" {
src.UserNameAttribute = "uid"
}
if src.DisplayNameAttribute == "" {
src.DisplayNameAttribute = "cn"
}
if src.EmailAttribute == "" {
src.EmailAttribute = "mail"
}
if src.GroupNameAttribute == "" {
src.GroupNameAttribute = "cn"
}
if src.GroupMemberAttribute == "" {
src.GroupMemberAttribute = "member"
}
}
func (s *Store) Load(ctx context.Context) (Config, error) {
var c Config
b, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return c, err
}
if len(b) == 0 {
return c, nil
}
err = json.Unmarshal(b, &c)
if err != nil {
return c, err
}
for i := range c.Sources {
if c.Sources[i].BindPassword != "" {
p, e := utils.Decrypt(ctx, s.key, c.Sources[i].BindPassword)
if e != nil {
return c, fmt.Errorf("failed to decrypt bind password for source %s: %w", c.Sources[i].Name, e)
}
c.Sources[i].BindPassword = p
}
applyDefaults(&c.Sources[i])
}
return c, nil
}
func (s *Store) Save(ctx context.Context, c Config) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
return err
}
var out Config
b, err := json.Marshal(c)
if err != nil {
return err
}
if err := json.Unmarshal(b, &out); err != nil {
return err
}
for i := range out.Sources {
if out.Sources[i].ID == "" {
out.Sources[i].ID = id.NewRandom()
}
applyDefaults(&out.Sources[i])
if out.Sources[i].BindPassword != "" {
enc, err := utils.Encrypt(ctx, s.key, out.Sources[i].BindPassword)
if err != nil {
return err
}
out.Sources[i].BindPassword = enc
}
}
b, err = json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, b, 0600)
}
func Sources(ctx context.Context) []AuthSource {
cfg, _ := NewStore().Load(ctx)
res := []AuthSource{{ID: "internal", Name: "Internal", Type: "internal"}}
for _, src := range cfg.Sources {
if src.Enabled {
res = append(res, AuthSource{ID: src.ID, Name: src.Name, Type: "ldap"})
}
}
return res
}
func Authenticate(ctx context.Context, ds model.DataStore, sourceID, username, password string) (*model.User, error) {
if sourceID == "" || sourceID == "internal" {
u, found, err := authInternal(ctx, ds, username, password, sourceID == "")
if err != nil || found {
return u, err
}
if sourceID == "internal" {
return nil, nil
}
}
cfg, _ := NewStore().Load(ctx)
for _, src := range cfg.Sources {
if !src.Enabled {
continue
}
if sourceID != "" && sourceID != "internal" && sourceID != src.ID && sourceID != src.Name {
continue
}
u, err := authLDAP(ctx, ds, src, username, password)
if errors.Is(err, ErrUserNotFound) {
log.Debug(ctx, "LDAP user not found in source", "source", src.Name, "username", username)
continue
}
if err != nil {
log.Warn(ctx, "LDAP authentication failed", "source", src.Name, "username", username, err)
return nil, nil
}
return u, nil
}
return nil, nil
}
func authInternal(ctx context.Context, ds model.DataStore, username, password string, skipExternal bool) (*model.User, bool, error) {
u, err := ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, model.ErrNotFound) {
return nil, false, nil
}
if err != nil {
return nil, true, err
}
if skipExternal && u.AuthSource != "" {
log.Debug(ctx, "Skipping internal password check for externally managed user", "username", username, "authSource", u.AuthSource, "authSourceId", u.AuthSourceID)
return nil, false, nil
}
if u.Password != password {
return nil, true, nil
}
_ = ds.User(ctx).UpdateLastLoginAt(u.ID)
return u, true, nil
}
func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, password string) (*model.User, error) {
applyDefaults(&src)
if password == "" {
return nil, ErrBadPassword
}
du, err := lookupAndBind(src, username, password)
if err != nil {
return nil, err
}
if !allowed(src, du) {
return nil, ErrBadPassword
}
repo := ds.User(ctx)
u, err := repo.FindByUsername(du.UserName)
if errors.Is(err, model.ErrNotFound) {
u = &model.User{ID: id.NewRandom(), UserName: du.UserName, NewPassword: consts.PasswordAutogenPrefix + id.NewRandom()}
} else if err != nil {
return nil, err
}
u.Name = first(du.Name, du.UserName)
u.Email = du.Email
u.IsAdmin = memberAny(du.Groups, src.AdminGroupDNs)
u.AuthSource = "ldap"
u.AuthSourceID = first(src.ID, src.Name)
u.ExternalSync = true
if err := repo.Put(u); err != nil {
return nil, err
}
_ = repo.UpdateLastLoginAt(u.ID)
return repo.FindByUsernameWithPassword(u.UserName)
}
func lookupAndBind(src Source, username, password string) (DiscoveredUser, error) {
l, err := ldap.DialURL(src.URL)
if err != nil {
return DiscoveredUser{}, err
}
defer l.Close()
if src.StartTLS {
if err = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}); err != nil { //nolint:gosec
return DiscoveredUser{}, err
}
}
if src.DirectBindDNTemplate != "" {
dn := fmt.Sprintf(src.DirectBindDNTemplate, ldap.EscapeFilter(username))
if err = l.Bind(dn, password); err != nil {
return DiscoveredUser{}, ErrBadPassword
}
return DiscoveredUser{DN: dn, UserName: username}, nil
}
if src.BindDN != "" {
if err = l.Bind(src.BindDN, src.BindPassword); err != nil {
return DiscoveredUser{}, err
}
}
filt := loginUserFilter(src, username)
log.Debug("LDAP searching user", "source", src.Name, "username", username, "filter", filt, "baseDN", src.UserBaseDN)
req := ldap.NewSearchRequest(src.UserBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, filt, []string{src.UserNameAttribute, src.DisplayNameAttribute, src.EmailAttribute, "memberOf"}, nil)
res, err := l.Search(req)
if err != nil || len(res.Entries) == 0 {
return DiscoveredUser{}, ErrUserNotFound
}
e := res.Entries[0]
if err = l.Bind(e.DN, password); err != nil {
return DiscoveredUser{}, ErrBadPassword
}
du := DiscoveredUser{DN: e.DN, UserName: first(e.GetAttributeValue(src.UserNameAttribute), username), Name: e.GetAttributeValue(src.DisplayNameAttribute), Email: e.GetAttributeValue(src.EmailAttribute), Groups: e.GetAttributeValues("memberOf")}
du.Groups = append(du.Groups, groupsForUser(src, e.DN, du.UserName)...)
return du, nil
}
func groupsForUser(src Source, userDN, username string) []string {
if src.GroupBaseDN == "" {
return nil
}
l, err := ldap.DialURL(src.URL)
if err != nil {
return nil
}
defer l.Close()
if src.StartTLS {
if err = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}); err != nil { //nolint:gosec
return nil
}
}
if src.BindDN != "" {
if err = l.Bind(src.BindDN, src.BindPassword); err != nil {
return nil
}
}
gf := src.GroupFilter
if gf == "" {
gf = "(|(objectClass=groupOfNames)(objectClass=groupOfUniqueNames)(objectClass=group))"
}
req := ldap.NewSearchRequest(src.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 30, false, gf, []string{src.GroupMemberAttribute}, nil)
res, err := l.Search(req)
if err != nil {
return nil
}
var out []string
for _, g := range res.Entries {
members := g.GetAttributeValues(src.GroupMemberAttribute)
if slices.Contains(members, userDN) || slices.Contains(members, username) {
out = append(out, g.DN)
}
}
return out
}
func allowed(src Source, u DiscoveredUser) bool {
return len(src.RequiredGroupDNs) == 0 || memberAny(u.Groups, src.RequiredGroupDNs) || memberAny(u.Groups, src.AdminGroupDNs)
}
func memberAny(a, b []string) bool {
for _, x := range a {
for _, y := range b {
if strings.EqualFold(x, y) {
return true
}
}
}
return false
}
func first(v ...string) string {
for _, s := range v {
if s != "" {
return s
}
}
return ""
}
func loginUserFilter(src Source, username string) string {
attr := first(src.UserNameAttribute, "uid")
escapedUsername := ldap.EscapeFilter(username)
if src.UserFilter == "" {
return fmt.Sprintf("(%s=%s)", attr, escapedUsername)
}
if strings.Contains(src.UserFilter, "%s") {
return strings.ReplaceAll(src.UserFilter, "%s", escapedUsername)
}
return fmt.Sprintf("(&%s(%s=%s))", src.UserFilter, attr, escapedUsername)
}
func dedupeStrings(values []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
key := strings.ToLower(value)
if value == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, value)
}
return out
}
func userNames(users []DiscoveredUser) []string {
names := make([]string, 0, len(users))
for _, user := range users {
names = append(names, first(user.UserName, user.DN))
}
return names
}
func groupNames(groups []DiscoveredGroup) []string {
names := make([]string, 0, len(groups))
for _, group := range groups {
names = append(names, first(group.Name, group.DN))
}
return names
}
func TestAndCache(ctx context.Context, src Source) (Source, error) {
applyDefaults(&src)
src.Cache = Cache{}
l, err := ldap.DialURL(src.URL)
if err != nil {
return src, err
}
defer l.Close()
if src.StartTLS {
if err = l.StartTLS(&tls.Config{InsecureSkipVerify: src.InsecureSkipVerify}); err != nil { //nolint:gosec
return src, err
}
}
if src.BindDN != "" {
if err = l.Bind(src.BindDN, src.BindPassword); err != nil {
return src, err
}
}
attrs := []string{src.UserNameAttribute, src.DisplayNameAttribute, src.EmailAttribute, "memberOf"}
uf := src.UserFilter
if uf == "" {
uf = "(objectClass=person)"
}
ur := ldap.NewSearchRequest(src.UserBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 60, false, uf, attrs, nil)
users, err := l.Search(ur)
if err != nil {
return src, err
}
grps := map[string][]string{}
for _, e := range users.Entries {
src.Cache.Users = append(src.Cache.Users, DiscoveredUser{DN: e.DN, UserName: e.GetAttributeValue(src.UserNameAttribute), Name: e.GetAttributeValue(src.DisplayNameAttribute), Email: e.GetAttributeValue(src.EmailAttribute), Groups: dedupeStrings(e.GetAttributeValues("memberOf"))})
}
gf := src.GroupFilter
if gf == "" {
gf = "(|(objectClass=groupOfNames)(objectClass=groupOfUniqueNames)(objectClass=group))"
}
if src.GroupBaseDN != "" {
gr := ldap.NewSearchRequest(src.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 60, false, gf, []string{src.GroupNameAttribute, src.GroupMemberAttribute}, nil)
gres, err := l.Search(gr)
if err != nil {
return src, err
}
seenGroups := map[string]bool{}
for _, e := range gres.Entries {
if seenGroups[e.DN] {
continue
}
seenGroups[e.DN] = true
m := dedupeStrings(e.GetAttributeValues(src.GroupMemberAttribute))
grps[e.DN] = m
src.Cache.Groups = append(src.Cache.Groups, DiscoveredGroup{DN: e.DN, Name: e.GetAttributeValue(src.GroupNameAttribute), Members: m})
}
}
for i, u := range src.Cache.Users {
for g, m := range grps {
if slices.Contains(m, u.DN) || slices.Contains(m, u.UserName) {
src.Cache.Users[i].Groups = append(src.Cache.Users[i].Groups, g)
}
}
src.Cache.Users[i].Groups = dedupeStrings(src.Cache.Users[i].Groups)
}
now := time.Now()
src.LastSyncAt = &now
log.Info(ctx, "LDAP test/cache completed", "source", src.Name, "users", len(src.Cache.Users), "groups", len(src.Cache.Groups), "matchedUsers", strings.Join(userNames(src.Cache.Users), ","), "matchedGroups", strings.Join(groupNames(src.Cache.Groups), ","))
return src, nil
}

View File

@ -0,0 +1,86 @@
package ldapauth
import (
"testing"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
)
func TestLoginUserFilter(t *testing.T) {
t.Parallel()
t.Run("defaults to username attribute equality", func(t *testing.T) {
t.Parallel()
got := loginUserFilter(Source{UserNameAttribute: "uid"}, "directory-user")
want := "(uid=directory-user)"
if got != want {
t.Fatalf("loginUserFilter() = %q, want %q", got, want)
}
})
t.Run("replaces every username placeholder", func(t *testing.T) {
t.Parallel()
got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(|(uid=%s)(mail=%s))"}, "directory-user")
want := "(|(uid=directory-user)(mail=directory-user))"
if got != want {
t.Fatalf("loginUserFilter() = %q, want %q", got, want)
}
})
t.Run("supports one-placeholder username filters", func(t *testing.T) {
t.Parallel()
got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(uid=%s)"}, "directory-user")
want := "(uid=directory-user)"
if got != want {
t.Fatalf("loginUserFilter() = %q, want %q", got, want)
}
})
t.Run("adds username assertion to discovery filters", func(t *testing.T) {
t.Parallel()
got := loginUserFilter(Source{UserNameAttribute: "uid", UserFilter: "(objectClass=person)"}, "directory-user")
want := "(&(objectClass=person)(uid=directory-user))"
if got != want {
t.Fatalf("loginUserFilter() = %q, want %q", got, want)
}
})
}
func TestDedupeStrings(t *testing.T) {
t.Parallel()
got := dedupeStrings([]string{"cn=users", "CN=users", "", "cn=admins"})
want := []string{"cn=users", "cn=admins"}
if len(got) != len(want) {
t.Fatalf("dedupeStrings() = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("dedupeStrings() = %#v, want %#v", got, want)
}
}
}
func TestAuthInternalSkipsExternalUsersForFallback(t *testing.T) {
t.Parallel()
repo := tests.CreateMockUserRepo()
repo.Data["directory-user"] = &model.User{ID: "1", UserName: "directory-user", Password: "generated", AuthSource: "ldap", AuthSourceID: "freeipa"}
ds := &tests.MockDataStore{MockedUser: repo}
user, found, err := authInternal(t.Context(), ds, "directory-user", "ldap-password", true)
if err != nil {
t.Fatalf("authInternal() unexpected error: %v", err)
}
if found || user != nil {
t.Fatalf("authInternal() found=%v user=%#v, want external user to be skipped for fallback", found, user)
}
user, found, err = authInternal(t.Context(), ds, "directory-user", "ldap-password", false)
if err != nil {
t.Fatalf("authInternal() unexpected error: %v", err)
}
if !found || user != nil {
t.Fatalf("authInternal() found=%v user=%#v, want explicit internal auth to stop", found, user)
}
}

View File

@ -0,0 +1,13 @@
-- +goose Up
-- Add LDAP ownership metadata to users.
-- +goose StatementBegin
alter table user add column auth_source varchar(32) default '' not null;
alter table user add column auth_source_id varchar(255) default '' not null;
-- +goose StatementEnd
-- +goose Down
-- Roll back the LDAP ownership metadata added in the Up migration.
-- +goose StatementBegin
alter table user drop column auth_source;
alter table user drop column auth_source_id;
-- +goose StatementEnd

3
go.mod
View File

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

6
go.sum
View File

@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
@ -71,6 +73,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@ -79,6 +83,8 @@ github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=

View File

@ -10,6 +10,8 @@ type User struct {
Name string `structs:"name" json:"name"`
Email string `structs:"email" json:"email"`
IsAdmin bool `structs:"is_admin" json:"isAdmin"`
AuthSource string `structs:"auth_source" json:"authSource,omitempty"`
AuthSourceID string `structs:"auth_source_id" json:"authSourceId,omitempty"`
LastLoginAt *time.Time `structs:"last_login_at" json:"lastLoginAt"`
LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
@ -25,6 +27,9 @@ type User struct {
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
// If changing the password, this is also required
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
// ExternalSync allows external auth providers to update read-only identity fields for managed users.
ExternalSync bool `structs:"-" json:"-"`
}
func (u User) HasLibraryAccess(libraryID int) bool {

View File

@ -116,6 +116,9 @@ func (r *userRepository) Put(u *model.User) error {
u.ID = id.NewRandom()
}
u.UpdatedAt = time.Now()
if err := r.preserveExternalAuthSource(u); err != nil {
return err
}
if u.NewPassword != "" {
_ = r.encryptPassword(u)
}
@ -165,6 +168,46 @@ func (r *userRepository) Put(u *model.User) error {
return nil
}
func (r *userRepository) preserveExternalAuthSource(u *model.User) error {
if u.ID == "" {
return nil
}
existing, err := r.Get(u.ID)
if errors.Is(err, model.ErrNotFound) {
return nil
}
if err != nil {
return err
}
if existing.AuthSource == "" {
return nil
}
if u.AuthSource == "" {
u.AuthSource = existing.AuthSource
u.AuthSourceID = existing.AuthSourceID
}
if u.ExternalSync {
return nil
}
validation := &rest.ValidationError{Errors: map[string]string{}}
if u.NewPassword != "" {
validation.Errors["password"] = "resources.user.validation.externalFieldReadOnly"
}
if !strings.EqualFold(u.UserName, existing.UserName) {
validation.Errors["userName"] = "resources.user.validation.externalFieldReadOnly"
}
if u.Name != existing.Name {
validation.Errors["name"] = "resources.user.validation.externalFieldReadOnly"
}
if u.Email != existing.Email {
validation.Errors["email"] = "resources.user.validation.externalFieldReadOnly"
}
if len(validation.Errors) > 0 {
return validation
}
return nil
}
func (r *userRepository) FindFirstAdmin() (*model.User, error) {
sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true})
var usr dbUser

View File

@ -70,6 +70,50 @@ var _ = Describe("UserRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(actual.Password).To(Equal("newpass"))
})
It("does not update managed fields for LDAP-sourced users", func() {
ldapUser := model.User{
ID: "ldap-user",
UserName: "ldap_user",
Name: "LDAP User",
Email: "ldap@example.com",
NewPassword: "generated",
AuthSource: "ldap",
AuthSourceID: "ldap01",
}
Expect(repo.Put(&ldapUser)).To(Succeed())
ldapUser.UserName = "renamed"
ldapUser.Name = "Renamed User"
ldapUser.Email = "renamed@example.com"
ldapUser.NewPassword = "newpass"
err := repo.Put(&ldapUser)
var verr *rest.ValidationError
Expect(errors.As(err, &verr)).To(BeTrue())
Expect(verr.Errors).To(HaveKeyWithValue("userName", "resources.user.validation.externalFieldReadOnly"))
Expect(verr.Errors).To(HaveKeyWithValue("name", "resources.user.validation.externalFieldReadOnly"))
Expect(verr.Errors).To(HaveKeyWithValue("email", "resources.user.validation.externalFieldReadOnly"))
Expect(verr.Errors).To(HaveKeyWithValue("password", "resources.user.validation.externalFieldReadOnly"))
actual, err := repo.FindByUsernameWithPassword("ldap_user")
Expect(err).ToNot(HaveOccurred())
Expect(actual.UserName).To(Equal("ldap_user"))
Expect(actual.Name).To(Equal("LDAP User"))
Expect(actual.Email).To(Equal("ldap@example.com"))
Expect(actual.Password).To(Equal("generated"))
actual.Name = "Synced User"
actual.Email = "synced@example.com"
actual.AuthSource = ""
actual.AuthSourceID = ""
actual.ExternalSync = true
Expect(repo.Put(actual)).To(Succeed())
actual, err = repo.FindByUsername("ldap_user")
Expect(err).ToNot(HaveOccurred())
Expect(actual.Name).To(Equal("Synced User"))
Expect(actual.Email).To(Equal("synced@example.com"))
Expect(actual.AuthSource).To(Equal("ldap"))
Expect(actual.AuthSourceID).To(Equal("ldap01"))
})
})
Describe("validatePasswordChange", func() {

View File

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

48
server/nativeapi/ldap.go Normal file
View File

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

View File

@ -91,6 +91,7 @@ func (api *Router) routes() http.Handler {
api.addConfigRoute(r)
api.addUserLibraryRoute(r)
api.addPluginRoute(r)
api.addLDAPRoute(r)
api.RX(r, "/library", api.libs.NewRepository, true)
})
})

View File

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

View File

@ -19,6 +19,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/ldapauth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -126,21 +127,28 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
salt, _ := p.String("s")
jwt, _ := p.String("jwt")
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:
err = validateCredentials(usr, pass, token, salt, jwt)
if err != nil {
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
if pass != "" && token == "" && jwt == "" {
usr, err = ldapauth.Authenticate(ctx, ds, "", username, pass)
if usr == nil && err == nil {
err = model.ErrInvalidAuth
}
} else {
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
return
}
switch {
case errors.Is(err, model.ErrNotFound):
err = model.ErrInvalidAuth
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:
err = validateCredentials(usr, pass, token, salt, jwt)
}
}
if err != nil {
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
}
}

View File

@ -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' ? (
<Resource
name="ldap"
list={LdapList}
icon={SettingsEthernetIcon}
options={{ subMenu: 'settings' }}
/>
) : null,
permissions === 'admin' && config.pluginsEnabled ? (
<Resource
name="plugin"

View File

@ -27,14 +27,14 @@ function storeAuthenticationInfo(authInfo) {
}
const authProvider = {
login: ({ username, password }) => {
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)

View File

@ -167,13 +167,15 @@
"deleted": "User deleted"
},
"validation": {
"librariesRequired": "At least one library must be selected for non-admin users"
"librariesRequired": "At least one library must be selected for non-admin users",
"externalFieldReadOnly": "This field is managed by an external authentication source."
},
"message": {
"listenBrainzToken": "Enter your ListenBrainz user token.",
"clickHereForToken": "Click here to get your token",
"selectAllLibraries": "Select all libraries",
"adminAutoLibraries": "Admin users automatically have access to all libraries"
"adminAutoLibraries": "Admin users automatically have access to all libraries",
"externalPasswordReadOnly": "Username, display name, email, and password changes are disabled because this user is managed by an external authentication source."
}
},
"player": {
@ -412,6 +414,10 @@
"configKey": "key",
"configValue": "value"
}
},
"ldap": {
"name": "LDAP |||| LDAP",
"fields": {}
}
},
"ra": {

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Field, Form } from 'react-final-form'
import { useDispatch } from 'react-redux'
@ -9,6 +9,9 @@ import CircularProgress from '@material-ui/core/CircularProgress'
import Link from '@material-ui/core/Link'
import TextField from '@material-ui/core/TextField'
import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
import Tabs from '@material-ui/core/Tabs'
import Tab from '@material-ui/core/Tab'
import { baseUrl } from '../utils'
import {
createMuiTheme,
useLogin,
@ -110,7 +113,14 @@ const renderInput = ({
/>
)
const FormLogin = ({ loading, handleSubmit, validate }) => {
const FormLogin = ({
loading,
handleSubmit,
validate,
authSources = [],
authSource = 'internal',
setAuthSource = () => {},
}) => {
const translate = useTranslate()
const classes = useStyles()
@ -143,6 +153,24 @@ const FormLogin = ({ loading, handleSubmit, validate }) => {
dangerouslySetInnerHTML={{ __html: config.welcomeMessage }}
/>
)}
{authSources.length > 1 && (
<Tabs
value={authSource}
onChange={(event, value) => setAuthSource(value)}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
scrollButtons="auto"
>
{authSources.map((source) => (
<Tab
key={source.id}
value={source.id}
label={source.name}
/>
))}
</Tabs>
)}
<div className={classes.form}>
<div className={classes.input}>
<Field
@ -318,30 +346,47 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => {
const Login = ({ location }) => {
const [loading, setLoading] = useState(false)
const [authSources, setAuthSources] = useState([
{ id: 'internal', name: 'Internal' },
])
const [authSource, setAuthSource] = useState('internal')
const translate = useTranslate()
const notify = useNotify()
const login = useLogin()
const dispatch = useDispatch()
useEffect(() => {
fetch(baseUrl('/auth/sources'))
.then((response) => (response.ok ? response.json() : []))
.then((sources) => {
if (sources.length) {
setAuthSources(sources)
setAuthSource(sources[0].id)
}
})
.catch(() => {})
}, [])
const handleSubmit = useCallback(
(auth) => {
setLoading(true)
dispatch(clearQueue())
login(auth, location.state ? location.state.nextPathname : '/').catch(
(error) => {
setLoading(false)
notify(
typeof error === 'string'
? error
: typeof error === 'undefined' || !error.message
? 'ra.auth.sign_in_error'
: error.message,
'warning',
)
},
)
login(
{ ...auth, authSource },
location.state ? location.state.nextPathname : '/',
).catch((error) => {
setLoading(false)
notify(
typeof error === 'string'
? error
: typeof error === 'undefined' || !error.message
? 'ra.auth.sign_in_error'
: error.message,
'warning',
)
})
},
[dispatch, login, notify, setLoading, location],
[authSource, dispatch, login, notify, setLoading, location],
)
const validateLogin = useCallback(
@ -390,6 +435,9 @@ const Login = ({ location }) => {
handleSubmit={handleSubmit}
validate={validateLogin}
loading={loading}
authSources={authSources}
authSource={authSource}
setAuthSource={setAuthSource}
/>
)
}

541
ui/src/ldap/index.jsx Normal file
View File

@ -0,0 +1,541 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Button,
Card,
CardActions,
CardContent,
Checkbox,
Chip,
Divider,
FormControl,
FormControlLabel,
InputLabel,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
MenuItem,
Select,
Step,
StepLabel,
Stepper,
TextField,
Typography,
} from '@material-ui/core'
import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'
import DeleteIcon from '@material-ui/icons/Delete'
import EditIcon from '@material-ui/icons/Edit'
import { useNotify } from 'react-admin'
import httpClient from '../dataProvider/httpClient'
import { REST_URL } from '../consts'
const emptySource = {
name: '',
enabled: true,
url: 'ldap://ldap.example.org:389',
startTLS: false,
insecureSkipVerify: false,
bindDN: '',
bindPassword: '',
userBaseDN: '',
userFilter: '(%s=%s)',
userNameAttribute: 'uid',
displayNameAttribute: 'cn',
emailAttribute: 'mail',
groupBaseDN: '',
groupFilter: '(|(objectClass=groupOfNames)(objectClass=group))',
groupNameAttribute: 'cn',
groupMemberAttribute: 'member',
requiredGroupDNs: [],
adminGroupDNs: [],
directBindDNTemplate: '',
cache: { users: [], groups: [] },
}
const steps = ['Server', 'Bind & filters', 'Fetch users/groups', 'Map access']
const groupLabel = (group) => group.name || group.dn
const uniqueByDN = (groups = []) => {
const seen = new Set()
return groups.filter((group) => {
if (!group.dn || seen.has(group.dn)) {
return false
}
seen.add(group.dn)
return true
})
}
const textField = (source, setSource, key, label, props = {}) => (
<TextField
label={label}
value={source[key] || ''}
onChange={(event) => setSource({ ...source, [key]: event.target.value })}
fullWidth
margin="normal"
variant="outlined"
{...props}
/>
)
const groupSelect = (source, setSource, key, label, groups) => (
<FormControl fullWidth margin="normal" variant="outlined">
<InputLabel>{label}</InputLabel>
<Select
multiple
value={source[key] || []}
onChange={(event) => setSource({ ...source, [key]: event.target.value })}
renderValue={(selected) => (
<div>
{selected.map((dn) => {
const group = groups.find((candidate) => candidate.dn === dn)
return (
<Chip
key={dn}
label={group ? groupLabel(group) : dn}
size="small"
style={{ margin: 2 }}
/>
)
})}
</div>
)}
label={label}
>
{groups.map((group) => (
<MenuItem key={group.dn} value={group.dn}>
<Checkbox checked={(source[key] || []).indexOf(group.dn) > -1} />
<ListItemText primary={groupLabel(group)} secondary={group.dn} />
</MenuItem>
))}
</Select>
</FormControl>
)
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 (
<Card>
<CardContent>
<Typography variant="h5" gutterBottom>
{source.id ? 'Edit LDAP Server' : 'Add LDAP Server'}
</Typography>
<Stepper activeStep={activeStep} alternativeLabel>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{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',
})}
<FormControlLabel
control={
<Checkbox
checked={!!source.enabled}
onChange={(event) =>
setSource({ ...source, enabled: event.target.checked })
}
/>
}
label="Enabled"
/>
<FormControlLabel
control={
<Checkbox
checked={!!source.startTLS}
onChange={(event) =>
setSource({ ...source, startTLS: event.target.checked })
}
/>
}
label="Use StartTLS"
/>
<FormControlLabel
control={
<Checkbox
checked={!!source.insecureSkipVerify}
onChange={(event) =>
setSource({
...source,
insecureSkipVerify: event.target.checked,
})
}
/>
}
label="Skip TLS certificate verification"
/>
</>
)}
{activeStep === 1 && (
<>
<Typography variant="subtitle1">Service account bind</Typography>
{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.',
},
)}
<Divider style={{ margin: '16px 0' }} />
<Typography variant="subtitle1">Users</Typography>
{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')}
<Divider style={{ margin: '16px 0' }} />
<Typography variant="subtitle1">Groups</Typography>
{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 && (
<>
<Typography variant="body1" gutterBottom>
Test the LDAP connection and service-account bind, then fetch
users, groups, and memberships for interactive mapping.
</Typography>
<Button
color="primary"
variant="contained"
onClick={testSource}
disabled={testing}
>
Test connection and fetch directory data
</Button>
<Typography variant="body2" style={{ marginTop: 16 }}>
Cached users: {source.cache?.users?.length || 0} · Cached groups:{' '}
{source.cache?.groups?.length || 0}
</Typography>
{!!source.cache?.users?.length && (
<Typography variant="body2" style={{ marginTop: 8 }}>
Matched users:{' '}
{source.cache.users
.slice(0, 10)
.map((user) => user.userName || user.dn)
.join(', ')}
{source.cache.users.length > 10 ? '…' : ''}
</Typography>
)}
{!!source.cache?.groups?.length && (
<Typography variant="body2" style={{ marginTop: 8 }}>
Matched groups:{' '}
{source.cache.groups
.slice(0, 10)
.map((group) => group.name || group.dn)
.join(', ')}
{source.cache.groups.length > 10 ? '…' : ''}
</Typography>
)}
</>
)}
{activeStep === 3 && (
<>
<Typography variant="body2" gutterBottom>
Select the groups that are allowed to log in. Admin groups also
grant Navidrome administrator access.
</Typography>
{groups.length === 0 ? (
<Typography color="textSecondary">
No groups have been discovered yet. Go back and fetch directory
data before mapping groups.
</Typography>
) : (
<>
{groupSelect(
source,
setSource,
'requiredGroupDNs',
'Allowed login groups',
groups,
)}
{groupSelect(
source,
setSource,
'adminGroupDNs',
'Admin groups',
groups,
)}
</>
)}
<Typography variant="body2" style={{ marginTop: 16 }}>
Preview: {source.cache?.users?.length || 0} users and{' '}
{source.cache?.groups?.length || 0} groups cached for this source.
</Typography>
</>
)}
</CardContent>
<CardActions>
<Button onClick={onCancel}>Cancel</Button>
{activeStep > 0 && (
<Button onClick={() => setActiveStep(activeStep - 1)}>Back</Button>
)}
{activeStep < steps.length - 1 ? (
<Button color="primary" variant="contained" onClick={next}>
Next
</Button>
) : (
<Button
color="primary"
variant="contained"
onClick={() => onSave(source)}
>
Save LDAP Server
</Button>
)}
</CardActions>
</Card>
)
}
export const LdapList = () => {
const notify = useNotify()
const [sources, setSources] = useState([])
const [editingIndex, setEditingIndex] = useState(null)
const [loading, setLoading] = useState(false)
const [testing, setTesting] = useState(false)
const load = () => {
setLoading(true)
httpClient(`${REST_URL}/ldap`)
.then(({ json }) => setSources(json.sources || []))
.catch(() => notify('Could not load LDAP configuration', 'warning'))
.finally(() => setLoading(false))
}
useEffect(load, [notify])
const saveSources = (nextSources) => {
setLoading(true)
return httpClient(`${REST_URL}/ldap`, {
method: 'PUT',
body: JSON.stringify({ sources: nextSources }),
})
.then(({ json }) => {
setSources(json.sources || nextSources)
notify('LDAP configuration saved')
})
.catch((e) => {
notify(`Could not save LDAP configuration: ${e.message}`, 'warning')
throw e
})
.finally(() => setLoading(false))
}
const saveSource = (source) => {
const nextSources = [...sources]
if (editingIndex === 'new') {
nextSources.push(source)
} else {
nextSources[editingIndex] = source
}
saveSources(nextSources).then(() => setEditingIndex(null))
}
const moveSource = (index, direction) => {
const target = index + direction
if (target < 0 || target >= sources.length) {
return
}
const nextSources = [...sources]
const movedSource = nextSources[index]
nextSources[index] = nextSources[target]
nextSources[target] = movedSource
saveSources(nextSources)
}
const deleteSource = (index) => {
const nextSources = sources.filter(
(_, sourceIndex) => sourceIndex !== index,
)
saveSources(nextSources)
}
const testSource = (source) => {
setTesting(true)
return httpClient(`${REST_URL}/ldap/test`, {
method: 'POST',
body: JSON.stringify(source),
})
.then(({ json }) => {
notify(
`LDAP test found ${json.cache?.users?.length || 0} users and ${json.cache?.groups?.length || 0} groups`,
)
return json
})
.catch((e) => {
notify(`LDAP test failed: ${e.message}`, 'warning')
throw e
})
.finally(() => setTesting(false))
}
if (editingIndex !== null) {
return (
<SourceWizard
initialSource={
editingIndex === 'new' ? emptySource : sources[editingIndex]
}
onCancel={() => setEditingIndex(null)}
onSave={saveSource}
onTest={testSource}
testing={testing}
/>
)
}
return (
<Card>
<CardContent>
<Typography variant="h5" gutterBottom>
LDAP Authentication
</Typography>
<Typography variant="body2" gutterBottom>
LDAP sources are tried in the order shown below after internal auth
for external clients. Use the arrow buttons to change fallback
priority.
</Typography>
{sources.length === 0 ? (
<Typography color="textSecondary" style={{ marginTop: 16 }}>
No LDAP servers configured yet.
</Typography>
) : (
<List>
{sources.map((source, index) => (
<ListItem key={source.id || source.name || index} divider>
<ListItemText
primary={`${index + 1}. ${source.name || 'Unnamed LDAP server'}`}
secondary={`${source.enabled ? 'Enabled' : 'Disabled'} · ${source.url} · ${source.cache?.users?.length || 0} cached users · ${source.cache?.groups?.length || 0} cached groups`}
/>
<ListItemSecondaryAction>
<Button
size="small"
onClick={() => moveSource(index, -1)}
disabled={loading || index === 0}
startIcon={<ArrowUpwardIcon />}
>
Up
</Button>
<Button
size="small"
onClick={() => moveSource(index, 1)}
disabled={loading || index === sources.length - 1}
startIcon={<ArrowDownwardIcon />}
>
Down
</Button>
<Button
size="small"
onClick={() => setEditingIndex(index)}
disabled={loading}
startIcon={<EditIcon />}
>
Edit
</Button>
<Button
size="small"
onClick={() => deleteSource(index)}
disabled={loading}
startIcon={<DeleteIcon />}
>
Delete
</Button>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
</CardContent>
<CardActions>
<Button
color="primary"
variant="contained"
onClick={() => setEditingIndex('new')}
disabled={loading}
>
+ Add LDAP Server
</Button>
</CardActions>
</Card>
)
}

View File

@ -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') ? (
<PasswordInput className="ra-input" source="currentPassword" {...rest} />
) : null
}
const NewPasswordInput = ({ formData, ...rest }) => {
const translate = useTranslate()
return formData.changePassword ? (
return formData.changePassword && !formData.authSource ? (
<PasswordInput
source="password"
className="ra-input"
@ -119,19 +121,52 @@ const UserEdit = (props) => {
validate={validateForm}
>
{permissions === 'admin' && (
<TextInput
spellCheck={false}
source="userName"
validate={[required()]}
/>
<FormDataConsumer>
{({ formData }) => (
<TextInput
spellCheck={false}
source="userName"
validate={[required()]}
disabled={!!formData.authSource}
/>
)}
</FormDataConsumer>
)}
<TextInput
source="name"
validate={[required()]}
{...getNameHelperText()}
/>
<TextInput spellCheck={false} source="email" validate={[email()]} />
<BooleanInput source="changePassword" />
<FormDataConsumer>
{({ formData }) => (
<TextInput
source="name"
validate={[required()]}
disabled={!!formData.authSource}
{...getNameHelperText()}
/>
)}
</FormDataConsumer>
<FormDataConsumer>
{({ formData }) => (
<TextInput
spellCheck={false}
source="email"
validate={[email()]}
disabled={!!formData.authSource}
/>
)}
</FormDataConsumer>
<FormDataConsumer>
{({ formData }) =>
formData.authSource ? (
<Typography
variant="body2"
color="textSecondary"
style={{ marginTop: 16, marginBottom: 16 }}
>
{translate('resources.user.message.externalPasswordReadOnly')}
</Typography>
) : (
<BooleanInput source="changePassword" />
)
}
</FormDataConsumer>
<FormDataConsumer>
{(formDataProps) => (
<CurrentPasswordInput