diff --git a/.gitignore b/.gitignore index ace40cd7d..c4e881d89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.db +*.db.*.bak *db-wal *db-shm *.sql diff --git a/README.md b/README.md index 8b8a1be22..4cd84a5c3 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,15 @@ password can then be changed from the web interface | `GONIC_TRANSCODE_CACHE_SIZE` | `-transcode-cache-size` | **optional** size of the transcode cache in MB (0 = no limit) | | `GONIC_TRANSCODE_EJECT_INTERVAL` | `-transcode-eject-interval` | **optional** interval (in minutes) to eject transcode cache (0 = never) | | `GONIC_EXPVAR` | `-expvar` | **optional** enable the /debug/vars endpoint (exposes useful debugging attributes as well as database stats) | +| `GONIC_LDAP_FQDN` | `-ldap-fqdn` | **optional** the name of the server to connect to (required for LDAP) | +| `GONIC_LDAP_PORT` | `-ldap-port` | **optional** what port the LDAP server is hosted on (_default_ `389`) | +| `GONIC_LDAP_TLS` | `-ldap-tls` | **optional** whether gonic will connect to the LDAP server using TLS (_default_ `false`) | +| `GONIC_LDAP_BASE_DN` | `-ldap-base-dn` | **optional** the base DN for LDAP objects (required for LDAP, escaping the commas might be necessary if using environmental variable) | +| `GONIC_LDAP_USERNAME_ATTR` | `-ldap-username-attr` | **optional** attribute used by the LDAP server for usernames, gets prepended to BaseDN (_default_ `uid`) | +| `GONIC_LDAP_BIND_USER` | `-ldap-bind-user` | **optional** the bind user to bind to LDAP with (required for LDAP) | +| `GONIC_LDAP_BIND_PASS` | `-ldap-bind-pass` | **optional** the password of the LDAP bind user (required for LDAP) | +| `GONIC_LDAP_FILTER` | `-ldap-filter` | **optional** the filter to select LDAP objects with (escaping the commas might be necessary if using environmental variable) | +| `GONIC_LDAP_ADMIN_FILTER` | `-ldap-admin-filter` | **optional** the filter to select LDAP admin objects with (escaping the commas might be necessary if using environmental variable) (_default_ `(memberof=cn=admin)`) | ## multi valued tags (v0.16+) diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 6f0872413..58a981362 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -39,6 +39,7 @@ import ( "go.senan.xyz/gonic/infocache/artistinfocache" "go.senan.xyz/gonic/jukebox" "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/ldap" "go.senan.xyz/gonic/listenbrainz" "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/podcast" @@ -83,6 +84,26 @@ func main() { confExcludePattern := flag.String("exclude-pattern", "", "regex pattern to exclude files from scan (optional)") + var ldapConfig ldap.Config + var ldapStore = make(ldap.LDAPStore) + flag.StringVar(&ldapConfig.BindUser, "ldap-bind-user", "", "the bind user to bind to LDAP with (required for LDAP)") + flag.StringVar(&ldapConfig.BindPass, "ldap-bind-pass", "", "the password of the LDAP bind user (required for LDAP)") + flag.StringVar(&ldapConfig.BaseDN, "ldap-base-dn", "", "the base DN for LDAP objects (required for LDAP)") + flag.StringVar(&ldapConfig.UsernameAttr, "ldap-username-attr", "uid", "attribute used by the LDAP server for usernames, gets prepended to BaseDN (optional)") + + flag.StringVar(&ldapConfig.Filter, "ldap-filter", "", "the filter to select LDAP objects with (optional)") + flag.StringVar(&ldapConfig.AdminFilter, "ldap-admin-filter", "(memberof=cn=admin)", "the filter to select LDAP objects with (optional)") + + flag.StringVar(&ldapConfig.FQDN, "ldap-fqdn", "", "the name of the server to connect to (required for LDAP)") + flag.UintVar(&ldapConfig.Port, "ldap-port", 389, "what port the LDAP server is hosted on (optional)") + flag.BoolVar(&ldapConfig.TLS, "ldap-tls", false, "whether gonic will connect to the LDAP server using TLS (optional)") + + if ldapConfig.FQDN != "" { + if ldapConfig.BindUser == "" || ldapConfig.BindPass == "" || ldapConfig.BaseDN == "" { + log.Fatal("a server was provided for an LDAP connection, but configuration is incomplete") + } + } + var confMultiValueGenre, confMultiValueArtist, confMultiValueAlbumArtist multiValueSetting flag.Var(&confMultiValueGenre, "multi-value-genre", "setting for multi-valued genre scanning (optional)") flag.Var(&confMultiValueArtist, "multi-value-artist", "setting for multi-valued track artist scanning (optional)") @@ -252,11 +273,11 @@ func main() { return url.String() } - ctrlAdmin, err := ctrladmin.New(dbc, sessDB, scannr, podcast, lastfmClient, resolveProxyPath) + ctrlAdmin, err := ctrladmin.New(dbc, sessDB, scannr, podcast, lastfmClient, resolveProxyPath, ldapConfig, ldapStore) if err != nil { log.Panicf("error creating admin controller: %v\n", err) } - ctrlSubsonic, err := ctrlsubsonic.New(dbc, scannr, musicPaths, *confPodcastPath, cacheDirAudio, cacheDirCovers, jukebx, playlistStore, scrobblers, podcast, transcoder, lastfmClient, artistInfoCache, albumInfoCache, tagReader, resolveProxyPath) + ctrlSubsonic, err := ctrlsubsonic.New(dbc, scannr, musicPaths, *confPodcastPath, cacheDirAudio, cacheDirCovers, jukebx, playlistStore, scrobblers, podcast, transcoder, lastfmClient, artistInfoCache, albumInfoCache, tagReader, resolveProxyPath, ldapConfig, ldapStore) if err != nil { log.Panicf("error creating subsonic controller: %v\n", err) } diff --git a/db/db.go b/db/db.go index 453c6706f..20845d229 100644 --- a/db/db.go +++ b/db/db.go @@ -287,7 +287,7 @@ type User struct { ID int `gorm:"primary_key"` CreatedAt time.Time Name string `gorm:"not null; unique_index" sql:"default: null"` - Password string `gorm:"not null" sql:"default: null"` + Password string `sql:"default: null"` LastFMSession string `sql:"default: null"` ListenBrainzURL string `sql:"default: null"` ListenBrainzToken string `sql:"default: null"` diff --git a/go.mod b/go.mod index eb25e7b15..48df847ae 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/env25/mpdlrc v0.7.4 github.com/fatih/structs v1.1.0 github.com/fsnotify/fsnotify v1.9.0 + github.com/go-ldap/ldap/v3 v3.4.6 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 @@ -38,11 +39,13 @@ require ( ) require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/PuerkitoBio/goquery v1.11.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/gorilla/context v1.1.2 // indirect diff --git a/go.sum b/go.sum index b946caaac..863613e97 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ cloud.google.com/go v0.33.1/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -8,6 +10,8 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= +github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= @@ -37,6 +41,10 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A= +github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= @@ -57,6 +65,7 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= @@ -141,8 +150,11 @@ github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7 github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 h1:sLILANWN76ja66/K4k/mBqJuCjDZaM67w+Ru6rEB0s0= github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981/go.mod h1:Rx8XB1ck+so+41uu9VY1gMKs1CPQ2NTq0pzf+OCCQHo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= diff --git a/ldap/ldap.go b/ldap/ldap.go new file mode 100644 index 000000000..5de1c8b30 --- /dev/null +++ b/ldap/ldap.go @@ -0,0 +1,225 @@ +package ldap + +import ( + "errors" + "fmt" + "log" + "time" + + "go.senan.xyz/gonic/db" + + "github.com/go-ldap/ldap/v3" +) + +// LDAPStore maps users to a cached password +type LDAPStore map[string]CachedLDAPpassword + +// Add caches a password username set. +func (store LDAPStore) Add(username, password string) { + store[username] = CachedLDAPpassword{ + Password: password, + ExpiresAt: time.Now().Add(time.Hour * 8), // Keep the password valid for 8 hours. + } +} + +// IsValid checks if a user's password is stored in the cache and checks if a +// given password is valid. +func (store LDAPStore) IsValid(username, password string) bool { + cached, ok := store[username] + if !ok { + return false + } + + if cached.Password != password { + return false + } + + return cached.IsValid() +} + +// CachedLDAPpassword stores an LDAP user's password and a time at which the +// server should no longer accept it. +type CachedLDAPpassword struct { + Password string + ExpiresAt time.Time +} + +func (password CachedLDAPpassword) IsValid() bool { + return password.ExpiresAt.After(time.Now()) +} + +// Cofig stores the user's LDAP server options. +type Config struct { + BindUser string + BindPass string + BaseDN string + UsernameAttr string + + Filter string + AdminFilter string + + FQDN string + Port uint + TLS bool +} + +func (c Config) IsSetup() bool { + // This is basically checking if LDAP is setup, if ldapFQDN isn't set we can + // assume that the user hasn't configured LDAP. + return c.FQDN != "" +} + +func CheckLDAPcreds(username string, password string, dbc *db.DB, config Config, store LDAPStore) (bool, error) { + if !config.IsSetup() { + return false, nil + } + + if store.IsValid(username, password) { + log.Println("Password authenticated via cache!") + return true, nil + } + + log.Println("Checking password against LDAP server ...") + + // Now, we can try to connect to the LDAP server. + l, err := createLDAPconnection(config) + if err != nil { + // Return a generic error. + log.Println("Failed to connect to LDAP server:", err) + return false, errors.New("failed to connect to LDAP server") + } + defer l.Close() + + // Create the user if it doesn't exist on the database already. + err = createUserFromLDAP(username, dbc, config, l) + if err != nil { + log.Println("Failed to create user from LDAP:", err) + return false, err + } + + // After we have a connection, let's try binding + _, err = l.SimpleBind(&ldap.SimpleBindRequest{ + Username: fmt.Sprintf("%s=%s,%s", config.UsernameAttr, username, config.BaseDN), + Password: password, + }) + + if err == nil { + // Authentication was OK + store.Add(username, password) + return true, nil + } + + log.Println("Failed to bind to LDAP server:", err) + return false, nil +} + +// Creates a user from creds +func createUserFromLDAP(username string, dbc *db.DB, config Config, l *ldap.Conn) error { + user := dbc.GetUserByName(username) + if user != nil { + return nil + } + + if !config.IsSetup() { + return nil + } + + isAdmin := doesLDAPAdminExist(username, config, l) + log.Println(username, isAdmin) + + if !doesLDAPUserExist(username, config, l) { + return errors.New("no such user") + } + + newUser := db.User{ + Name: username, + Password: "", // no password because we want auth to fail. + IsAdmin: isAdmin, + } + + err := dbc.Create(&newUser).Error + if err != nil { + return err + } + + log.Println("User created via LDAP:", username) + return nil +} + +// doesLDAPAdminExist checks if an admin exists on the server. +func doesLDAPAdminExist(username string, config Config, l *ldap.Conn) bool { + filter := fmt.Sprintf("(&(%s=%s)%s)", config.UsernameAttr, ldap.EscapeFilter(username), config.AdminFilter) + + searchReq := ldap.NewSearchRequest( + config.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + []string{"dn"}, + nil, + ) + + result, err := l.Search(searchReq) + if err != nil { + log.Println("failed to query LDAP server:", err) + return false + } + + if len(result.Entries) == 1 { + return true + } + + return false +} + +// doesLDAPUserExist checks if a user exists on the server. +func doesLDAPUserExist(username string, config Config, l *ldap.Conn) bool { + filter := fmt.Sprintf("(&(%s=%s)%s)", config.UsernameAttr, ldap.EscapeFilter(username), config.Filter) + + searchReq := ldap.NewSearchRequest( + config.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + filter, + []string{"dn"}, + nil, + ) + + result, err := l.Search(searchReq) + if err != nil { + log.Println("failed to query LDAP server:", err) + return false + } + + if len(result.Entries) == 1 { + return true + } + + return false +} + +// Creates a connection to an LDAP server. +func createLDAPconnection(config Config) (*ldap.Conn, error) { + protocol := "ldap" + if config.TLS { + protocol = "ldaps" + } + + // Now, we can try to connect to the LDAP server. + l, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", protocol, config.FQDN, config.Port)) + if err != nil { + // Warn the server and return the error. + log.Println("Failed to connect to LDAP server", err) + return nil, err + } + + // After we have a connection, let's try binding + _, err = l.SimpleBind(&ldap.SimpleBindRequest{ + Username: fmt.Sprintf("%s=%s,%s", config.UsernameAttr, config.BindUser, config.BaseDN), + Password: config.BindPass, + }) + if err != nil { + log.Println("Failed to bind to LDAP:", err) + return nil, errors.New("wrong username or password") + } + + return l, nil +} diff --git a/listenbrainz/listenbrainz.go b/listenbrainz/listenbrainz.go index 8d1da4515..1bd508280 100644 --- a/listenbrainz/listenbrainz.go +++ b/listenbrainz/listenbrainz.go @@ -30,7 +30,13 @@ type Client struct { } func NewClient() *Client { - return NewClientCustom(http.DefaultClient) + // disable keep-alive to avoid "connection reset by peer" errors caused by a race between + // the server closing idle connections and the client reusing them for new requests + return NewClientCustom(&http.Client{ + Transport: &http.Transport{ + DisableKeepAlives: true, + }, + }) } func NewClientCustom(httpClient *http.Client) *Client { diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index 3722f7223..e174a5f39 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -27,6 +27,7 @@ import ( "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/handlerutil" "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/ldap" "go.senan.xyz/gonic/podcast" "go.senan.xyz/gonic/scanner" "go.senan.xyz/gonic/server/ctrladmin/adminui" @@ -48,11 +49,13 @@ type Controller struct { podcasts *podcast.Podcasts lastfmClient *lastfm.Client resolveProxyPath ProxyPathResolver + ldapConfig ldap.Config + ldapStore ldap.LDAPStore } type ProxyPathResolver func(in string) string -func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts *podcast.Podcasts, lastfmClient *lastfm.Client, resolveProxyPath ProxyPathResolver) (*Controller, error) { +func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts *podcast.Podcasts, lastfmClient *lastfm.Client, resolveProxyPath ProxyPathResolver, ldapConfig ldap.Config, ldapStore ldap.LDAPStore) (*Controller, error) { c := Controller{ ServeMux: http.NewServeMux(), @@ -62,6 +65,8 @@ func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts podcasts: podcasts, lastfmClient: lastfmClient, resolveProxyPath: resolveProxyPath, + ldapConfig: ldapConfig, + ldapStore: ldapStore, } resp := respHandler(adminui.TemplatesFS, resolveProxyPath) diff --git a/server/ctrladmin/handlers_raw.go b/server/ctrladmin/handlers_raw.go index 49d9e2a84..4099651f7 100644 --- a/server/ctrladmin/handlers_raw.go +++ b/server/ctrladmin/handlers_raw.go @@ -1,28 +1,48 @@ package ctrladmin import ( + "log" "net/http" "github.com/gorilla/sessions" + "go.senan.xyz/gonic/ldap" ) func (c *Controller) ServeLoginDo(w http.ResponseWriter, r *http.Request) { session := r.Context().Value(CtxSession).(*sessions.Session) username := r.FormValue("username") password := r.FormValue("password") + user := c.dbc.GetUserByName(username) if username == "" || password == "" { sessAddFlashW(session, []string{"please provide username and password"}) sessLogSave(session, w, r) http.Redirect(w, r, r.Referer(), http.StatusSeeOther) return } - user := c.dbc.GetUserByName(username) - if user == nil || password != user.Password { + + if c.ldapConfig.IsSetup() { + ok, err := ldap.CheckLDAPcreds(username, password, c.dbc, c.ldapConfig, c.ldapStore) + if err != nil { + log.Println("Failed to check LDAP credentials:", err) + sessAddFlashW(session, []string{"failed to check LDAP credentials"}) + sessLogSave(session, w, r) + http.Redirect(w, r, r.Referer(), http.StatusSeeOther) + return + } else if !ok { + sessAddFlashW(session, []string{"invalid username / password"}) + sessLogSave(session, w, r) + http.Redirect(w, r, r.Referer(), http.StatusSeeOther) + return + } + } else if user == nil || user.Password != password { sessAddFlashW(session, []string{"invalid username / password"}) sessLogSave(session, w, r) http.Redirect(w, r, r.Referer(), http.StatusSeeOther) return } + + user = c.dbc.GetUserByName(username) + // put the user name into the session. future endpoints after this one // are wrapped with WithUserSession() which will get the name from the // session and put the row into the request context diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index a548909eb..424335366 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -10,6 +10,7 @@ import ( "io" "log" "net/http" + "strings" "time" "go.senan.xyz/gonic/db" @@ -18,6 +19,7 @@ import ( "go.senan.xyz/gonic/infocache/artistinfocache" "go.senan.xyz/gonic/jukebox" "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/ldap" "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/podcast" "go.senan.xyz/gonic/scanner" @@ -72,7 +74,7 @@ type Controller struct { resolveProxyPath ProxyPathResolver } -func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPath string, cacheAudioPath string, cacheCoverPath string, jukebox *jukebox.Jukebox, playlistStore *playlist.Store, scrobblers []scrobble.Scrobbler, podcasts *podcast.Podcasts, transcoder transcode.Transcoder, lastFMClient *lastfm.Client, artistInfoCache *artistinfocache.ArtistInfoCache, albumInfoCache *albuminfocache.AlbumInfoCache, tagReader tags.Reader, resolveProxyPath ProxyPathResolver) (*Controller, error) { +func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPath string, cacheAudioPath string, cacheCoverPath string, jukebox *jukebox.Jukebox, playlistStore *playlist.Store, scrobblers []scrobble.Scrobbler, podcasts *podcast.Podcasts, transcoder transcode.Transcoder, lastFMClient *lastfm.Client, artistInfoCache *artistinfocache.ArtistInfoCache, albumInfoCache *albuminfocache.AlbumInfoCache, tagReader tags.Reader, resolveProxyPath ProxyPathResolver, ldapConfig ldap.Config, ldapStore ldap.LDAPStore) (*Controller, error) { c := Controller{ ServeMux: http.NewServeMux(), @@ -98,7 +100,7 @@ func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPa chain := handlerutil.Chain( withParams, withRequiredParams, - withUser(dbc), + withUser(dbc, ldapConfig, ldapStore), ) chainRaw := handlerutil.Chain( chain, @@ -229,39 +231,82 @@ func withRequiredParams(next http.Handler) http.Handler { }) } -func withUser(dbc *db.DB) handlerutil.Middleware { +func withUser(dbc *db.DB, ldapConfig ldap.Config, ldapStore ldap.LDAPStore) handlerutil.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { params := r.Context().Value(CtxParams).(params.Params) // ignoring errors here, a middleware has already ensured they exist username, _ := params.Get("u") - password, _ := params.Get("p") + passwordHex, _ := params.Get("p") token, _ := params.Get("t") salt, _ := params.Get("s") passwordAuth := token == "" && salt == "" - tokenAuth := password == "" + tokenAuth := passwordHex == "" if tokenAuth == passwordAuth { _ = writeResp(w, r, spec.NewError(10, "please provide `t` and `s`, or just `p`")) return } + + var password string + if passwordAuth && passwordHex != "" { + if strings.HasPrefix(passwordHex, "enc:") { + raw := strings.TrimPrefix(passwordHex, "enc:") + decoded, err := hex.DecodeString(raw) + if err != nil { + log.Println("Failed to decode hex password:", err) + _ = writeResp(w, r, spec.NewError(40, "invalid password encoding")) + return + } + password = string(decoded) + } else { + // if not prefixed with "enc:", treat as plain text + password = passwordHex + } + } + user := dbc.GetUserByName(username) + + if ldapConfig.IsSetup() { + // Complete auth using LDAP + log.Println("Authenticating using LDAP ...") + + ok, err := ldap.CheckLDAPcreds(username, password, dbc, ldapConfig, ldapStore) + if err != nil { + log.Println("Failed to check LDAP creds:", err) + _ = writeResp(w, r, spec.NewError(40, "invalid password")) + return + } + + if !ok { + _ = writeResp(w, r, spec.NewError(40, "invalid password")) + return + } + + withUser := context.WithValue(r.Context(), CtxUser, user) + next.ServeHTTP(w, r.WithContext(withUser)) + return + } + + log.Println("Authenticating using built-in ...") if user == nil { - _ = writeResp(w, r, spec.NewError(40, - "invalid username %q", username)) + _ = writeResp(w, r, spec.NewError(40, "invalid password")) return } + var credsOk bool if tokenAuth { credsOk = checkCredsToken(user.Password, token, salt) } else { credsOk = checkCredsBasic(user.Password, password) } + if !credsOk { _ = writeResp(w, r, spec.NewError(40, "invalid password")) return } + withUser := context.WithValue(r.Context(), CtxUser, user) next.ServeHTTP(w, r.WithContext(withUser)) })