Add LDAP authentication support

This commit is contained in:
Firehawk 2026-07-11 14:18:10 +09:30
parent e91687e760
commit 181b59528b
13 changed files with 647 additions and 27 deletions

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

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

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=
@ -73,6 +75,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@ -81,6 +85,8 @@ github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa
github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=

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

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

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"
@ -133,7 +134,11 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
if pass != "" && token == "" && jwt == "" {
usr, err = ldapauth.Authenticate(ctx, ds, "", username, pass)
} else {
err = model.ErrInvalidAuth
}
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:

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

@ -411,6 +411,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,
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(

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

@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react'
import {
Button,
Card,
CardContent,
TextField,
Typography,
} from '@material-ui/core'
import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet'
import { useNotify } from 'react-admin'
import httpClient from '../dataProvider/httpClient'
import { REST_URL } from '../consts'
const defaultConfig = { sources: [] }
export const LdapList = () => {
const notify = useNotify()
const [text, setText] = useState(JSON.stringify(defaultConfig, null, 2))
const [loading, setLoading] = useState(false)
useEffect(() => {
httpClient(`${REST_URL}/ldap`)
.then(({ json }) =>
setText(JSON.stringify({ sources: json.sources || [] }, null, 2)),
)
.catch(() => notify('Could not load LDAP configuration', 'warning'))
}, [notify])
const save = () => {
setLoading(true)
httpClient(`${REST_URL}/ldap`, { method: 'PUT', body: text })
.then(({ json }) => {
setText(JSON.stringify({ sources: json.sources || [] }, null, 2))
notify('LDAP configuration saved')
})
.catch((e) =>
notify(`Could not save LDAP configuration: ${e.message}`, 'warning'),
)
.finally(() => setLoading(false))
}
const test = () => {
const cfg = JSON.parse(text)
const source = cfg.sources?.[0]
if (!source) {
notify('Add at least one LDAP source to test', 'warning')
return
}
setLoading(true)
httpClient(`${REST_URL}/ldap/test`, {
method: 'POST',
body: JSON.stringify(source),
})
.then(({ json }) =>
notify(
`LDAP test found ${json.cache?.users?.length || 0} users and ${json.cache?.groups?.length || 0} groups`,
),
)
.catch((e) => notify(`LDAP test failed: ${e.message}`, 'warning'))
.finally(() => setLoading(false))
}
return (
<Card>
<CardContent>
<Typography variant="h5" gutterBottom>
LDAP Authentication
</Typography>
<Typography variant="body2" gutterBottom>
Configure LDAP sources as JSON. Sources are evaluated after internal
auth for external clients; the login page shows enabled sources as
tabs.
</Typography>
<TextField
multiline
minRows={24}
fullWidth
variant="outlined"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<Button
color="primary"
variant="contained"
onClick={save}
disabled={loading}
style={{ marginTop: 16, marginRight: 8 }}
>
Save
</Button>
<Button
variant="outlined"
onClick={test}
disabled={loading}
style={{ marginTop: 16 }}
>
Test first source
</Button>
</CardContent>
</Card>
)
}