feat(model): add app password support

Adds the model, persistence, and database migration for named per-user
app passwords. Secret values are AES-GCM encrypted at rest using a key
generated on first run and stored in the data directory.

This is a standalone primitive useful on its own for Subsonic clients
and CLI/script auth and lands ahead of the OIDC work that will rely
on it for IdP-managed users.
This commit is contained in:
zkvvoob 2026-05-20 13:45:52 +03:00
parent efe9291db0
commit 14ba9c7ed3
No known key found for this signature in database
GPG Key ID: 3CBAEDB5B3509ECE
9 changed files with 498 additions and 0 deletions

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

View File

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

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

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

View File

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

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

View File

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