mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge 4fb127249eb2987c77073651005e8dde73b4a136 into d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e
This commit is contained in:
commit
bb41b6d6eb
464
core/ldapauth/ldapauth.go
Normal file
464
core/ldapauth/ldapauth.go
Normal 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
|
||||
}
|
||||
86
core/ldapauth/ldapauth_test.go
Normal file
86
core/ldapauth/ldapauth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
13
db/migrations/20260711130000_add_user_auth_source.sql
Normal file
13
db/migrations/20260711130000_add_user_auth_source.sql
Normal 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
3
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
|
||||
|
||||
6
go.sum
6
go.sum
@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
@ -71,6 +73,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
@ -79,6 +83,8 @@ github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa
|
||||
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
48
server/nativeapi/ldap.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
541
ui/src/ldap/index.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user