mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Add LDAP authentication support
This commit is contained in:
parent
e91687e760
commit
181b59528b
386
core/ldapauth/ldapauth.go
Normal file
386
core/ldapauth/ldapauth.go
Normal 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
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=
|
||||
@ -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=
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -411,6 +411,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,
|
||||
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
102
ui/src/ldap/index.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user