From 362a8826b93f58714414ed87f2d87d9b1c8cff2b Mon Sep 17 00:00:00 2001 From: Kartik Ohri Date: Sat, 3 May 2025 15:02:23 +0530 Subject: [PATCH] Implement OpenSubsonic's API Key authentication Ref: https://opensubsonic.netlify.app/docs/extensions/apikeyauth/. Add functionality for users to create, manage, and use API keys for authentication. * **Database:** * Added a new `api_key`(`20250415111500_add_api_key_table.go`) table to store API keys associated with users. * **Backend:** * Defined the `APIKey` model. * Implemented `APIKeyRepository` for database operations. * Added `FindByAPIKey` method to `UserRepository` to allow user lookup via API key. * Updated Subsonic API error responses to include API key-related errors and messaging. * Integrated API key authentication into the subsonic authentication middleware. * **UI:** * Added new React components for API Key CRUD operations: `ApiKeyList`, `ApiKeyCreate`, and `ApiKeyEdit`. * The API Key management section can be accessed from the settings menu. # Conflicts: # model/user.go # server/subsonic/middlewares.go # server/subsonic/middlewares_test.go # tests/mock_data_store.go # tests/mock_user_repo.go --- .../20250415111500_add_api_key_table.go | 39 ++++ model/api_key.go | 26 +++ model/datastore.go | 1 + model/user.go | 2 + persistence/api_key_repository.go | 158 +++++++++++++ persistence/api_key_repository_test.go | 216 ++++++++++++++++++ persistence/persistence.go | 6 + persistence/user_repository.go | 12 + server/nativeapi/native_api.go | 1 + server/subsonic/middlewares.go | 66 +++++- server/subsonic/middlewares_test.go | 59 +++++ server/subsonic/responses/errors.go | 38 +-- tests/mock_apikey_repo.go | 57 +++++ tests/mock_data_store.go | 15 +- tests/mock_user_repo.go | 29 ++- ui/src/App.jsx | 6 + ui/src/apikey/ApiKeyCreate.jsx | 57 +++++ ui/src/apikey/ApiKeyEdit.jsx | 75 ++++++ ui/src/apikey/ApiKeyList.jsx | 58 +++++ ui/src/apikey/index.js | 11 + ui/src/i18n/en.json | 16 ++ ui/src/layout/AppBar.jsx | 10 + ui/src/layout/Menu.jsx | 5 +- 23 files changed, 931 insertions(+), 32 deletions(-) create mode 100644 db/migrations/20250415111500_add_api_key_table.go create mode 100644 model/api_key.go create mode 100644 persistence/api_key_repository.go create mode 100644 persistence/api_key_repository_test.go create mode 100644 tests/mock_apikey_repo.go create mode 100644 ui/src/apikey/ApiKeyCreate.jsx create mode 100644 ui/src/apikey/ApiKeyEdit.jsx create mode 100644 ui/src/apikey/ApiKeyList.jsx create mode 100644 ui/src/apikey/index.js diff --git a/db/migrations/20250415111500_add_api_key_table.go b/db/migrations/20250415111500_add_api_key_table.go new file mode 100644 index 000000000..d75ddd19f --- /dev/null +++ b/db/migrations/20250415111500_add_api_key_table.go @@ -0,0 +1,39 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddApiKeyTable, downAddApiKeyTable) +} + +func upAddApiKeyTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + create table if not exists api_key ( + id text not null primary key, + user_id text not null, + name text not null, + key text not null unique, + created_at datetime not null, + + foreign key (user_id) + references user(id) + on delete cascade + ); + + create index if not exists api_key_key on api_key(key); + create index if not exists api_key_user_id on api_key(user_id); +`) + return err +} + +func downAddApiKeyTable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + drop table api_key; +`) + return err +} diff --git a/model/api_key.go b/model/api_key.go new file mode 100644 index 000000000..6d238f422 --- /dev/null +++ b/model/api_key.go @@ -0,0 +1,26 @@ +package model + +import ( + "github.com/deluan/rest" + "time" +) + +type APIKey struct { + ID string `structs:"id" json:"id"` + UserID string `structs:"user_id" json:"userId"` + Name string `structs:"name" json:"name"` + Key string `structs:"key" json:"key"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type APIKeys []APIKey + +type APIKeyRepository interface { + ResourceRepository + rest.Persistable + CountAll(...QueryOptions) (int64, error) + Get(id string) (*APIKey, error) + GetAll(options ...QueryOptions) (APIKeys, error) + Put(*APIKey) error + FindByKey(key string) (*APIKey, error) +} diff --git a/model/datastore.go b/model/datastore.go index 4290e2134..ebd99e25b 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -38,6 +38,7 @@ type DataStore interface { User(ctx context.Context) UserRepository UserProps(ctx context.Context) UserPropsRepository ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository + APIKey(ctx context.Context) APIKeyRepository Resource(ctx context.Context, model interface{}) ResourceRepository diff --git a/model/user.go b/model/user.go index aabedc096..042ac8d60 100644 --- a/model/user.go +++ b/model/user.go @@ -56,4 +56,6 @@ type UserRepository interface { // Library association methods GetUserLibraries(userID string) (Libraries, error) SetUserLibraries(userID string, libraryIDs []int) error + // FindByAPIKey finds a user by the provided API key + FindByAPIKey(key string) (*User, error) } diff --git a/persistence/api_key_repository.go b/persistence/api_key_repository.go new file mode 100644 index 000000000..583578d3c --- /dev/null +++ b/persistence/api_key_repository.go @@ -0,0 +1,158 @@ +package persistence + +import ( + "context" + "time" + + "github.com/deluan/rest" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type apiKeyRepository struct { + sqlRepository +} + +func NewAPIKeyRepository(ctx context.Context, db dbx.Builder) model.APIKeyRepository { + r := &apiKeyRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.APIKey{}, nil) + return r +} + +func (r *apiKeyRepository) userFilter() Sqlizer { + user := loggedUser(r.ctx) + if user.IsAdmin { + return And{} + } + return Eq{"user_id": user.ID} +} + +func (r *apiKeyRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sq := Select().From(r.tableName).Where(r.userFilter()) + return r.count(sq, options...) +} + +func (r *apiKeyRepository) Get(id string) (*model.APIKey, error) { + sel := r.newSelect().Columns("*").Where(And{Eq{"id": id}}) + var res model.APIKey + err := r.queryOne(sel, &res) + if err != nil { + return nil, err + } + return &res, err +} + +func (r *apiKeyRepository) GetAll(options ...model.QueryOptions) (model.APIKeys, error) { + sel := r.newSelect(options...).Columns("*").Where(r.userFilter()) + res := model.APIKeys{} + err := r.queryAll(sel, &res) + if err != nil { + return nil, err + } + return res, err +} + +func (r *apiKeyRepository) Put(ak *model.APIKey) error { + if ak.ID == "" { + ak.ID = id.NewRandom() + } + ak.CreatedAt = time.Now() + values, err := toSQLArgs(*ak) + if err != nil { + return err + } + insert := Insert(r.tableName).SetMap(values) + _, err = r.executeSQL(insert) + return err +} + +func (r *apiKeyRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *apiKeyRepository) Read(id string) (interface{}, error) { + user := loggedUser(r.ctx) + apiKey, err := r.Get(id) + if err != nil { + return nil, err + } + if !user.IsAdmin && apiKey.UserID != user.ID { + return nil, rest.ErrPermissionDenied + } + return apiKey, err +} + +func (r *apiKeyRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *apiKeyRepository) EntityName() string { + return "apikey" +} + +func (r *apiKeyRepository) NewInstance() interface{} { + return &model.APIKey{} +} + +func (r *apiKeyRepository) Save(entity interface{}) (string, error) { + ak := entity.(*model.APIKey) + user := loggedUser(r.ctx) + ak.UserID = user.ID + // prefix API keys with nav_ + ak.Key = "nav_" + id.NewRandom() + err := r.Put(ak) + if err != nil { + return "", err + } + return ak.ID, err +} + +func (r *apiKeyRepository) Update(id string, entity interface{}, _ ...string) error { + ak := entity.(*model.APIKey) + current, err := r.Get(id) + if err != nil { + return err + } + user := loggedUser(r.ctx) + if !user.IsAdmin && current.UserID != user.ID { + return rest.ErrPermissionDenied + } + + // Only allow updating name + update := Update(r.tableName). + Set("name", ak.Name). + Where(Eq{"id": id}) + _, err = r.executeSQL(update) + return err +} + +func (r *apiKeyRepository) Delete(id string) error { + user := loggedUser(r.ctx) + apiKey, err := r.Get(id) + if err != nil { + return err + } + if !user.IsAdmin && apiKey.UserID != user.ID { + return rest.ErrPermissionDenied + } + return r.delete(Eq{"id": id}) +} + +func (r *apiKeyRepository) FindByKey(key string) (*model.APIKey, error) { + sel := r.newSelect().Columns("*").Where(Eq{"key": key}) + var res model.APIKey + err := r.queryOne(sel, &res) + if err != nil { + return nil, err + } + return &res, err +} + +var _ model.APIKeyRepository = (*apiKeyRepository)(nil) +var _ rest.Repository = (*apiKeyRepository)(nil) +var _ rest.Persistable = (*apiKeyRepository)(nil) diff --git a/persistence/api_key_repository_test.go b/persistence/api_key_repository_test.go new file mode 100644 index 000000000..44ab6f0cc --- /dev/null +++ b/persistence/api_key_repository_test.go @@ -0,0 +1,216 @@ +package persistence + +import ( + "context" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("APIKeyRepository", func() { + var repo model.APIKeyRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewAPIKeyRepository(ctx, GetDBXBuilder()) + }) + + Describe("Put", func() { + It("sets an ID if it is not set", func() { + apiKey := &model.APIKey{ + UserID: "userid", + Name: "Test API Key", + Key: "test-key", + } + + err := repo.Put(apiKey) + + Expect(err).ToNot(HaveOccurred()) + Expect(apiKey.ID).ToNot(BeEmpty()) + Expect(apiKey.CreatedAt).ToNot(BeZero()) + }) + + It("keeps existing values", func() { + apiKey := &model.APIKey{ + ID: "existing-id", + UserID: "userid", + Name: "Test API Key 2", + Key: "test-key-2", + } + + err := repo.Put(apiKey) + + Expect(err).ToNot(HaveOccurred()) + Expect(apiKey.ID).To(Equal("existing-id")) + Expect(apiKey.CreatedAt).ToNot(BeZero()) + }) + }) + + Describe("FindByKey", func() { + It("returns the API key with matching key", func() { + apiKey := &model.APIKey{ + UserID: "userid", + Name: "Unique API Key", + Key: "unique-test-key", + } + + err := repo.Put(apiKey) + Expect(err).ToNot(HaveOccurred()) + + result, err := repo.FindByKey("unique-test-key") + + Expect(err).ToNot(HaveOccurred()) + Expect(result.ID).To(Equal(apiKey.ID)) + Expect(result.Key).To(Equal("unique-test-key")) + }) + + It("returns error when key not found", func() { + _, err := repo.FindByKey("non-existent-key") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("Save", func() { + It("creates a new API key with a generated key", func() { + apiKey := &model.APIKey{ + Name: "Test API Key Save", + } + + id, err := repo.Save(apiKey) + + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeEmpty()) + Expect(apiKey.Key).To(HavePrefix("nav_")) + Expect(apiKey.UserID).To(Equal("userid")) + }) + }) + + Describe("Update", func() { + It("only updates the name field", func() { + apiKey := &model.APIKey{ + UserID: "userid", + Name: "Original Name", + Key: "test-key-for-update", + } + + err := repo.Put(apiKey) + Expect(err).ToNot(HaveOccurred()) + + updateKey := &model.APIKey{ + Name: "Updated Name", + Key: "should-not-change", + UserID: "2222", + } + + err = repo.Update(apiKey.ID, updateKey) + Expect(err).ToNot(HaveOccurred()) + + result, err := repo.Get(apiKey.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Name).To(Equal("Updated Name")) + Expect(result.Key).To(Equal("test-key-for-update")) + Expect(result.UserID).To(Equal("userid")) + }) + + It("returns error when attempting to update non-existent key", func() { + err := repo.Update("non-existent-id", &model.APIKey{Name: "Updated Name"}) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("Delete", func() { + It("deletes an existing API key", func() { + apiKey := &model.APIKey{ + UserID: "userid", + Name: "API Key to Delete", + Key: "key-to-delete", + } + + err := repo.Put(apiKey) + Expect(err).ToNot(HaveOccurred()) + + err = repo.Delete(apiKey.ID) + Expect(err).ToNot(HaveOccurred()) + + _, err = repo.Get(apiKey.ID) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("User permissions", func() { + var nonAdminCtx context.Context + var nonAdminRepo model.APIKeyRepository + var adminKey model.APIKey + + BeforeEach(func() { + nonAdminCtx = log.NewContext(context.TODO()) + nonAdminCtx = context.WithValue(nonAdminCtx, "user", model.User{ID: "2222", UserName: "user", IsAdmin: false}) + nonAdminRepo = NewAPIKeyRepository(nonAdminCtx, GetDBXBuilder()) + + cleanupKeys := func(key string) { + foundKey, err := repo.FindByKey(key) + if err == nil { + _ = repo.Delete(foundKey.ID) + } + } + cleanupKeys("admin-key") + cleanupKeys("user-key") + + tmpAdminKey := &model.APIKey{ + UserID: "userid", + Name: "Admin's API Key", + Key: "admin-key", + } + err := repo.Put(tmpAdminKey) + Expect(err).ToNot(HaveOccurred()) + adminKey = *tmpAdminKey + + userKey := &model.APIKey{ + UserID: "2222", + Name: "User's API Key", + Key: "user-key", + } + err = repo.Put(userKey) + Expect(err).ToNot(HaveOccurred()) + }) + + It("non-admin users can only see their own API keys", func() { + results, err := nonAdminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + + for _, key := range results { + Expect(key.UserID).To(Equal("2222")) + } + }) + + It("admin users can see all API keys", func() { + results, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + + userIds := make(map[string]bool) + for _, key := range results { + userIds[key.UserID] = true + } + + Expect(userIds).To(HaveKey("userid")) + Expect(userIds).To(HaveKey("2222")) + }) + + It("a user cannot view/delete/update another user's key", func() { + result, err := nonAdminRepo.Read(adminKey.ID) + Expect(result).To(BeNil()) + Expect(err).To(MatchError(rest.ErrPermissionDenied)) + + updatedKey := &model.APIKey{Name: "new admin key name"} + err = nonAdminRepo.Update(adminKey.ID, updatedKey) + Expect(err).To(MatchError(rest.ErrPermissionDenied)) + + err = nonAdminRepo.Delete(adminKey.ID) + Expect(err).To(MatchError(rest.ErrPermissionDenied)) + }) + }) +}) diff --git a/persistence/persistence.go b/persistence/persistence.go index ac607f85f..76f89e7ec 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos return NewScrobbleBufferRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) APIKey(ctx context.Context) model.APIKeyRepository { + return NewAPIKeyRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { switch m.(type) { case model.User: @@ -113,6 +117,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe return s.Share(ctx).(model.ResourceRepository) case model.Tag: return s.Tag(ctx).(model.ResourceRepository) + case model.APIKey: + return s.APIKey(ctx).(model.ResourceRepository) } log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) return nil diff --git a/persistence/user_repository.go b/persistence/user_repository.go index a7181b1a7..c675228d3 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -193,6 +193,18 @@ func (r *userRepository) FindByUsernameWithPassword(username string) (*model.Use return usr, nil } +func (r *userRepository) FindByAPIKey(key string) (*model.User, error) { + // find the API key in the database + apiKeyRepo := NewAPIKeyRepository(r.ctx, r.db) + apiKey, err := apiKeyRepo.FindByKey(key) + if err != nil { + return nil, err + } + + // Then get the user associated with this API key + return r.Get(apiKey.UserID) +} + func (r *userRepository) UpdateLastLoginAt(id string) error { upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now()) _, err := r.executeSQL(upd) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 370bdbd1e..c6d6e9c64 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -55,6 +55,7 @@ func (n *Router) routes() http.Handler { n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) n.R(r, "/radio", model.Radio{}, true) n.R(r, "/tag", model.Tag{}, true) + n.R(r, "/apikey", model.APIKey{}, true) if conf.Server.EnableSharing { n.RX(r, "/share", n.share.NewRepository, true) } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index af1ba448f..a39063273 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -66,11 +66,12 @@ func checkRequiredParameters(next http.Handler) http.Handler { username, _ := fromInternalOrProxyAuth(r) if username != "" { requiredParameters = []string{"v", "c"} + } else if apiKey, _ := p.String("apiKey"); apiKey != "" { + requiredParameters = []string{"v", "c"} } else { requiredParameters = []string{"u", "v", "c"} } - p := req.Params(r) for _, param := range requiredParameters { if _, err := p.String(param); err != nil { log.Warn(r, err) @@ -123,21 +124,62 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { token, _ := p.String("t") salt, _ := p.String("s") jwt, _ := p.String("jwt") + apiKey, _ := p.String("apiKey") - usr, err = ds.User(ctx).FindByUsernameWithPassword(username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + // When an API key is provided, username should not be provided + if apiKey != "" && username != "" { + log.Warn(ctx, "API: Invalid login - username provided with API key", "auth", "subsonic", "remoteAddr", r.RemoteAddr) + sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided)) return } - switch { - case errors.Is(err, model.ErrNotFound): - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - case err != nil: - log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - default: - err = validateCredentials(usr, pass, token, salt, jwt) - if err != nil { + + // Check for conflicting authentication mechanisms + authMechanismsCount := 0 + if apiKey != "" { + authMechanismsCount++ + } + if pass != "" { + authMechanismsCount++ + } + if token != "" && salt != "" { + authMechanismsCount++ + } + if jwt != "" { + authMechanismsCount++ + } + if authMechanismsCount > 1 { + log.Warn(ctx, "API: Invalid login - multiple authentication mechanisms", "auth", "subsonic", "remoteAddr", r.RemoteAddr) + sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided)) + return + } + + if apiKey != "" { + usr, err = ds.User(ctx).FindByAPIKey(apiKey) + if errors.Is(err, context.Canceled) { + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr, err) + return + } + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr) + } else if err != nil { + log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr, err) + } + } else { + usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + if errors.Is(err, context.Canceled) { + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + return + } + switch { + case errors.Is(err, model.ErrNotFound): log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + case err != nil: + log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + default: + err = validateCredentials(usr, pass, token, salt, jwt) + if err != nil { + log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + } } } } diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index a30d5b3af..e9f1c0177 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -306,6 +306,65 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeFalse()) }) }) + + When("using api key authentication", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + + ur := ds.User(context.TODO()) + user := &model.User{ + UserName: "user-api", + NewPassword: "wordpass-api", + } + _ = ur.Put(user) + + ar := ds.APIKey(context.TODO()) + apiKey := &model.APIKey{ + ID: "api-key-id", + UserID: user.ID, + Name: "API Key", + Key: "api-key", + } + _ = ar.Put(apiKey) + }) + + It("passes authentication with correct api key", func() { + r := newGetRequest("apiKey=api-key") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + user, _ := request.UserFrom(next.req.Context()) + Expect(user.UserName).To(Equal("user-api")) + }) + + It("fails authentication with invalid api key", func() { + r := newGetRequest("apiKey=invalid-api-key") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(next.called).To(BeFalse()) + }) + + It("fails authentication with empty api key", func() { + r := newGetRequest("apiKey=") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(next.called).To(BeFalse()) + }) + + It("fails authentication if both api key and username are provided", func() { + r := newGetRequest("apiKey=api-key", "u=user-api") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="43"`)) + Expect(next.called).To(BeFalse()) + }) + }) }) Describe("GetPlayer", func() { diff --git a/server/subsonic/responses/errors.go b/server/subsonic/responses/errors.go index 42e5427b3..f869c763e 100644 --- a/server/subsonic/responses/errors.go +++ b/server/subsonic/responses/errors.go @@ -1,25 +1,31 @@ package responses const ( - ErrorGeneric int32 = 0 - ErrorMissingParameter int32 = 10 - ErrorClientTooOld int32 = 20 - ErrorServerTooOld int32 = 30 - ErrorAuthenticationFail int32 = 40 - ErrorAuthorizationFail int32 = 50 - ErrorTrialExpired int32 = 60 - ErrorDataNotFound int32 = 70 + ErrorGeneric int32 = 0 + ErrorMissingParameter int32 = 10 + ErrorClientTooOld int32 = 20 + ErrorServerTooOld int32 = 30 + ErrorAuthenticationFail int32 = 40 + ErrorTokenAuthNotSupported int32 = 41 + ErrorAuthMechanismNotSupported int32 = 42 + ErrorMultipleAuthMechanismsProvided int32 = 43 + ErrorAuthorizationFail int32 = 50 + ErrorTrialExpired int32 = 60 + ErrorDataNotFound int32 = 70 ) var errors = map[int32]string{ - ErrorGeneric: "A generic error", - ErrorMissingParameter: "Required parameter is missing", - ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", - ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", - ErrorAuthenticationFail: "Wrong username or password", - ErrorAuthorizationFail: "User is not authorized for the given operation", - ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", - ErrorDataNotFound: "The requested data was not found", + ErrorGeneric: "A generic error", + ErrorMissingParameter: "Required parameter is missing", + ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", + ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", + ErrorAuthenticationFail: "Wrong username or password or api key", + ErrorTokenAuthNotSupported: "Token authentication not supported", + ErrorAuthMechanismNotSupported: "Provided authentication mechanism not supported", + ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided", + ErrorAuthorizationFail: "User is not authorized for the given operation", + ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", + ErrorDataNotFound: "The requested data was not found", } func ErrorMsg(code int32) string { diff --git a/tests/mock_apikey_repo.go b/tests/mock_apikey_repo.go new file mode 100644 index 000000000..240281a13 --- /dev/null +++ b/tests/mock_apikey_repo.go @@ -0,0 +1,57 @@ +package tests + +import ( + "github.com/navidrome/navidrome/model" + "strings" +) + +func CreateMockApiKeyRepo() *MockedAPIKeyRepo { + return &MockedAPIKeyRepo{ + Data: map[string]*model.APIKey{}, + } +} + +type MockedAPIKeyRepo struct { + model.APIKeyRepository + Error error + Data map[string]*model.APIKey +} + +func (m *MockedAPIKeyRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + if m.Error != nil { + return 0, m.Error + } + return int64(len(m.Data)), nil +} + +func (m *MockedAPIKeyRepo) Put(apiKey *model.APIKey) error { + if m.Error != nil { + return m.Error + } + m.Data[strings.ToLower(apiKey.Key)] = apiKey + return nil +} + +func (m *MockedAPIKeyRepo) FindByKey(key string) (*model.APIKey, error) { + if m.Error != nil { + return nil, m.Error + } + apiKey, exists := m.Data[strings.ToLower(key)] + if !exists { + return nil, model.ErrNotFound + } + return apiKey, nil +} + +func (m *MockedAPIKeyRepo) Get(id string) (*model.APIKey, error) { + if m.Error != nil { + return nil, m.Error + } + + for _, apiKey := range m.Data { + if apiKey.ID == id { + return apiKey, nil + } + } + return nil, model.ErrNotFound +} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 56f68a74b..93f2d8a68 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,6 +28,7 @@ type MockDataStore struct { MockedRadio model.RadioRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex + MockedAPIKey model.APIKeyRepository } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { @@ -169,7 +170,8 @@ func (db *MockDataStore) User(ctx context.Context) model.UserRepository { if db.RealDS != nil { db.MockedUser = db.RealDS.User(ctx) } else { - db.MockedUser = CreateMockUserRepo() + apiKeyRepo := db.APIKey(ctx).(*MockedAPIKeyRepo) + db.MockedUser = CreateMockUserRepo(apiKeyRepo) } } return db.MockedUser @@ -221,6 +223,17 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { return db.MockedRadio } +func (db *MockDataStore) APIKey(ctx context.Context) model.APIKeyRepository { + if db.MockedAPIKey == nil { + if db.RealDS != nil { + db.MockedAPIKey = db.RealDS.APIKey(ctx) + } else { + db.MockedAPIKey = CreateMockApiKeyRepo() + } + } + return db.MockedAPIKey +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 9f3dd672e..5cfd32cf1 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -10,10 +10,15 @@ import ( "github.com/navidrome/navidrome/utils/gg" ) -func CreateMockUserRepo() *MockedUserRepo { +func CreateMockUserRepo(apiKeyRepo ...*MockedAPIKeyRepo) *MockedUserRepo { + var repo *MockedAPIKeyRepo + if len(apiKeyRepo) > 0 { + repo = apiKeyRepo[0] + } return &MockedUserRepo{ Data: map[string]*model.User{}, UserLibraries: map[string][]int{}, + APIKeyRepo: repo, } } @@ -22,9 +27,10 @@ type MockedUserRepo struct { Error error Data map[string]*model.User UserLibraries map[string][]int // userID -> libraryIDs + APIKeyRepo *MockedAPIKeyRepo } -func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) { +func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) { if u.Error != nil { return 0, u.Error } @@ -123,3 +129,22 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error u.UserLibraries[userID] = libraryIDs return nil } + +func (u *MockedUserRepo) FindByAPIKey(key string) (*model.User, error) { + if u.Error != nil { + return nil, u.Error + } + + apiKey, err := u.APIKeyRepo.FindByKey(key) + if err != nil { + return nil, err + } + + for _, usr := range u.Data { + if usr.ID == apiKey.UserID { + return usr, nil + } + } + + return nil, model.ErrNotFound +} diff --git a/ui/src/App.jsx b/ui/src/App.jsx index dc4fe9b53..7a5899045 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -42,6 +42,7 @@ import SharePlayer from './share/SharePlayer' import { HTML5Backend } from 'react-dnd-html5-backend' import { DndProvider } from 'react-dnd' import missing from './missing/index.js' +import apikey from './apikey/index.js' const history = createHashHistory() @@ -111,6 +112,11 @@ const Admin = (props) => { options={{ subMenu: 'playlist' }} />, , + , { + const translate = useTranslate() + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + const resourceName = translate('resources.apikey.name', { smart_count: 1 }) + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'create', + resource: 'apikey', + payload: { data: values }, + }, + { returnPromise: true }, + ) + notify('resources.apikey.notifications.created', 'info', { + smart_count: 1, + }) + redirect('/apikey') + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [mutate, notify, redirect], + ) + + return ( + } {...props}> + + + + + ) +} + +export default ApiKeyCreate diff --git a/ui/src/apikey/ApiKeyEdit.jsx b/ui/src/apikey/ApiKeyEdit.jsx new file mode 100644 index 000000000..cbcfa7396 --- /dev/null +++ b/ui/src/apikey/ApiKeyEdit.jsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react' +import { + Edit, + SimpleForm, + TextInput, + required, + Toolbar, + SaveButton, + DeleteButton, + useTranslate, + useMutation, + useNotify, + useRefresh, + DateField, +} from 'react-admin' +import { Title } from '../common' + +const ApiKeyTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.apikey.name', { smart_count: 1 }) + return +} + +const ApiKeyEditToolbar = (props) => ( + <Toolbar {...props}> + <SaveButton /> + <DeleteButton /> + </Toolbar> +) + +const ApiKeyEdit = (props) => { + const [mutate] = useMutation() + const notify = useNotify() + const refresh = useRefresh() + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'update', + resource: 'apikey', + payload: { id: values.id, data: values }, + }, + { returnPromise: true }, + ) + notify('resources.apikey.notifications.updated', 'info', { + smart_count: 1, + }) + refresh() + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [mutate, notify, refresh], + ) + + return ( + <Edit title={<ApiKeyTitle />} {...props}> + <SimpleForm + toolbar={<ApiKeyEditToolbar />} + save={save} + variant={'outlined'} + > + <TextInput source="name" validate={[required()]} /> + <TextInput source="key" disabled fullWidth /> + <DateField variant="body1" source="createdAt" showTime /> + </SimpleForm> + </Edit> + ) +} + +export default ApiKeyEdit diff --git a/ui/src/apikey/ApiKeyList.jsx b/ui/src/apikey/ApiKeyList.jsx new file mode 100644 index 000000000..42a43aa15 --- /dev/null +++ b/ui/src/apikey/ApiKeyList.jsx @@ -0,0 +1,58 @@ +import React from 'react' +import { + Datagrid, + DateField, + Filter, + List, + SearchInput, + TextField, + useTranslate, +} from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import { SimpleList } from '../common' +import AddIcon from '@material-ui/icons/Add' +import { CreateButton } from 'react-admin' + +const ApiKeyFilter = (props) => ( + <Filter {...props} variant={'outlined'}> + <SearchInput id="search" source="name" alwaysOn /> + </Filter> +) + +const ApiKeyList = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const translate = useTranslate() + return ( + <List + {...props} + actions={ + <CreateButton + basePath="/apikey" + icon={<AddIcon />} + label={translate('resources.apikey.actions.add')} + /> + } + sort={{ field: 'createdAt', order: 'DESC' }} + exporter={false} + bulkActionButtons={false} + filters={<ApiKeyFilter />} + > + {isXsmall ? ( + <SimpleList + primaryText={(r) => r.name} + secondaryText={(r) => r.key} + tertiaryText={(r) => <DateField record={r} source="createdAt" />} + linkType={'edit'} + /> + ) : ( + <Datagrid rowClick="edit"> + <TextField source="name" /> + <TextField source="key" /> + <DateField source="createdAt" showTime /> + </Datagrid> + )} + </List> + ) +} + +export default ApiKeyList diff --git a/ui/src/apikey/index.js b/ui/src/apikey/index.js new file mode 100644 index 000000000..9c5c411c1 --- /dev/null +++ b/ui/src/apikey/index.js @@ -0,0 +1,11 @@ +import VpnKeyIcon from '@material-ui/icons/VpnKey' +import ApiKeyList from './ApiKeyList' +import ApiKeyCreate from './ApiKeyCreate' +import ApiKeyEdit from './ApiKeyEdit' + +export default { + list: ApiKeyList, + create: ApiKeyCreate, + edit: ApiKeyEdit, + icon: VpnKeyIcon, +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 4a9039a67..48a8bc819 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -1,6 +1,22 @@ { "languageName": "English", "resources": { + "apikey": { + "name": "API Key |||| API Keys", + "fields": { + "name": "Name", + "key": "Key", + "createdAt": "Created At", + "key_help_text": "This key will be used for API authentication" + }, + "actions": { + "add": "Add API Key" + }, + "notifications": { + "created": "API Key created", + "updated": "API Key updated" + } + }, "song": { "name": "Song |||| Songs", "fields": { diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index 561701dce..9a8e7017b 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -63,6 +63,7 @@ AboutMenuItem.displayName = 'AboutMenuItem' const settingsResources = (resource) => resource.name !== 'user' && + resource.name !== 'apikey' && resource.hasList && resource.options && resource.options.subMenu === 'settings' @@ -95,6 +96,14 @@ const CustomUserMenu = ({ onClick, ...rest }) => { ) } + const renderApiKeyMenuItemLink = () => { + const apiKeyResource = resourceDefinition('apikey') + if (!apiKeyResource) { + return null + } + return renderSettingsMenuItemLink(apiKeyResource) + } + const renderSettingsMenuItemLink = (resource, id) => { const label = translate(`resources.${resource.name}.name`, { smart_count: id ? 1 : 2, @@ -128,6 +137,7 @@ const CustomUserMenu = ({ onClick, ...rest }) => { <PersonalMenu sidebarIsOpen={true} onClick={onClick} /> <Divider /> {renderUserMenuItemLink()} + {renderApiKeyMenuItemLink()} {resources .filter(settingsResources) .map((r) => renderSettingsMenuItemLink(r))} diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx index 45f40b26d..cffbb3acf 100644 --- a/ui/src/layout/Menu.jsx +++ b/ui/src/layout/Menu.jsx @@ -103,7 +103,10 @@ const Menu = ({ dense = false }) => { } const subItems = (subMenu) => (resource) => - resource.hasList && resource.options && resource.options.subMenu === subMenu + resource.hasList && + resource.options && + resource.options.subMenu === subMenu && + resource.name !== 'apikey' return ( <div