From 99c28731d42c1201d72bcd591255a8e8b461f28c Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 20 Jan 2020 13:35:59 -0500 Subject: [PATCH] Authenticate Subsonic API calls using the DB --- README.md | 14 ++--- conf/configuration.go | 5 +- engine/users.go | 58 ++++++++++++++++++++ engine/users_test.go | 52 ++++++++++++++++++ engine/wire_providers.go | 1 + model/model.go | 3 +- model/user.go | 1 + persistence/mock_persistence.go | 18 ++++++- persistence/user_repository.go | 7 +++ server/subsonic/api.go | 7 +-- server/subsonic/middlewares.go | 52 ++++++++---------- server/subsonic/middlewares_test.go | 82 ++++++++++++----------------- wire_gen.go | 3 +- 13 files changed, 210 insertions(+), 93 deletions(-) create mode 100644 engine/users.go create mode 100644 engine/users_test.go diff --git a/README.md b/README.md index f8e61abe1..becd88ddb 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,16 @@ $ export SONIC_MUSICFOLDER="/path/to/your/music/folder" $ make ``` -The server should start listening for requests. The default configuration is: +The server should start listening for requests on the default port 4533. The first time you start the app it will +create a new user "admin" with a random password. Check the logs for a line like this: +``` +Creating initial user. Please change the password! password=be32e686-d59b-4f57-b780-d04dc5e9cf04 user=admin +``` -- Port: `4533` -- User: `admin` -- Password: `admin` +You can change this password using the UI. Just login in with this temporary password at http://localhost:4533 -To override this or any other configuration, create a file named `sonic.toml` in the project folder. - For all options see the [configuration.go](conf/configuration.go) file +To change any configuration, create a file named `sonic.toml` in the project folder. For all options see the +[configuration.go](conf/configuration.go) file ### Development Environment diff --git a/conf/configuration.go b/conf/configuration.go index edc70c7a3..1b095185e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -9,15 +9,12 @@ import ( type sonic struct { Port string `default:"4533"` - MusicFolder string `default:"./iTunes1.xml"` + MusicFolder string `default:"./music"` DbPath string `default:"./data/cloudsonic.db"` IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"` IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"` - User string `default:"admin"` - Password string `default:"admin"` - DisableDownsampling bool `default:"false"` DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"` ProbeCommand string `default:"ffprobe -v quiet -print_format json -show_format %s"` diff --git a/engine/users.go b/engine/users.go new file mode 100644 index 000000000..092630f07 --- /dev/null +++ b/engine/users.go @@ -0,0 +1,58 @@ +package engine + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "strings" + + "github.com/cloudsonic/sonic-server/log" + "github.com/cloudsonic/sonic-server/model" +) + +type Users interface { + Authenticate(username, password, token, salt string) (*model.User, error) +} + +func NewUsers(ds model.DataStore) Users { + return &users{ds} +} + +type users struct { + ds model.DataStore +} + +func (u *users) Authenticate(username, pass, token, salt string) (*model.User, error) { + user, err := u.ds.User().FindByUsername(username) + if err == model.ErrNotFound { + return nil, model.ErrInvalidAuth + } + if err != nil { + return nil, err + } + valid := false + + switch { + case pass != "": + if strings.HasPrefix(pass, "enc:") { + if dec, err := hex.DecodeString(pass[4:]); err == nil { + pass = string(dec) + } + } + valid = pass == user.Password + case token != "": + t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt))) + valid = t == token + } + + if !valid { + return nil, model.ErrInvalidAuth + } + go func() { + err := u.ds.User().UpdateLastAccessAt(user.ID) + if err != nil { + log.Error("Could not update user's lastAccessAt", "user", user.UserName) + } + }() + return user, nil +} diff --git a/engine/users_test.go b/engine/users_test.go new file mode 100644 index 000000000..4386d0a0e --- /dev/null +++ b/engine/users_test.go @@ -0,0 +1,52 @@ +package engine + +import ( + "github.com/cloudsonic/sonic-server/model" + "github.com/cloudsonic/sonic-server/persistence" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Users", func() { + Describe("Authenticate", func() { + var users Users + BeforeEach(func() { + ds := &persistence.MockDataStore{} + users = NewUsers(ds) + }) + + Context("Plaintext password", func() { + It("authenticates with plaintext password ", func() { + usr, err := users.Authenticate("admin", "wordpass", "", "") + Expect(err).NotTo(HaveOccurred()) + Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"})) + }) + + It("fails authentication with wrong password", func() { + _, err := users.Authenticate("admin", "INVALID", "", "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Context("Encoded password", func() { + It("authenticates with simple encoded password ", func() { + usr, err := users.Authenticate("admin", "enc:776f726470617373", "", "") + Expect(err).NotTo(HaveOccurred()) + Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"})) + }) + }) + + Context("Token based authentication", func() { + It("authenticates with token based authentication", func() { + usr, err := users.Authenticate("admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt") + Expect(err).NotTo(HaveOccurred()) + Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"})) + }) + + It("fails if salt is missing", func() { + _, err := users.Authenticate("admin", "", "23b342970e25c7928831c3317edd0b67", "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + }) +}) diff --git a/engine/wire_providers.go b/engine/wire_providers.go index 9efa60e5e..caf85a03d 100644 --- a/engine/wire_providers.go +++ b/engine/wire_providers.go @@ -11,4 +11,5 @@ var Set = wire.NewSet( NewScrobbler, NewSearch, NewNowPlayingRepository, + NewUsers, ) diff --git a/model/model.go b/model/model.go index 9808356c8..aa6570571 100644 --- a/model/model.go +++ b/model/model.go @@ -7,7 +7,8 @@ import ( ) var ( - ErrNotFound = errors.New("data not found") + ErrNotFound = errors.New("data not found") + ErrInvalidAuth = errors.New("invalid authentication") ) // Filters use the same operators as Beego ORM: See https://beego.me/docs/mvc/model/query.md#operators diff --git a/model/user.go b/model/user.go index f59e8195e..90af1de41 100644 --- a/model/user.go +++ b/model/user.go @@ -21,4 +21,5 @@ type UserRepository interface { Put(*User) error FindByUsername(username string) (*User, error) UpdateLastLoginAt(id string) error + UpdateLastAccessAt(id string) error } diff --git a/persistence/mock_persistence.go b/persistence/mock_persistence.go index 37614047e..7ae65e2f8 100644 --- a/persistence/mock_persistence.go +++ b/persistence/mock_persistence.go @@ -7,6 +7,7 @@ type MockDataStore struct { MockedAlbum model.AlbumRepository MockedArtist model.ArtistRepository MockedMediaFile model.MediaFileRepository + MockedUser model.UserRepository } func (db *MockDataStore) Album() model.AlbumRepository { @@ -50,7 +51,10 @@ func (db *MockDataStore) Property() model.PropertyRepository { } func (db *MockDataStore) User() model.UserRepository { - return struct{ model.UserRepository }{} + if db.MockedUser == nil { + db.MockedUser = &mockedUserRepo{} + } + return db.MockedUser } func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error { @@ -60,3 +64,15 @@ func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error { func (db *MockDataStore) Resource(m interface{}) model.ResourceRepository { return struct{ model.ResourceRepository }{} } + +type mockedUserRepo struct { + model.UserRepository +} + +func (u *mockedUserRepo) FindByUsername(username string) (*model.User, error) { + return &model.User{UserName: "admin", Password: "wordpass"}, nil +} + +func (u *mockedUserRepo) UpdateLastAccessAt(id string) error { + return nil +} diff --git a/persistence/user_repository.go b/persistence/user_repository.go index e2d8e1062..833794878 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -81,5 +81,12 @@ func (r *userRepository) UpdateLastLoginAt(id string) error { return err } +func (r *userRepository) UpdateLastAccessAt(id string) error { + now := time.Now() + tu := user{ID: id, LastAccessAt: &now} + _, err := r.ormer.Update(&tu, "last_access_at") + return err +} + var _ = model.User(user{}) var _ model.UserRepository = (*userRepository)(nil) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index a6f019094..7e4c77832 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -24,15 +24,16 @@ type Router struct { Ratings engine.Ratings Scrobbler engine.Scrobbler Search engine.Search + Users engine.Users mux http.Handler } -func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, +func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users, playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search) *Router { r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists, - Ratings: ratings, Scrobbler: scrobbler, Search: search} + Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users} r.mux = r.routes() return r } @@ -48,7 +49,7 @@ func (api *Router) routes() http.Handler { // Add validation middleware if not disabled if !conf.Sonic.DevDisableAuthentication { - r.Use(authenticate) + r.Use(authenticate(api.Users)) // TODO Validate version } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index f36910a45..9e146634e 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -2,14 +2,12 @@ package subsonic import ( "context" - "crypto/md5" - "encoding/hex" "fmt" "net/http" - "strings" - "github.com/cloudsonic/sonic-server/conf" + "github.com/cloudsonic/sonic-server/engine" "github.com/cloudsonic/sonic-server/log" + "github.com/cloudsonic/sonic-server/model" "github.com/cloudsonic/sonic-server/server/subsonic/responses" ) @@ -44,36 +42,30 @@ func checkRequiredParameters(next http.Handler) http.Handler { }) } -func authenticate(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - password := conf.Sonic.Password - user := ParamString(r, "u") - pass := ParamString(r, "p") - salt := ParamString(r, "s") - token := ParamString(r, "t") - valid := false +func authenticate(users engine.Users) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := ParamString(r, "u") + pass := ParamString(r, "p") + token := ParamString(r, "t") + salt := ParamString(r, "s") - switch { - case pass != "": - if strings.HasPrefix(pass, "enc:") { - if dec, err := hex.DecodeString(pass[4:]); err == nil { - pass = string(dec) - } + _, err := users.Authenticate(user, pass, token, salt) + if err == model.ErrInvalidAuth { + log.Warn(r, "Invalid login", "user", user, err) + } else if err != nil { + log.Error(r, "Error authenticating user", "user", user, err) } - valid = pass == password - case token != "": - t := fmt.Sprintf("%x", md5.Sum([]byte(password+salt))) - valid = t == token - } - if user != conf.Sonic.User || !valid { - log.Warn(r, "Invalid login", "user", user) - SendError(w, r, NewError(responses.ErrorAuthenticationFail)) - return - } + if err != nil { + log.Warn(r, "Invalid login", "user", user) + SendError(w, r, NewError(responses.ErrorAuthenticationFail)) + return + } - next.ServeHTTP(w, r) - }) + next.ServeHTTP(w, r) + }) + } } func requiredParams(params ...string) func(next http.Handler) http.Handler { diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index bf3dc9156..63f7991f3 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -5,8 +5,9 @@ import ( "net/http/httptest" "strings" - "github.com/cloudsonic/sonic-server/conf" + "github.com/cloudsonic/sonic-server/engine" "github.com/cloudsonic/sonic-server/log" + "github.com/cloudsonic/sonic-server/model" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) @@ -67,60 +68,31 @@ var _ = Describe("Middlewares", func() { }) Describe("Authenticate", func() { + var mockedUser *mockUsers BeforeEach(func() { - conf.Sonic.User = "admin" - conf.Sonic.Password = "wordpass" - conf.Sonic.DevDisableAuthentication = false + mockedUser = &mockUsers{} }) - Context("Plaintext password", func() { - It("authenticates with plaintext password ", func() { - r := newTestRequest("u=admin", "p=wordpass") - cp := authenticate(next) - cp.ServeHTTP(w, r) + It("passes all parameters to users.Authenticate ", func() { + r := newTestRequest("u=valid", "p=password", "t=token", "s=salt") + cp := authenticate(mockedUser)(next) + cp.ServeHTTP(w, r) - Expect(next.called).To(BeTrue()) - }) - - It("fails authentication with wrong password", func() { - r := newTestRequest("u=admin", "p=INVALID") - cp := authenticate(next) - cp.ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) + Expect(mockedUser.username).To(Equal("valid")) + Expect(mockedUser.password).To(Equal("password")) + Expect(mockedUser.token).To(Equal("token")) + Expect(mockedUser.salt).To(Equal("salt")) + Expect(next.called).To(BeTrue()) }) - Context("Encoded password", func() { - It("authenticates with simple encoded password ", func() { - r := newTestRequest("u=admin", "p=enc:776f726470617373") - cp := authenticate(next) - cp.ServeHTTP(w, r) + It("fails authentication with wrong password", func() { + r := newTestRequest("u=invalid", "", "", "") + cp := authenticate(mockedUser)(next) + cp.ServeHTTP(w, r) - Expect(next.called).To(BeTrue()) - }) + Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(next.called).To(BeFalse()) }) - - Context("Token based authentication", func() { - It("authenticates with token based authentication", func() { - r := newTestRequest("u=admin", "t=23b342970e25c7928831c3317edd0b67", "s=retnlmjetrymazgkt") - cp := authenticate(next) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - }) - - It("fails if salt is missing", func() { - r := newTestRequest("u=admin", "t=23b342970e25c7928831c3317edd0b67") - cp := authenticate(next) - cp.ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - }) - }) }) @@ -133,3 +105,19 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { mh.req = r mh.called = true } + +type mockUsers struct { + engine.Users + username, password, token, salt string +} + +func (m *mockUsers) Authenticate(username, password, token, salt string) (*model.User, error) { + m.username = username + m.password = password + m.token = token + m.salt = salt + if username == "valid" { + return &model.User{UserName: username, Password: password}, nil + } + return nil, model.ErrInvalidAuth +} diff --git a/wire_gen.go b/wire_gen.go index b2174e444..c151ff6aa 100644 --- a/wire_gen.go +++ b/wire_gen.go @@ -36,11 +36,12 @@ func CreateSubsonicAPIRouter() *subsonic.Router { cover := engine.NewCover(dataStore) nowPlayingRepository := engine.NewNowPlayingRepository() listGenerator := engine.NewListGenerator(dataStore, nowPlayingRepository) + users := engine.NewUsers(dataStore) playlists := engine.NewPlaylists(dataStore) ratings := engine.NewRatings(dataStore) scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository) search := engine.NewSearch(dataStore) - router := subsonic.New(browser, cover, listGenerator, playlists, ratings, scrobbler, search) + router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search) return router }