mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +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
|
||||
User(ctx context.Context) UserRepository
|
||||
UserProps(ctx context.Context) UserPropsRepository
|
||||
AppPassword(ctx context.Context) AppPasswordRepository
|
||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||
Scrobble(ctx context.Context) ScrobbleRepository
|
||||
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())
|
||||
}
|
||||
|
||||
func (s *SQLStore) AppPassword(ctx context.Context) model.AppPasswordRepository {
|
||||
return NewAppPasswordRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
|
||||
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.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
api.addAppPasswordRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
api.addInspectRoute(r)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"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)
|
||||
default:
|
||||
err = validateCredentials(usr, pass, token, salt, jwt)
|
||||
if err != nil && jwt == "" {
|
||||
err = validateAppPasswordCredentials(ctx, ds, usr, pass, token, salt)
|
||||
}
|
||||
if err != nil {
|
||||
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 {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUser, ok := request.UserFrom(r.Context())
|
||||
if !ok {
|
||||
sendError(w, r, newError(responses.ErrorGeneric, "Internal error"))
|
||||
return
|
||||
}
|
||||
// validateAppPasswordCredentials is the Subsonic-side fallback for users who
|
||||
// authenticate with a per-application secondary password instead of their
|
||||
// primary one. It only kicks in when the request is NOT presenting a JWT
|
||||
// (those are exclusively for Navidrome-issued tokens).
|
||||
//
|
||||
// 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 {
|
||||
sendError(w, r, newError(responses.ErrorAuthorizationFail))
|
||||
return
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
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 {
|
||||
|
||||
@ -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() {
|
||||
BeforeEach(func() {
|
||||
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() {
|
||||
var mockedPlayers *mockPlayers
|
||||
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
|
||||
MockedRadio model.RadioRepository
|
||||
MockedPlugin model.PluginRepository
|
||||
MockedAppPassword model.AppPasswordRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
|
||||
@ -247,6 +248,17 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository {
|
||||
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 {
|
||||
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