Authenticate Subsonic API calls using the DB

This commit is contained in:
Deluan 2020-01-20 13:35:59 -05:00
parent 50c7d3800a
commit 99c28731d4
13 changed files with 210 additions and 93 deletions

View File

@ -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

View File

@ -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"`

58
engine/users.go Normal file
View File

@ -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
}

52
engine/users_test.go Normal file
View File

@ -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))
})
})
})
})

View File

@ -11,4 +11,5 @@ var Set = wire.NewSet(
NewScrobbler,
NewSearch,
NewNowPlayingRepository,
NewUsers,
)

View File

@ -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

View File

@ -21,4 +21,5 @@ type UserRepository interface {
Put(*User) error
FindByUsername(username string) (*User, error)
UpdateLastLoginAt(id string) error
UpdateLastAccessAt(id string) error
}

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}