mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge e5ef453ebe553f48e0eb3814e5c547e998108358 into add0a6dc9b8360db480f76793fa928499bf51741
This commit is contained in:
commit
d0ca5dbe5f
32
db/migrations/20260510120100_create_app_password.go
Normal file
32
db/migrations/20260510120100_create_app_password.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upCreateAppPassword, downCreateAppPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upCreateAppPassword(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS app_password (
|
||||||
|
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
user_id VARCHAR(255) NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
secret_encrypted TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at DATETIME,
|
||||||
|
expires_at DATETIME
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS app_password_user_id ON app_password(user_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS app_password_user_name ON app_password(user_id, name);`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func downCreateAppPassword(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
76
model/app_password.go
Normal file
76
model/app_password.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppPassword represents a long-lived secondary credential a user creates so
|
||||||
|
// that Subsonic clients (which cannot perform an OIDC flow) can authenticate
|
||||||
|
// without exposing the user's primary password. Each app password is bound to
|
||||||
|
// a single user, has a human-readable name, and is stored AES-GCM-encrypted so
|
||||||
|
// that the Subsonic md5(password+salt) verification path can read the
|
||||||
|
// plaintext at request time.
|
||||||
|
type AppPassword struct {
|
||||||
|
ID string `structs:"id" json:"id"`
|
||||||
|
UserID string `structs:"user_id" json:"userId"`
|
||||||
|
Name string `structs:"name" json:"name"`
|
||||||
|
SecretEncrypted string `structs:"secret_encrypted" json:"-"`
|
||||||
|
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||||
|
LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt,omitempty"`
|
||||||
|
ExpiresAt *time.Time `structs:"expires_at" json:"expiresAt,omitempty"`
|
||||||
|
|
||||||
|
// Secret is the plaintext app password. It is populated in two situations
|
||||||
|
// only: (a) on the response to Create, where the caller must surface it to
|
||||||
|
// the user immediately because it is never recoverable afterwards; and (b)
|
||||||
|
// internally inside GetActiveForUser so the Subsonic auth fallback can
|
||||||
|
// compute md5(secret+salt). Never persisted, never serialized on read APIs.
|
||||||
|
Secret string `structs:"-" json:"secret,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppPasswords []AppPassword
|
||||||
|
|
||||||
|
// AppPasswordPublic is the read-only projection returned by listing endpoints.
|
||||||
|
// It deliberately omits SecretEncrypted and Secret so neither the ciphertext
|
||||||
|
// nor the plaintext can leak through generic JSON serialization.
|
||||||
|
type AppPasswordPublic struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppPasswordRepository interface {
|
||||||
|
// Create generates a cryptographically random secret, encrypts it with the
|
||||||
|
// shared password-encryption key, persists the record, and returns the
|
||||||
|
// plaintext secret along with the new record. The plaintext is returned
|
||||||
|
// exactly once; subsequent reads only ever expose the ciphertext.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// userID - owner user ID; foreign key into the user table.
|
||||||
|
// name - human-readable label, unique per user.
|
||||||
|
// expiresAt - optional expiry; nil means the password never expires.
|
||||||
|
Create(ctx context.Context, userID, name string, expiresAt *time.Time) (plaintextSecret string, ap *AppPassword, err error)
|
||||||
|
|
||||||
|
// Delete removes the app password identified by id, but only if it is owned
|
||||||
|
// by ownerUserID. This double-check prevents users from deleting other
|
||||||
|
// users' app passwords by guessing IDs.
|
||||||
|
Delete(ctx context.Context, id, ownerUserID string) error
|
||||||
|
|
||||||
|
// GetActiveForUser returns all non-expired app passwords for the given
|
||||||
|
// user, with the Secret field populated (decrypted) so the Subsonic
|
||||||
|
// authentication fallback can perform md5(secret+salt) checks. Returns an
|
||||||
|
// empty slice if the user has no active app passwords.
|
||||||
|
GetActiveForUser(ctx context.Context, userID string) (AppPasswords, error)
|
||||||
|
|
||||||
|
// UpdateLastUsedAt sets last_used_at on the record to the current time.
|
||||||
|
// Called fire-and-forget after a successful Subsonic authentication, so
|
||||||
|
// errors are non-fatal but should be logged.
|
||||||
|
UpdateLastUsedAt(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// ListForUser returns the metadata-only public projection for the given
|
||||||
|
// user, suitable for the management UI list view.
|
||||||
|
ListForUser(ctx context.Context, userID string) ([]AppPasswordPublic, error)
|
||||||
|
}
|
||||||
@ -37,6 +37,7 @@ type DataStore interface {
|
|||||||
Property(ctx context.Context) PropertyRepository
|
Property(ctx context.Context) PropertyRepository
|
||||||
User(ctx context.Context) UserRepository
|
User(ctx context.Context) UserRepository
|
||||||
UserProps(ctx context.Context) UserPropsRepository
|
UserProps(ctx context.Context) UserPropsRepository
|
||||||
|
AppPassword(ctx context.Context) AppPasswordRepository
|
||||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||||
Scrobble(ctx context.Context) ScrobbleRepository
|
Scrobble(ctx context.Context) ScrobbleRepository
|
||||||
Plugin(ctx context.Context) PluginRepository
|
Plugin(ctx context.Context) PluginRepository
|
||||||
|
|||||||
176
persistence/app_password_repository.go
Normal file
176
persistence/app_password_repository.go
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
. "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/id"
|
||||||
|
"github.com/navidrome/navidrome/utils"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// appPasswordRepository persists named long-lived app passwords. Each row stores
|
||||||
|
// an AES-GCM-encrypted secret so that the Subsonic md5(secret+salt) verification
|
||||||
|
// path can recover the plaintext on each authentication attempt.
|
||||||
|
type appPasswordRepository struct {
|
||||||
|
sqlRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// appPasswordSecretBytes is the size, in raw bytes, of a generated app-password
|
||||||
|
// secret before base64 encoding. 24 bytes → 32-character URL-safe string, which
|
||||||
|
// is well above the practical brute-force threshold for the Subsonic md5+salt
|
||||||
|
// verification flow.
|
||||||
|
const appPasswordSecretBytes = 24
|
||||||
|
|
||||||
|
// NewAppPasswordRepository constructs a repository over the app_password table.
|
||||||
|
//
|
||||||
|
// Side effect: it calls NewUserRepository to guarantee that the shared password
|
||||||
|
// encryption key (encKey) has been derived and any one-time password
|
||||||
|
// re-encryption migration has run before this repository is used. Without this
|
||||||
|
// piggyback, a request that happens to hit /rest before any /api or /auth
|
||||||
|
// endpoint could observe a nil encKey.
|
||||||
|
func NewAppPasswordRepository(ctx context.Context, db dbx.Builder) model.AppPasswordRepository {
|
||||||
|
_ = NewUserRepository(ctx, db) // ensures encKey is initialised
|
||||||
|
r := &appPasswordRepository{}
|
||||||
|
r.ctx = ctx
|
||||||
|
r.db = db
|
||||||
|
r.tableName = "app_password"
|
||||||
|
r.registerModel(&model.AppPassword{}, nil)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create generates a fresh secret, encrypts it, and inserts a new row.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx : request context used for cancellation and logging.
|
||||||
|
// - userID : the owning user's ID; must already exist in the user table.
|
||||||
|
// - name : a human-readable label, unique per user.
|
||||||
|
// - expiresAt : optional expiry timestamp; nil means the password never expires.
|
||||||
|
//
|
||||||
|
// Returns the plaintext secret (which the caller must surface to the user
|
||||||
|
// exactly once — it is not retrievable afterwards) plus the persisted record.
|
||||||
|
func (r *appPasswordRepository) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return "", nil, errors.New("appPassword: userID is required")
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return "", nil, errors.New("appPassword: name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := generateAppPasswordSecret()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
encrypted, err := utils.Encrypt(ctx, encryptionKey(), plaintext)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ap := &model.AppPassword{
|
||||||
|
ID: id.NewRandom(),
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
SecretEncrypted: encrypted,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
insert := Insert(r.tableName).SetMap(map[string]any{
|
||||||
|
"id": ap.ID,
|
||||||
|
"user_id": ap.UserID,
|
||||||
|
"name": ap.Name,
|
||||||
|
"secret_encrypted": ap.SecretEncrypted,
|
||||||
|
"created_at": ap.CreatedAt,
|
||||||
|
"expires_at": ap.ExpiresAt,
|
||||||
|
})
|
||||||
|
if _, err := r.executeSQL(insert); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
ap.Secret = plaintext
|
||||||
|
return plaintext, ap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the row identified by id, but only if owned by ownerUserID.
|
||||||
|
// The compound WHERE clause prevents users from deleting other users' app
|
||||||
|
// passwords by guessing IDs.
|
||||||
|
func (r *appPasswordRepository) Delete(ctx context.Context, id, ownerUserID string) error {
|
||||||
|
del := Delete(r.tableName).Where(And{Eq{"id": id}, Eq{"user_id": ownerUserID}})
|
||||||
|
count, err := r.executeSQL(del)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveForUser returns every non-expired app password for userID, with the
|
||||||
|
// Secret field populated by decrypting SecretEncrypted. Used by the Subsonic
|
||||||
|
// authentication fallback.
|
||||||
|
func (r *appPasswordRepository) GetActiveForUser(ctx context.Context, userID string) (model.AppPasswords, error) {
|
||||||
|
now := time.Now()
|
||||||
|
sel := r.newSelect().Columns("id", "user_id", "name", "secret_encrypted", "created_at", "last_used_at", "expires_at").
|
||||||
|
Where(Eq{"user_id": userID}).
|
||||||
|
Where(Or{Eq{"expires_at": nil}, Gt{"expires_at": now}})
|
||||||
|
var rows model.AppPasswords
|
||||||
|
if err := r.queryAll(sel, &rows); err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return model.AppPasswords{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
plain, err := utils.Decrypt(ctx, encryptionKey(), rows[i].SecretEncrypted)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Skipping app password whose secret could not be decrypted", "id", rows[i].ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows[i].Secret = plain
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastUsedAt sets last_used_at on the row to the current wall clock time.
|
||||||
|
// Intended to be called fire-and-forget from the Subsonic auth path.
|
||||||
|
func (r *appPasswordRepository) UpdateLastUsedAt(ctx context.Context, id string) error {
|
||||||
|
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_used_at", time.Now())
|
||||||
|
_, err := r.executeSQL(upd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForUser returns the metadata-only public projection for the user's
|
||||||
|
// app passwords, sorted by creation time descending so the most recent appears
|
||||||
|
// first in the management UI.
|
||||||
|
func (r *appPasswordRepository) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
|
||||||
|
sel := r.newSelect().
|
||||||
|
Columns("id", "user_id", "name", "created_at", "last_used_at", "expires_at").
|
||||||
|
Where(Eq{"user_id": userID}).
|
||||||
|
OrderBy("created_at DESC")
|
||||||
|
var rows []model.AppPasswordPublic
|
||||||
|
if err := r.queryAll(sel, &rows); err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return []model.AppPasswordPublic{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateAppPasswordSecret returns a URL-safe base64-encoded random string
|
||||||
|
// suitable for use as a secondary password.
|
||||||
|
func generateAppPasswordSecret() (string, error) {
|
||||||
|
buf := make([]byte, appPasswordSecretBytes)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ model.AppPasswordRepository = (*appPasswordRepository)(nil)
|
||||||
122
persistence/app_password_repository_test.go
Normal file
122
persistence/app_password_repository_test.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/id"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("AppPasswordRepository", func() {
|
||||||
|
var (
|
||||||
|
repo model.AppPasswordRepository
|
||||||
|
userID string
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx := log.NewContext(GinkgoT().Context())
|
||||||
|
// The user repo constructor seeds the encryption key; the app password
|
||||||
|
// repo piggybacks on that via NewUserRepository in its constructor.
|
||||||
|
userRepo := NewUserRepository(ctx, GetDBXBuilder())
|
||||||
|
userID = id.NewRandom()
|
||||||
|
Expect(userRepo.Put(&model.User{
|
||||||
|
ID: userID,
|
||||||
|
UserName: "ap-user-" + userID,
|
||||||
|
Name: "AP User",
|
||||||
|
NewPassword: "irrelevant",
|
||||||
|
})).To(Succeed())
|
||||||
|
|
||||||
|
repo = NewAppPasswordRepository(ctx, GetDBXBuilder())
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Create", func() {
|
||||||
|
It("returns plaintext and persists encrypted secret", func() {
|
||||||
|
plain, ap, err := repo.Create(GinkgoT().Context(), userID, "phone", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(plain).ToNot(BeEmpty())
|
||||||
|
Expect(ap.SecretEncrypted).ToNot(BeEmpty())
|
||||||
|
Expect(ap.SecretEncrypted).ToNot(Equal(plain))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects empty user ID", func() {
|
||||||
|
_, _, err := repo.Create(GinkgoT().Context(), "", "x", nil)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetActiveForUser", func() {
|
||||||
|
It("decrypts and returns active passwords", func() {
|
||||||
|
plain, _, err := repo.Create(GinkgoT().Context(), userID, "active", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rows).To(HaveLen(1))
|
||||||
|
Expect(rows[0].Secret).To(Equal(plain))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("excludes expired passwords", func() {
|
||||||
|
past := time.Now().Add(-time.Hour)
|
||||||
|
_, _, err := repo.Create(GinkgoT().Context(), userID, "expired", &past)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rows).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Delete", func() {
|
||||||
|
It("removes a password owned by the user", func() {
|
||||||
|
_, ap, err := repo.Create(GinkgoT().Context(), userID, "to-delete", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(repo.Delete(GinkgoT().Context(), ap.ID, userID)).To(Succeed())
|
||||||
|
|
||||||
|
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rows).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("refuses to delete a password owned by a different user", func() {
|
||||||
|
_, ap, err := repo.Create(GinkgoT().Context(), userID, "other-owned", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
err = repo.Delete(GinkgoT().Context(), ap.ID, "someone-else")
|
||||||
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("UpdateLastUsedAt", func() {
|
||||||
|
It("sets last_used_at on the row", func() {
|
||||||
|
_, ap, err := repo.Create(GinkgoT().Context(), userID, "lu", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(repo.UpdateLastUsedAt(GinkgoT().Context(), ap.ID)).To(Succeed())
|
||||||
|
|
||||||
|
pubs, err := repo.ListForUser(GinkgoT().Context(), userID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pubs).ToNot(BeEmpty())
|
||||||
|
Expect(pubs[0].LastUsedAt).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ListForUser", func() {
|
||||||
|
It("returns metadata only, ordered newest first", func() {
|
||||||
|
_, _, err := repo.Create(GinkgoT().Context(), userID, "first", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
_, _, err = repo.Create(GinkgoT().Context(), userID, "second", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
pubs, err := repo.ListForUser(GinkgoT().Context(), userID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(pubs).To(HaveLen(2))
|
||||||
|
Expect(pubs[0].Name).To(Equal("second"))
|
||||||
|
Expect(pubs[1].Name).To(Equal("first"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
15
persistence/enc_key.go
Normal file
15
persistence/enc_key.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
// encryptionKey returns the 32-byte AES-GCM key used to encrypt user passwords
|
||||||
|
// and app-password secrets.
|
||||||
|
//
|
||||||
|
// The key is initialised by NewUserRepository's sync.Once on first call to any
|
||||||
|
// user repository constructor; that path also performs the one-time
|
||||||
|
// re-encryption migration when conf.Server.PasswordEncryptionKey is rotated.
|
||||||
|
// Repositories that depend on this key (e.g. app_password_repository) must
|
||||||
|
// therefore ensure a user repository has been constructed at least once before
|
||||||
|
// they invoke this accessor — see appPasswordRepository's constructor for the
|
||||||
|
// canonical pattern.
|
||||||
|
func encryptionKey() []byte {
|
||||||
|
return encKey
|
||||||
|
}
|
||||||
@ -77,6 +77,10 @@ func (s *SQLStore) User(ctx context.Context) model.UserRepository {
|
|||||||
return NewUserRepository(ctx, s.getDBXBuilder())
|
return NewUserRepository(ctx, s.getDBXBuilder())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) AppPassword(ctx context.Context) model.AppPasswordRepository {
|
||||||
|
return NewAppPasswordRepository(ctx, s.getDBXBuilder())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
|
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
|
||||||
return NewTranscodingRepository(ctx, s.getDBXBuilder())
|
return NewTranscodingRepository(ctx, s.getDBXBuilder())
|
||||||
}
|
}
|
||||||
|
|||||||
134
server/nativeapi/app_password.go
Normal file
134
server/nativeapi/app_password.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addAppPasswordRoute mounts the /user/{userId}/app-password CRUD endpoints.
|
||||||
|
//
|
||||||
|
// The route is owner-or-admin gated: a non-admin user can only manage their
|
||||||
|
// own passwords; an admin can manage any user's. POST returns the freshly
|
||||||
|
// generated plaintext secret exactly once; subsequent GETs only return
|
||||||
|
// metadata.
|
||||||
|
func (api *Router) addAppPasswordRoute(r chi.Router) {
|
||||||
|
r.Route("/user/{userId}/app-password", func(r chi.Router) {
|
||||||
|
r.Use(appPasswordOwnerOrAdminMiddleware)
|
||||||
|
r.Get("/", listAppPasswords(api.ds))
|
||||||
|
r.Post("/", createAppPassword(api.ds))
|
||||||
|
r.Delete("/{id}", deleteAppPassword(api.ds))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// appPasswordOwnerOrAdminMiddleware permits the request only if the
|
||||||
|
// authenticated user owns the userId in the path or is an admin. Any other
|
||||||
|
// caller (including unauthenticated requests, which should not reach this
|
||||||
|
// far in normal routing) gets 403.
|
||||||
|
func appPasswordOwnerOrAdminMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := request.UserFrom(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "not authenticated", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := chi.URLParam(r, "userId")
|
||||||
|
if !caller.IsAdmin && caller.ID != userID {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAppPasswords returns the metadata-only public projection of the
|
||||||
|
// caller's (or, for admins, the target user's) app passwords. The plaintext
|
||||||
|
// secret is never re-served — it is only returned by POST at creation time.
|
||||||
|
func listAppPasswords(ds model.DataStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := chi.URLParam(r, "userId")
|
||||||
|
rows, err := ds.AppPassword(r.Context()).ListForUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = rest.RespondWithJSON(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAppPasswordRequest is the JSON payload accepted by POST.
|
||||||
|
type createAppPasswordRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAppPasswordResponse is the JSON payload returned by POST. The
|
||||||
|
// "secret" field is the plaintext shown only once.
|
||||||
|
type createAppPasswordResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAppPassword generates and stores a new app password and returns the
|
||||||
|
// plaintext secret in the response body. Callers must surface the secret to
|
||||||
|
// the user immediately — Navidrome cannot retrieve it again.
|
||||||
|
func createAppPassword(ds model.DataStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := chi.URLParam(r, "userId")
|
||||||
|
|
||||||
|
var req createAppPasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, ap, err := ds.AppPassword(r.Context()).Create(r.Context(), userID, req.Name, req.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(r, "Failed to create app password", "userId", userID, err)
|
||||||
|
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = rest.RespondWithJSON(w, http.StatusCreated, createAppPasswordResponse{
|
||||||
|
ID: ap.ID,
|
||||||
|
Name: ap.Name,
|
||||||
|
Secret: plaintext,
|
||||||
|
CreatedAt: ap.CreatedAt,
|
||||||
|
ExpiresAt: ap.ExpiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteAppPassword removes an app password the caller is allowed to
|
||||||
|
// manage. The repository enforces the ownership check redundantly: even a
|
||||||
|
// path-confusion attack that smuggled a foreign userId past the middleware
|
||||||
|
// would not delete other users' rows because the WHERE clause includes
|
||||||
|
// user_id.
|
||||||
|
func deleteAppPassword(ds model.DataStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := chi.URLParam(r, "userId")
|
||||||
|
appPwdID := chi.URLParam(r, "id")
|
||||||
|
if err := ds.AppPassword(r.Context()).Delete(r.Context(), appPwdID, userID); err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
_ = rest.RespondWithError(w, http.StatusNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
274
server/nativeapi/app_password_test.go
Normal file
274
server/nativeapi/app_password_test.go
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubAppPasswordRepo gives the handler-level tests deterministic control over
|
||||||
|
// what the repo returns, without dragging in encryption / DB plumbing.
|
||||||
|
type stubAppPasswordRepo struct {
|
||||||
|
model.AppPasswordRepository
|
||||||
|
list []model.AppPasswordPublic
|
||||||
|
listErr error
|
||||||
|
createErr error
|
||||||
|
deleteErr error
|
||||||
|
|
||||||
|
createdName string
|
||||||
|
createdUserID string
|
||||||
|
createdExpiresAt *time.Time
|
||||||
|
deletedID string
|
||||||
|
deletedOwnerID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubAppPasswordRepo) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
|
||||||
|
if s.listErr != nil {
|
||||||
|
return nil, s.listErr
|
||||||
|
}
|
||||||
|
out := make([]model.AppPasswordPublic, 0, len(s.list))
|
||||||
|
for _, p := range s.list {
|
||||||
|
if p.UserID == userID {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubAppPasswordRepo) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
|
||||||
|
s.createdUserID = userID
|
||||||
|
s.createdName = name
|
||||||
|
s.createdExpiresAt = expiresAt
|
||||||
|
if s.createErr != nil {
|
||||||
|
return "", nil, s.createErr
|
||||||
|
}
|
||||||
|
ap := &model.AppPassword{
|
||||||
|
ID: "new-id",
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
return "plaintext-secret", ap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubAppPasswordRepo) Delete(ctx context.Context, id, ownerUserID string) error {
|
||||||
|
s.deletedID = id
|
||||||
|
s.deletedOwnerID = ownerUserID
|
||||||
|
return s.deleteErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("App Password API", func() {
|
||||||
|
var (
|
||||||
|
ds *tests.MockDataStore
|
||||||
|
repo *stubAppPasswordRepo
|
||||||
|
router chi.Router
|
||||||
|
api *Router
|
||||||
|
|
||||||
|
owner = model.User{ID: "user-1", UserName: "alice", IsAdmin: false}
|
||||||
|
admin = model.User{ID: "admin-1", UserName: "root", IsAdmin: true}
|
||||||
|
other = model.User{ID: "user-2", UserName: "bob", IsAdmin: false}
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
repo = &stubAppPasswordRepo{}
|
||||||
|
ds = &tests.MockDataStore{MockedAppPassword: repo}
|
||||||
|
api = &Router{ds: ds}
|
||||||
|
|
||||||
|
router = chi.NewRouter()
|
||||||
|
api.addAppPasswordRoute(router)
|
||||||
|
})
|
||||||
|
|
||||||
|
// serve wraps the user into the request context (the way JWTVerifier would
|
||||||
|
// in production) and exercises the chi router so the URL params are
|
||||||
|
// populated for the middleware and handlers.
|
||||||
|
serve := func(method, path string, body []byte, caller *model.User) *httptest.ResponseRecorder {
|
||||||
|
var reader *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
reader = bytes.NewReader(body)
|
||||||
|
} else {
|
||||||
|
reader = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, reader)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if caller != nil {
|
||||||
|
req = req.WithContext(request.WithUser(req.Context(), *caller))
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe("appPasswordOwnerOrAdminMiddleware", func() {
|
||||||
|
It("lets the owner manage their own passwords", func() {
|
||||||
|
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("lets an admin manage any user's passwords", func() {
|
||||||
|
w := serve("GET", "/user/user-1/app-password/", nil, &admin)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("forbids non-owner non-admin access", func() {
|
||||||
|
w := serve("GET", "/user/user-1/app-password/", nil, &other)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects unauthenticated requests with 401", func() {
|
||||||
|
w := serve("GET", "/user/user-1/app-password/", nil, nil)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("listAppPasswords", func() {
|
||||||
|
It("returns the public projection without any secret fields", func() {
|
||||||
|
repo.list = []model.AppPasswordPublic{
|
||||||
|
{ID: "ap-1", UserID: "user-1", Name: "Phone", CreatedAt: time.Now()},
|
||||||
|
{ID: "ap-2", UserID: "user-1", Name: "Laptop", CreatedAt: time.Now()},
|
||||||
|
}
|
||||||
|
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
// Decode into the typed projection - if any unexpected secret field
|
||||||
|
// leaks in, json.Decoder won't see it here, so additionally inspect
|
||||||
|
// the raw bytes.
|
||||||
|
var got []model.AppPasswordPublic
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
|
||||||
|
Expect(got).To(HaveLen(2))
|
||||||
|
|
||||||
|
raw := w.Body.String()
|
||||||
|
Expect(raw).NotTo(ContainSubstring("secret"))
|
||||||
|
Expect(raw).NotTo(ContainSubstring("Secret"))
|
||||||
|
Expect(raw).NotTo(ContainSubstring("secret_encrypted"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 500 when the repo errors", func() {
|
||||||
|
repo.listErr = errors.New("boom")
|
||||||
|
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("createAppPassword", func() {
|
||||||
|
It("returns 201 with the one-time plaintext secret", func() {
|
||||||
|
body, _ := json.Marshal(map[string]any{"name": "Phone"})
|
||||||
|
w := serve("POST", "/user/user-1/app-password/", body, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusCreated))
|
||||||
|
|
||||||
|
var resp createAppPasswordResponse
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||||
|
Expect(resp.ID).To(Equal("new-id"))
|
||||||
|
Expect(resp.Name).To(Equal("Phone"))
|
||||||
|
Expect(resp.Secret).To(Equal("plaintext-secret"))
|
||||||
|
|
||||||
|
Expect(repo.createdUserID).To(Equal("user-1"))
|
||||||
|
Expect(repo.createdName).To(Equal("Phone"))
|
||||||
|
Expect(repo.createdExpiresAt).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("forwards expiresAt to the repo", func() {
|
||||||
|
exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
body, _ := json.Marshal(map[string]any{"name": "TempKey", "expiresAt": exp})
|
||||||
|
w := serve("POST", "/user/user-1/app-password/", body, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusCreated))
|
||||||
|
Expect(repo.createdExpiresAt).NotTo(BeNil())
|
||||||
|
Expect(repo.createdExpiresAt.Equal(exp)).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects an empty name with 422", func() {
|
||||||
|
body, _ := json.Marshal(map[string]any{"name": ""})
|
||||||
|
w := serve("POST", "/user/user-1/app-password/", body, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusUnprocessableEntity))
|
||||||
|
Expect(repo.createdName).To(Equal("")) // repo was not called
|
||||||
|
Expect(repo.createdUserID).To(Equal(""))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects a malformed body with 422", func() {
|
||||||
|
w := serve("POST", "/user/user-1/app-password/", []byte("{not json"), &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusUnprocessableEntity))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects access from a non-owner before reaching the handler", func() {
|
||||||
|
body, _ := json.Marshal(map[string]any{"name": "Phone"})
|
||||||
|
w := serve("POST", "/user/user-1/app-password/", body, &other)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(repo.createdName).To(Equal("")) // repo was not called
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 500 when the repo errors", func() {
|
||||||
|
repo.createErr = errors.New("boom")
|
||||||
|
body, _ := json.Marshal(map[string]any{"name": "Phone"})
|
||||||
|
w := serve("POST", "/user/user-1/app-password/", body, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("deleteAppPassword", func() {
|
||||||
|
It("returns 204 on success and forwards the owner scope", func() {
|
||||||
|
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||||
|
Expect(repo.deletedID).To(Equal("ap-1"))
|
||||||
|
Expect(repo.deletedOwnerID).To(Equal("user-1"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 404 when the repo reports not found", func() {
|
||||||
|
repo.deleteErr = model.ErrNotFound
|
||||||
|
w := serve("DELETE", "/user/user-1/app-password/missing", nil, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 500 on unexpected repo errors", func() {
|
||||||
|
repo.deleteErr = errors.New("boom")
|
||||||
|
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("forbids non-owner non-admin callers before reaching the repo", func() {
|
||||||
|
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &other)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(repo.deletedID).To(Equal(""))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("scopes admin deletes to the path's userId, not the admin's", func() {
|
||||||
|
// Even when an admin deletes on behalf of someone else, the
|
||||||
|
// owner-id passed to the repo must be the path's userId so the
|
||||||
|
// WHERE clause stays correct.
|
||||||
|
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &admin)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||||
|
Expect(repo.deletedOwnerID).To(Equal("user-1"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("end-to-end URL shapes", func() {
|
||||||
|
It("404s for an unknown subpath under app-password", func() {
|
||||||
|
w := serve("GET", "/user/user-1/app-password/unknown/extra", nil, &owner)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
// nothing should hit the create/delete tracking
|
||||||
|
Expect(repo.deletedID).To(Equal(""))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("preserves the URL parameter through the middleware (sanity)", func() {
|
||||||
|
// Smoke-test: hit a path that includes a hyphen in the userId,
|
||||||
|
// confirm the middleware sees the same value chi parsed.
|
||||||
|
hyphenated := model.User{ID: "user-with-hyphen", UserName: "x", IsAdmin: false}
|
||||||
|
w := serve("GET", "/user/user-with-hyphen/app-password/", nil, &hyphenated)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
// The list path uses URLParam internally - we hit the trailing
|
||||||
|
// slash one so chi resolves the param.
|
||||||
|
Expect(strings.Contains(w.Body.String(), "[]")).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -85,6 +85,7 @@ func (api *Router) routes() http.Handler {
|
|||||||
api.addMissingFilesRoute(r)
|
api.addMissingFilesRoute(r)
|
||||||
api.addKeepAliveRoute(r)
|
api.addKeepAliveRoute(r)
|
||||||
api.addInsightsRoute(r)
|
api.addInsightsRoute(r)
|
||||||
|
api.addAppPasswordRoute(r)
|
||||||
|
|
||||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||||
api.addInspectRoute(r)
|
api.addInspectRoute(r)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -138,6 +139,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
|||||||
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
default:
|
default:
|
||||||
err = validateCredentials(usr, pass, token, salt, jwt)
|
err = validateCredentials(usr, pass, token, salt, jwt)
|
||||||
|
if err != nil && jwt == "" {
|
||||||
|
err = validateAppPasswordCredentials(ctx, ds, usr, pass, token, salt)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
}
|
}
|
||||||
@ -155,21 +159,55 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminOnly(next http.Handler) http.Handler {
|
// validateAppPasswordCredentials is the Subsonic-side fallback for users who
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
// authenticate with a per-application secondary password instead of their
|
||||||
loggedUser, ok := request.UserFrom(r.Context())
|
// primary one. It only kicks in when the request is NOT presenting a JWT
|
||||||
if !ok {
|
// (those are exclusively for Navidrome-issued tokens).
|
||||||
sendError(w, r, newError(responses.ErrorGeneric, "Internal error"))
|
//
|
||||||
return
|
// Both p= (plaintext / "enc:" hex-encoded) and t=/s= (md5(secret+salt)) auth
|
||||||
}
|
// shapes are supported. Comparisons use crypto/subtle to avoid leaking
|
||||||
|
// whether a username has any active app passwords through timing side
|
||||||
|
// channels.
|
||||||
|
//
|
||||||
|
// On success, last_used_at is bumped asynchronously using a context that
|
||||||
|
// outlives the HTTP request — the user-facing response has already been
|
||||||
|
// committed by the time the UPDATE returns.
|
||||||
|
func validateAppPasswordCredentials(ctx context.Context, ds model.DataStore, user *model.User, pass, token, salt string) error {
|
||||||
|
aps, err := ds.AppPassword(ctx).GetActiveForUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Failed to load app passwords during auth", "userId", user.ID, err)
|
||||||
|
return model.ErrInvalidAuth
|
||||||
|
}
|
||||||
|
if len(aps) == 0 {
|
||||||
|
return model.ErrInvalidAuth
|
||||||
|
}
|
||||||
|
|
||||||
if !loggedUser.IsAdmin {
|
if strings.HasPrefix(pass, "enc:") {
|
||||||
sendError(w, r, newError(responses.ErrorAuthorizationFail))
|
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||||
return
|
pass = string(dec)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
for _, ap := range aps {
|
||||||
})
|
if ap.Secret == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var matches bool
|
||||||
|
switch {
|
||||||
|
case pass != "":
|
||||||
|
matches = subtle.ConstantTimeCompare([]byte(pass), []byte(ap.Secret)) == 1
|
||||||
|
case token != "":
|
||||||
|
t := fmt.Sprintf("%x", md5.Sum([]byte(ap.Secret+salt)))
|
||||||
|
matches = subtle.ConstantTimeCompare([]byte(t), []byte(token)) == 1
|
||||||
|
}
|
||||||
|
if matches {
|
||||||
|
asyncCtx := context.WithoutCancel(ctx)
|
||||||
|
id := ap.ID
|
||||||
|
go func() { _ = ds.AppPassword(asyncCtx).UpdateLastUsedAt(asyncCtx, id) }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.ErrInvalidAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
|
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
|
||||||
|
|||||||
@ -251,6 +251,130 @@ var _ = Describe("Middlewares", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
When("using app password authentication", func() {
|
||||||
|
var (
|
||||||
|
appRepo *tests.MockAppPasswordRepo
|
||||||
|
usedCh chan string
|
||||||
|
userID string
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
// The primary password remains "wordpass"; an app password
|
||||||
|
// for the same user is "app-secret". The fallback path is only
|
||||||
|
// hit when validateCredentials fails (i.e. p != "wordpass"),
|
||||||
|
// so every test below uses "app-secret" or its derivatives.
|
||||||
|
existing, err := ds.User(context.TODO()).FindByUsername("admin")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
userID = existing.ID
|
||||||
|
|
||||||
|
usedCh = make(chan string, 1)
|
||||||
|
appRepo = &tests.MockAppPasswordRepo{
|
||||||
|
Active: model.AppPasswords{
|
||||||
|
{
|
||||||
|
ID: "ap-active",
|
||||||
|
UserID: userID,
|
||||||
|
Name: "iOS",
|
||||||
|
Secret: "app-secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
UpdateLastUsedAtFn: func(id string) {
|
||||||
|
usedCh <- id
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ds.(*tests.MockDataStore).MockedAppPassword = appRepo
|
||||||
|
})
|
||||||
|
|
||||||
|
It("authenticates with the plaintext app password via p=", func() {
|
||||||
|
r := newGetRequest("u=admin", "p=app-secret")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(next.called).To(BeTrue())
|
||||||
|
user, _ := request.UserFrom(next.req.Context())
|
||||||
|
Expect(user.UserName).To(Equal("admin"))
|
||||||
|
Eventually(usedCh).Should(Receive(Equal("ap-active")))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("authenticates with the hex-encoded app password via enc:", func() {
|
||||||
|
// hex("app-secret") = 6170702d736563726574
|
||||||
|
r := newGetRequest("u=admin", "p=enc:6170702d736563726574")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(next.called).To(BeTrue())
|
||||||
|
Eventually(usedCh).Should(Receive(Equal("ap-active")))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("authenticates with md5(secret+salt) via t=/s=", func() {
|
||||||
|
salt := "abcdef"
|
||||||
|
token := fmt.Sprintf("%x", md5.Sum([]byte("app-secret"+salt)))
|
||||||
|
r := newGetRequest("u=admin", "t="+token, "s="+salt)
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(next.called).To(BeTrue())
|
||||||
|
Eventually(usedCh).Should(Receive(Equal("ap-active")))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects a request whose p= matches no primary nor app password", func() {
|
||||||
|
r := newGetRequest("u=admin", "p=nope")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||||
|
Expect(next.called).To(BeFalse())
|
||||||
|
Consistently(usedCh).ShouldNot(Receive())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects when the user has no active app passwords", func() {
|
||||||
|
appRepo.Active = nil
|
||||||
|
r := newGetRequest("u=admin", "p=app-secret")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||||
|
Expect(next.called).To(BeFalse())
|
||||||
|
Consistently(usedCh).ShouldNot(Receive())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls through to the existing-password check when only the primary password matches", func() {
|
||||||
|
// p=wordpass would succeed at validateCredentials; the
|
||||||
|
// fallback should not even be queried.
|
||||||
|
appRepo.Active = nil
|
||||||
|
appRepo.GetActiveErr = errors.New("repo should not be queried")
|
||||||
|
r := newGetRequest("u=admin", "p=wordpass")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(next.called).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not bump last_used_at on failed auth", func() {
|
||||||
|
r := newGetRequest("u=admin", "p=app-secret-wrong")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(next.called).To(BeFalse())
|
||||||
|
Consistently(usedCh).ShouldNot(Receive())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not run the app-password fallback for JWT auth", func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.SessionTimeout = time.Minute
|
||||||
|
auth.Init(ds)
|
||||||
|
|
||||||
|
// A valid app secret presented via the jwt= param must not
|
||||||
|
// authenticate — the fallback is gated on jwt == "".
|
||||||
|
appRepo.GetActiveErr = errors.New("fallback must not run for jwt requests")
|
||||||
|
r := newGetRequest("u=admin", "jwt=app-secret")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||||
|
Expect(next.called).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
When("using reverse proxy authentication", func() {
|
When("using reverse proxy authentication", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
@ -308,36 +432,6 @@ var _ = Describe("Middlewares", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("AdminOnly", func() {
|
|
||||||
It("passes admin users", func() {
|
|
||||||
r := newGetRequest()
|
|
||||||
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true}))
|
|
||||||
|
|
||||||
adminOnly(next).ServeHTTP(w, r)
|
|
||||||
|
|
||||||
Expect(next.called).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects non-admin users", func() {
|
|
||||||
r := newGetRequest()
|
|
||||||
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false}))
|
|
||||||
|
|
||||||
adminOnly(next).ServeHTTP(w, r)
|
|
||||||
|
|
||||||
Expect(w.Body.String()).To(ContainSubstring(`code="50"`))
|
|
||||||
Expect(next.called).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an internal error when user is missing from context", func() {
|
|
||||||
r := newGetRequest()
|
|
||||||
|
|
||||||
adminOnly(next).ServeHTTP(w, r)
|
|
||||||
|
|
||||||
Expect(w.Body.String()).To(ContainSubstring(`code="0"`))
|
|
||||||
Expect(next.called).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetPlayer", func() {
|
Describe("GetPlayer", func() {
|
||||||
var mockedPlayers *mockPlayers
|
var mockedPlayers *mockPlayers
|
||||||
var r *http.Request
|
var r *http.Request
|
||||||
|
|||||||
60
tests/mock_app_password_repo.go
Normal file
60
tests/mock_app_password_repo.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockAppPasswordRepo is a minimal in-memory stand-in for
|
||||||
|
// model.AppPasswordRepository. By default GetActiveForUser returns an empty
|
||||||
|
// slice, so the Subsonic auth fallback short-circuits cleanly without
|
||||||
|
// panicking on nil-method invocations of an unmocked interface.
|
||||||
|
//
|
||||||
|
// Tests that need richer behaviour can populate Active or override fields
|
||||||
|
// directly.
|
||||||
|
type MockAppPasswordRepo struct {
|
||||||
|
model.AppPasswordRepository
|
||||||
|
Active model.AppPasswords
|
||||||
|
GetActiveErr error
|
||||||
|
UpdateLastUsedAtFn func(id string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateMockAppPasswordRepo() *MockAppPasswordRepo {
|
||||||
|
return &MockAppPasswordRepo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppPasswordRepo) GetActiveForUser(ctx context.Context, userID string) (model.AppPasswords, error) {
|
||||||
|
if m.GetActiveErr != nil {
|
||||||
|
return nil, m.GetActiveErr
|
||||||
|
}
|
||||||
|
out := make(model.AppPasswords, 0, len(m.Active))
|
||||||
|
for _, ap := range m.Active {
|
||||||
|
if ap.UserID == userID {
|
||||||
|
out = append(out, ap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppPasswordRepo) UpdateLastUsedAt(ctx context.Context, id string) error {
|
||||||
|
if m.UpdateLastUsedAtFn != nil {
|
||||||
|
m.UpdateLastUsedAtFn(id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppPasswordRepo) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppPasswordRepo) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
|
||||||
|
ap := &model.AppPassword{ID: "mock", UserID: userID, Name: name, CreatedAt: time.Now(), ExpiresAt: expiresAt}
|
||||||
|
m.Active = append(m.Active, *ap)
|
||||||
|
return "secret", ap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockAppPasswordRepo) Delete(ctx context.Context, id, ownerUserID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ type MockDataStore struct {
|
|||||||
MockedScrobble model.ScrobbleRepository
|
MockedScrobble model.ScrobbleRepository
|
||||||
MockedRadio model.RadioRepository
|
MockedRadio model.RadioRepository
|
||||||
MockedPlugin model.PluginRepository
|
MockedPlugin model.PluginRepository
|
||||||
|
MockedAppPassword model.AppPasswordRepository
|
||||||
scrobbleBufferMu sync.Mutex
|
scrobbleBufferMu sync.Mutex
|
||||||
repoMu sync.Mutex
|
repoMu sync.Mutex
|
||||||
|
|
||||||
@ -247,6 +248,17 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository {
|
|||||||
return db.MockedPlugin
|
return db.MockedPlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *MockDataStore) AppPassword(ctx context.Context) model.AppPasswordRepository {
|
||||||
|
if db.MockedAppPassword != nil {
|
||||||
|
return db.MockedAppPassword
|
||||||
|
}
|
||||||
|
if db.RealDS != nil {
|
||||||
|
return db.RealDS.AppPassword(ctx)
|
||||||
|
}
|
||||||
|
db.MockedAppPassword = CreateMockAppPasswordRepo()
|
||||||
|
return db.MockedAppPassword
|
||||||
|
}
|
||||||
|
|
||||||
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
|
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
|
||||||
return block(db)
|
return block(db)
|
||||||
}
|
}
|
||||||
|
|||||||
237
ui/src/user/AppPasswordManager.jsx
Normal file
237
ui/src/user/AppPasswordManager.jsx
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@material-ui/core'
|
||||||
|
import DeleteIcon from '@material-ui/icons/Delete'
|
||||||
|
import FileCopyIcon from '@material-ui/icons/FileCopy'
|
||||||
|
import { useNotify } from 'react-admin'
|
||||||
|
import httpClient from '../dataProvider/httpClient'
|
||||||
|
import { REST_URL } from '../consts'
|
||||||
|
|
||||||
|
// AppPasswordManager renders a simple per-user table of long-lived app
|
||||||
|
// passwords used for Subsonic clients that cannot speak OIDC. The plaintext
|
||||||
|
// secret is shown exactly once on creation; afterwards only metadata
|
||||||
|
// (created/last used/expires) is visible.
|
||||||
|
const AppPasswordManager = ({ userId }) => {
|
||||||
|
const notify = useNotify()
|
||||||
|
const [rows, setRows] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newExpiresAt, setNewExpiresAt] = useState('')
|
||||||
|
const [createdSecret, setCreatedSecret] = useState(null)
|
||||||
|
|
||||||
|
const baseURL = `${REST_URL}/user/${userId}/app-password`
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
httpClient(baseURL)
|
||||||
|
.then((response) => {
|
||||||
|
const data = response.json
|
||||||
|
setRows(Array.isArray(data) ? data : [])
|
||||||
|
})
|
||||||
|
.catch((error) => notify(error.message || 'Failed to load app passwords', 'warning'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [baseURL, notify])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
const body = { name: newName }
|
||||||
|
if (newExpiresAt) {
|
||||||
|
body.expiresAt = new Date(newExpiresAt).toISOString()
|
||||||
|
}
|
||||||
|
httpClient(baseURL, { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
.then((response) => {
|
||||||
|
setCreatedSecret(response.json)
|
||||||
|
setNewName('')
|
||||||
|
setNewExpiresAt('')
|
||||||
|
setCreateOpen(false)
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
.catch((error) =>
|
||||||
|
notify(error.message || 'Failed to create app password', 'warning'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id) => {
|
||||||
|
if (!window.confirm('Delete this app password? Clients using it will stop working.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpClient(`${baseURL}/${id}`, { method: 'DELETE' })
|
||||||
|
.then(() => refresh())
|
||||||
|
.catch((error) =>
|
||||||
|
notify(error.message || 'Failed to delete app password', 'warning'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copySecret = () => {
|
||||||
|
if (!createdSecret?.secret) return
|
||||||
|
navigator.clipboard
|
||||||
|
?.writeText(createdSecret.secret)
|
||||||
|
.then(() => notify('Secret copied to clipboard', 'info'))
|
||||||
|
.catch(() => notify('Could not copy to clipboard', 'warning'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ marginTop: 24 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6">App passwords (Subsonic clients)</Typography>
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
Generate a dedicated password for each Subsonic-compatible app. The
|
||||||
|
secret is shown only once.
|
||||||
|
</Typography>
|
||||||
|
<Table size="small" style={{ marginTop: 16 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Created</TableCell>
|
||||||
|
<TableCell>Last used</TableCell>
|
||||||
|
<TableCell>Expires</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell>{row.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.createdAt ? new Date(row.createdAt).toLocaleString() : ''}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.lastUsedAt
|
||||||
|
? new Date(row.lastUsedAt).toLocaleString()
|
||||||
|
: '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.expiresAt
|
||||||
|
? new Date(row.expiresAt).toLocaleString()
|
||||||
|
: 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton size="small" onClick={() => handleDelete(row.id)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} align="center">
|
||||||
|
<Typography variant="body2" color="textSecondary">
|
||||||
|
No app passwords yet.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button color="primary" onClick={() => setCreateOpen(true)}>
|
||||||
|
Generate new
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="xs"
|
||||||
|
>
|
||||||
|
<DialogTitle>New app password</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
helperText="Friendly label, e.g. 'DSub on phone'"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Expires"
|
||||||
|
type="datetime-local"
|
||||||
|
fullWidth
|
||||||
|
value={newExpiresAt}
|
||||||
|
onChange={(e) => setNewExpiresAt(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
helperText="Leave blank for no expiry"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button color="primary" disabled={!newName} onClick={handleCreate}>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={createdSecret !== null}
|
||||||
|
onClose={() => setCreatedSecret(null)}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
>
|
||||||
|
<DialogTitle>Copy this secret now</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
This secret is shown only once. Configure your Subsonic client with
|
||||||
|
this username and the secret below — Navidrome cannot retrieve it
|
||||||
|
again.
|
||||||
|
</DialogContentText>
|
||||||
|
{createdSecret && (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={createdSecret.secret || ''}
|
||||||
|
InputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton onClick={copySecret} size="small">
|
||||||
|
<FileCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button color="primary" onClick={() => setCreatedSecret(null)}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AppPasswordManager.propTypes = {
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppPasswordManager
|
||||||
108
ui/src/user/AppPasswordManager.test.jsx
Normal file
108
ui/src/user/AppPasswordManager.test.jsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import AppPasswordManager from './AppPasswordManager.jsx'
|
||||||
|
|
||||||
|
const notify = vi.fn()
|
||||||
|
vi.mock('react-admin', () => ({
|
||||||
|
useNotify: () => notify,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const httpClient = vi.fn()
|
||||||
|
vi.mock('../dataProvider/httpClient', () => ({
|
||||||
|
default: (...args) => httpClient(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../consts', () => ({
|
||||||
|
REST_URL: '/api',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const password = (overrides = {}) => ({
|
||||||
|
id: 'ap1',
|
||||||
|
name: 'DSub',
|
||||||
|
createdAt: '2026-05-01T10:00:00Z',
|
||||||
|
lastUsedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<AppPasswordManager />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
httpClient.mockReset()
|
||||||
|
notify.mockReset()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the empty state when the user has no app passwords', async () => {
|
||||||
|
httpClient.mockResolvedValueOnce({ json: [] })
|
||||||
|
|
||||||
|
render(<AppPasswordManager userId="u1" />)
|
||||||
|
|
||||||
|
expect(await screen.findByText('No app passwords yet.')).toBeInTheDocument()
|
||||||
|
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a row for each existing app password', async () => {
|
||||||
|
httpClient.mockResolvedValueOnce({
|
||||||
|
json: [password({ name: 'DSub' }), password({ id: 'ap2', name: 'Symfonium' })],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<AppPasswordManager userId="u1" />)
|
||||||
|
|
||||||
|
expect(await screen.findByText('DSub')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Symfonium')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a password and reveals the secret exactly once', async () => {
|
||||||
|
httpClient
|
||||||
|
.mockResolvedValueOnce({ json: [] }) // initial list
|
||||||
|
.mockResolvedValueOnce({ json: { id: 'ap1', name: 'CLI', secret: 's3cret' } }) // create
|
||||||
|
.mockResolvedValueOnce({ json: [password({ name: 'CLI' })] }) // refresh
|
||||||
|
|
||||||
|
render(<AppPasswordManager userId="u1" />)
|
||||||
|
await screen.findByText('No app passwords yet.')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /generate new/i }))
|
||||||
|
const nameInput = screen.getAllByRole('textbox')[0]
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'CLI' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^generate$/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('s3cret')).toBeInTheDocument()
|
||||||
|
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: 'CLI' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a password only after the user confirms', async () => {
|
||||||
|
httpClient
|
||||||
|
.mockResolvedValueOnce({ json: [password({ id: 'ap1', name: 'DSub' })] }) // initial list
|
||||||
|
.mockResolvedValueOnce({ json: {} }) // delete
|
||||||
|
.mockResolvedValueOnce({ json: [] }) // refresh
|
||||||
|
|
||||||
|
render(<AppPasswordManager userId="u1" />)
|
||||||
|
const row = await screen.findByText('DSub')
|
||||||
|
|
||||||
|
fireEvent.click(row.closest('tr').querySelector('button'))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(httpClient).toHaveBeenCalledWith('/api/user/u1/app-password/ap1', {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not delete when the user cancels the confirmation', async () => {
|
||||||
|
window.confirm.mockReturnValue(false)
|
||||||
|
httpClient.mockResolvedValueOnce({
|
||||||
|
json: [password({ id: 'ap1', name: 'DSub' })],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<AppPasswordManager userId="u1" />)
|
||||||
|
const row = await screen.findByText('DSub')
|
||||||
|
|
||||||
|
fireEvent.click(row.closest('tr').querySelector('button'))
|
||||||
|
|
||||||
|
expect(httpClient).toHaveBeenCalledTimes(1) // only the initial list
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user