fix(auth): the Subsonic password is generated, never the login password (M462 #5026)
release / govulncheck (push) Successful in 39s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m37s
release / android (push) Successful in 5m56s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 39s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m37s
release / android (push) Successful in 5m56s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
`minstrel admin reset-password` copied the new login password into subsonic_password, which is stored in plain text because Subsonic t/s sign-in needs it. Every account recovered through the CLI had its login password readable in the database, and changing the password later left the copy behind. - reset-password now changes only password_hash. - Migration 0064 clears every subsonic_password, removing the copies. - Settings gets a Subsonic password card: the server generates a random password, shows it once, and it can be regenerated or turned off (GET/POST/DELETE /api/me/subsonic-password, audited). Generated rather than user-chosen so it can never be a reused password. - docs/security.md describes the separate password instead of the known issue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+8
-13
@@ -55,12 +55,14 @@ func runAdmin(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminResetPassword resets a user's credentials. It updates BOTH
|
// adminResetPassword resets a user's login password (password_hash). It
|
||||||
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
|
// recovers a locked-out operator when the bootstrap password was missed or
|
||||||
// (plaintext, required for Subsonic t+s token verification) so neither
|
// the DB volume was recreated (Fable #321) without DB surgery.
|
||||||
// auth path is left stale. Recovers a locked-out operator when the
|
//
|
||||||
// bootstrap password was missed or the DB volume was recreated (Fable
|
// It deliberately leaves subsonic_password alone. That column is stored in
|
||||||
// #321) without DB surgery.
|
// plain text, and it used to receive the new login password here, so any
|
||||||
|
// account recovered this way had its login password readable in the database
|
||||||
|
// (#5026). The Subsonic password is generated separately in Settings.
|
||||||
func adminResetPassword(args []string) error {
|
func adminResetPassword(args []string) error {
|
||||||
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||||
@@ -112,13 +114,6 @@ func adminResetPassword(args []string) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("update password_hash: %w", err)
|
return fmt.Errorf("update password_hash: %w", err)
|
||||||
}
|
}
|
||||||
sp := pw
|
|
||||||
if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{
|
|
||||||
ID: user.ID,
|
|
||||||
SubsonicPassword: &sp,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("update subsonic_password: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if generated {
|
if generated {
|
||||||
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
||||||
|
|||||||
+13
-9
@@ -54,20 +54,24 @@ they aren't checked.
|
|||||||
Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password
|
Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password
|
||||||
followed by a random salt. To check that, the server has to know the password
|
followed by a random salt. To check that, the server has to know the password
|
||||||
itself, so supporting this sign-in method means storing a password Minstrel
|
itself, so supporting this sign-in method means storing a password Minstrel
|
||||||
can read. That is the `subsonic_password` column, and it is the only
|
can read. That is the Subsonic password, and it is the only credential
|
||||||
credential Minstrel keeps unhashed.
|
Minstrel keeps unhashed.
|
||||||
|
|
||||||
- **The recommended way in is the API key.** Clients that support the
|
- **The recommended way in is the API key.** Clients that support the
|
||||||
OpenSubsonic `apiKey` should use it. The key is stored hashed and can be
|
OpenSubsonic `apiKey` should use it. The key is stored hashed and can be
|
||||||
replaced at any time in Settings.
|
replaced at any time in Settings.
|
||||||
- **`t`/`s` and `p=` sign-in are off for an account until its
|
- **The Subsonic password is never your login password.** Minstrel generates
|
||||||
`subsonic_password` is set**, and nothing in the app sets it. Plain `p=`
|
it (**Settings → Subsonic password**), shows it once, and lets you replace
|
||||||
sign-in is additionally off server-wide unless
|
or turn it off. Because it is random, a copy of the database exposes access
|
||||||
|
to this server's Subsonic API and nothing else: it can't be a password you
|
||||||
|
also use somewhere else.
|
||||||
|
- **`t`/`s` and `p=` sign-in are off for an account until it has a Subsonic
|
||||||
|
password.** Plain `p=` sign-in is additionally off server-wide unless
|
||||||
`subsonic.allow_plaintext_password` is enabled.
|
`subsonic.allow_plaintext_password` is enabled.
|
||||||
- **Known issue:** `minstrel admin reset-password` writes the new login
|
- `minstrel admin reset-password` changes only the login password. Older
|
||||||
password into `subsonic_password` as well, so `t`/`s` clients keep working
|
versions also copied it into the Subsonic password; upgrading clears every
|
||||||
after a recovery. For an account reset that way, the login password is
|
Subsonic password once, so those copies are gone. An account that used
|
||||||
stored in plain text until the column is cleared.
|
`t`/`s` sign-in needs a new Subsonic password generated in Settings.
|
||||||
|
|
||||||
## Android allows plain HTTP
|
## Android allows plain HTTP
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||||
|
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
||||||
|
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
||||||
|
authed.Delete("/me/subsonic-password", h.handleClearMySubsonicPassword)
|
||||||
authed.Get("/me/sessions", h.handleListMySessions)
|
authed.Get("/me/sessions", h.handleListMySessions)
|
||||||
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||||
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Subsonic password is for clients that sign in with Subsonic's t/s
|
||||||
|
// scheme (md5 of the password plus a salt). Checking that needs the password
|
||||||
|
// itself, so it is stored readable (migration 0003). That is why Minstrel
|
||||||
|
// generates it rather than letting the user choose one: a generated value
|
||||||
|
// can never be a login password reused from somewhere else, so a leaked
|
||||||
|
// users table gives up access to this server's /rest API and nothing more
|
||||||
|
// (M462 #5026).
|
||||||
|
|
||||||
|
const subsonicPasswordBytes = 18 // 24 base64url characters
|
||||||
|
|
||||||
|
type subsonicPasswordStatusResp struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type subsonicPasswordResp struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetMySubsonicPassword implements GET /api/me/subsonic-password. It
|
||||||
|
// reports only whether one is set; the value is shown once, when generated.
|
||||||
|
func (h *handlers) handleGetMySubsonicPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, subsonicPasswordStatusResp{Enabled: user.SubsonicPassword != nil})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenerateMySubsonicPassword implements POST /api/me/subsonic-password:
|
||||||
|
// replaces any existing Subsonic password with a new random one and returns it.
|
||||||
|
func (h *handlers) handleGenerateMySubsonicPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := make([]byte, subsonicPasswordBytes)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
h.logger.Error("generate subsonic password: rand failed", "err", err)
|
||||||
|
writeErr(w, apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pw := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{
|
||||||
|
ID: user.ID,
|
||||||
|
SubsonicPassword: &pw,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("generate subsonic password: update failed", "err", err)
|
||||||
|
writeErr(w, apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSubsonicPasswordSet, nil)
|
||||||
|
writeJSON(w, http.StatusOK, subsonicPasswordResp{Password: pw})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClearMySubsonicPassword implements DELETE /api/me/subsonic-password,
|
||||||
|
// which turns t/s and p= sign-in off for the account.
|
||||||
|
func (h *handlers) handleClearMySubsonicPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{
|
||||||
|
ID: user.ID,
|
||||||
|
SubsonicPassword: nil,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("clear subsonic password: update failed", "err", err)
|
||||||
|
writeErr(w, apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSubsonicPasswordClear, nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMeSubsonicPasswordRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/me/subsonic-password", h.handleGetMySubsonicPassword)
|
||||||
|
r.Post("/api/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
||||||
|
r.Delete("/api/me/subsonic-password", h.handleClearMySubsonicPassword)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSubsonicPassword(t *testing.T, h *handlers, user dbq.User) *string {
|
||||||
|
t.Helper()
|
||||||
|
var pw *string
|
||||||
|
if err := h.pool.QueryRow(context.Background(),
|
||||||
|
"SELECT subsonic_password FROM users WHERE id = $1", user.ID).Scan(&pw); err != nil {
|
||||||
|
t.Fatalf("read subsonic_password: %v", err)
|
||||||
|
}
|
||||||
|
return pw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubsonicPassword_GenerateIsRandomAndNotTheLoginPassword(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "sspw1", "login-pw", false)
|
||||||
|
router := newMeSubsonicPasswordRouter(h)
|
||||||
|
|
||||||
|
generate := func() string {
|
||||||
|
req := withUser(httptest.NewRequest(http.MethodPost, "/api/me/subsonic-password", nil), user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp subsonicPasswordResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return resp.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
first := generate()
|
||||||
|
if len(first) != 24 {
|
||||||
|
t.Errorf("password length = %d, want 24", len(first))
|
||||||
|
}
|
||||||
|
if first == "login-pw" {
|
||||||
|
t.Errorf("generated password equals the login password")
|
||||||
|
}
|
||||||
|
if got := readSubsonicPassword(t, h, user); got == nil || *got != first {
|
||||||
|
t.Errorf("stored = %v, want the returned password", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := generate()
|
||||||
|
if second == first {
|
||||||
|
t.Errorf("regenerate returned the same password")
|
||||||
|
}
|
||||||
|
if got := readSubsonicPassword(t, h, user); got == nil || *got != second {
|
||||||
|
t.Errorf("stored after regenerate = %v, want the new password", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubsonicPassword_StatusAndClear(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "sspw2", "login-pw", false)
|
||||||
|
router := newMeSubsonicPasswordRouter(h)
|
||||||
|
|
||||||
|
status := func(u dbq.User) bool {
|
||||||
|
req := withUser(httptest.NewRequest(http.MethodGet, "/api/me/subsonic-password", nil), u)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp subsonicPasswordStatusResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return resp.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if status(user) {
|
||||||
|
t.Errorf("enabled = true for a new account, want false")
|
||||||
|
}
|
||||||
|
pw := "set-by-test"
|
||||||
|
user.SubsonicPassword = &pw
|
||||||
|
if !status(user) {
|
||||||
|
t.Errorf("enabled = false with a password set, want true")
|
||||||
|
}
|
||||||
|
// The status response never carries the value itself.
|
||||||
|
req := withUser(httptest.NewRequest(http.MethodGet, "/api/me/subsonic-password", nil), user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if _, has := raw["password"]; has {
|
||||||
|
t.Errorf("GET response includes the password: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dbq.New(pool).SetSubsonicPassword(context.Background(), dbq.SetSubsonicPasswordParams{
|
||||||
|
ID: user.ID, SubsonicPassword: &pw,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed subsonic_password: %v", err)
|
||||||
|
}
|
||||||
|
req = withUser(httptest.NewRequest(http.MethodDelete, "/api/me/subsonic-password", nil), user)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("DELETE status = %d, want 204; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := readSubsonicPassword(t, h, user); got != nil {
|
||||||
|
t.Errorf("stored after clear = %q, want NULL", *got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,10 @@ const (
|
|||||||
// and its history moved onto the copy kept. The metadata names both, so the
|
// and its history moved onto the copy kept. The metadata names both, so the
|
||||||
// log can answer "where did that file go" long after the report is gone.
|
// log can answer "where did that file go" long after the report is gone.
|
||||||
ActionDuplicateMerge Action = "duplicate_merge"
|
ActionDuplicateMerge Action = "duplicate_merge"
|
||||||
|
|
||||||
|
// Subsonic password (#5026): generated in Settings for t/s-only clients.
|
||||||
|
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
||||||
|
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Write inserts one audit_log row. metadata is marshaled as JSON;
|
// Write inserts one audit_log row. metadata is marshaled as JSON;
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ func TestWrite_AllActionConstantsArePersisted(t *testing.T) {
|
|||||||
audit.ActionForgotPasswordInit,
|
audit.ActionForgotPasswordInit,
|
||||||
audit.ActionPasswordResetByEmail,
|
audit.ActionPasswordResetByEmail,
|
||||||
audit.ActionDuplicateMerge,
|
audit.ActionDuplicateMerge,
|
||||||
|
audit.ActionSubsonicPasswordSet,
|
||||||
|
audit.ActionSubsonicPasswordClear,
|
||||||
}
|
}
|
||||||
for _, a := range actions {
|
for _, a := range actions {
|
||||||
if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, a, nil); err != nil {
|
if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, a, nil); err != nil {
|
||||||
|
|||||||
@@ -589,7 +589,8 @@ type SetSubsonicPasswordParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stores (or clears with NULL) the per-user Subsonic legacy credential used
|
// Stores (or clears with NULL) the per-user Subsonic legacy credential used
|
||||||
// for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
|
// for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only
|
||||||
|
// ever a server-generated value, never the login password (#5026).
|
||||||
func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error {
|
func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error {
|
||||||
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
|
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- The cleared values are gone and were never meant to be kept; nothing to undo.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- `minstrel admin reset-password` used to copy the new login password into
|
||||||
|
-- subsonic_password, so every account recovered through the CLI had its login
|
||||||
|
-- password stored in plain text, and a later password change in Settings left
|
||||||
|
-- that copy behind (M462 #5026). The CLI no longer writes this column; a
|
||||||
|
-- Subsonic password is now generated separately in Settings and is never the
|
||||||
|
-- login password. Clearing every value here removes the copies already made.
|
||||||
|
--
|
||||||
|
-- Accounts whose Subsonic client signs in with t/s stop working until the user
|
||||||
|
-- generates a Subsonic password (or switches the client to an API key).
|
||||||
|
UPDATE users SET subsonic_password = NULL WHERE subsonic_password IS NOT NULL;
|
||||||
@@ -36,7 +36,8 @@ SELECT count(*) FROM users;
|
|||||||
|
|
||||||
-- name: SetSubsonicPassword :exec
|
-- name: SetSubsonicPassword :exec
|
||||||
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
|
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
|
||||||
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
|
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only
|
||||||
|
-- ever a server-generated value, never the login password (#5026).
|
||||||
UPDATE users SET subsonic_password = $2 WHERE id = $1;
|
UPDATE users SET subsonic_password = $2 WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetUserByID :one
|
-- name: GetUserByID :one
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ export async function regenerateAPIToken(): Promise<APITokenResponse> {
|
|||||||
return api.post<APITokenResponse>('/api/me/api-token', {});
|
return api.post<APITokenResponse>('/api/me/api-token', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subsonic password (#5026) ------------------------------------------------
|
||||||
|
// For Subsonic clients that only sign in with a username and password (the
|
||||||
|
// t/s scheme). The server generates it, so it is never the login password;
|
||||||
|
// like the API key it is shown once, when generated.
|
||||||
|
|
||||||
|
export type SubsonicPasswordStatus = { enabled: boolean };
|
||||||
|
|
||||||
|
export async function getSubsonicPasswordStatus(): Promise<SubsonicPasswordStatus> {
|
||||||
|
return api.get<SubsonicPasswordStatus>('/api/me/subsonic-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateSubsonicPassword(): Promise<{ password: string }> {
|
||||||
|
return api.post<{ password: string }>('/api/me/subsonic-password', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSubsonicPassword(): Promise<void> {
|
||||||
|
await api.del('/api/me/subsonic-password');
|
||||||
|
}
|
||||||
|
|
||||||
// Submits the browser's current IANA timezone for the authenticated
|
// Submits the browser's current IANA timezone for the authenticated
|
||||||
// user. Called from the auth store on login + bootstrap + once weekly
|
// user. Called from the auth store on login + bootstrap + once weekly
|
||||||
// (cadence tracked client-side in localStorage). Failures are
|
// (cadence tracked client-side in localStorage). Failures are
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import type { CreateQueryResult, CreateMutationResult } from '@tanstack/svelte-query';
|
import type { CreateQueryResult, CreateMutationResult } from '@tanstack/svelte-query';
|
||||||
@@ -19,7 +20,10 @@
|
|||||||
import {
|
import {
|
||||||
updateProfile,
|
updateProfile,
|
||||||
changePassword,
|
changePassword,
|
||||||
regenerateAPIToken
|
regenerateAPIToken,
|
||||||
|
getSubsonicPasswordStatus,
|
||||||
|
generateSubsonicPassword,
|
||||||
|
clearSubsonicPassword
|
||||||
} from '$lib/api/me';
|
} from '$lib/api/me';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
@@ -203,6 +207,77 @@
|
|||||||
tokenSaving = false;
|
tokenSaving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subsonic password card (#5026) ------------------------------------------
|
||||||
|
|
||||||
|
// null until the status loads. The value itself is only held right after
|
||||||
|
// Generate, for the same reason as the API token above.
|
||||||
|
let subsonicEnabled = $state<boolean | null>(null);
|
||||||
|
let subsonicPassword = $state<string | null>(null);
|
||||||
|
let subsonicSaving = $state(false);
|
||||||
|
let confirmSubsonic = $state<'generate' | 'clear' | null>(null);
|
||||||
|
let subsonicTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
subsonicEnabled = (await getSubsonicPasswordStatus()).enabled;
|
||||||
|
} catch {
|
||||||
|
// Leave it unknown; the buttons still work.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replacing or removing a password breaks clients already using it, so
|
||||||
|
// both ask for a second click. A first-time Generate breaks nothing.
|
||||||
|
function armSubsonic(action: 'generate' | 'clear'): boolean {
|
||||||
|
if (confirmSubsonic === action) {
|
||||||
|
if (subsonicTimer) clearTimeout(subsonicTimer);
|
||||||
|
confirmSubsonic = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
confirmSubsonic = action;
|
||||||
|
if (subsonicTimer) clearTimeout(subsonicTimer);
|
||||||
|
subsonicTimer = setTimeout(() => { confirmSubsonic = null; }, 5000);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGenerateSubsonic() {
|
||||||
|
if (subsonicEnabled && !armSubsonic('generate')) return;
|
||||||
|
subsonicSaving = true;
|
||||||
|
try {
|
||||||
|
subsonicPassword = (await generateSubsonicPassword()).password;
|
||||||
|
subsonicEnabled = true;
|
||||||
|
pushToast('Subsonic password created. Copy it now; it will not be shown again.');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Generate failed: ${errCode(e)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
subsonicSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearSubsonic() {
|
||||||
|
if (!armSubsonic('clear')) return;
|
||||||
|
subsonicSaving = true;
|
||||||
|
try {
|
||||||
|
await clearSubsonicPassword();
|
||||||
|
subsonicPassword = null;
|
||||||
|
subsonicEnabled = false;
|
||||||
|
pushToast('Subsonic password turned off.');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Turn off failed: ${errCode(e)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
subsonicSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySubsonic() {
|
||||||
|
if (!subsonicPassword) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(subsonicPassword);
|
||||||
|
pushToast('Password copied to clipboard.');
|
||||||
|
} catch {
|
||||||
|
pushToast('Copy failed.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{pageTitle('Settings')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Settings')}</title></svelte:head>
|
||||||
@@ -553,9 +628,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Sits with Password and API Token rather than near the bottom: these
|
<!-- Subsonic password card -->
|
||||||
three are the account-security group, and this is the one that tells
|
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||||
you the other two need attention. -->
|
<h2 class="text-lg font-semibold">Subsonic password</h2>
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
For Subsonic apps that can't use an API token and ask for a username and password instead.
|
||||||
|
Sign those apps in with your username and this password, not your login password.
|
||||||
|
Minstrel generates it, and has to store it readable for these apps to work, so it is never
|
||||||
|
your login password. Use the API token where the app supports it.
|
||||||
|
</p>
|
||||||
|
{#if subsonicEnabled !== null}
|
||||||
|
<p class="text-sm">
|
||||||
|
{subsonicEnabled ? 'A Subsonic password is set.' : "No Subsonic password is set; these apps can't sign in."}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if subsonicPassword}
|
||||||
|
<code class="block break-all rounded bg-background p-2 text-xs">
|
||||||
|
{subsonicPassword}
|
||||||
|
</code>
|
||||||
|
<p class="text-xs text-text-secondary">Copy this now. It won't be shown again.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if subsonicPassword}
|
||||||
|
<button type="button" onclick={copySubsonic}
|
||||||
|
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" disabled={subsonicSaving}
|
||||||
|
onclick={onGenerateSubsonic}
|
||||||
|
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||||
|
{confirmSubsonic === 'generate' ? 'Click again to confirm' : (subsonicEnabled ? 'Regenerate' : 'Generate')}
|
||||||
|
</button>
|
||||||
|
{#if subsonicEnabled}
|
||||||
|
<button type="button" disabled={subsonicSaving}
|
||||||
|
onclick={onClearSubsonic}
|
||||||
|
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||||
|
{confirmSubsonic === 'clear' ? 'Click again to confirm' : 'Turn off'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sits with Password, API Token and Subsonic password rather than near
|
||||||
|
the bottom: these are the account-security group, and this is the one
|
||||||
|
that tells you the others need attention. -->
|
||||||
<ActiveSessions />
|
<ActiveSessions />
|
||||||
|
|
||||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||||
import { readable, writable } from 'svelte/store';
|
import { readable, writable } from 'svelte/store';
|
||||||
import type { LBStatus } from '$lib/api/listenbrainz';
|
import type { LBStatus } from '$lib/api/listenbrainz';
|
||||||
|
|
||||||
@@ -16,7 +16,10 @@ vi.mock('$lib/api/me', () => ({
|
|||||||
changePassword: vi.fn(),
|
changePassword: vi.fn(),
|
||||||
// Default to a resolved value so the page's $effect doesn't crash
|
// Default to a resolved value so the page's $effect doesn't crash
|
||||||
// on `.then()` of undefined when individual tests don't override.
|
// on `.then()` of undefined when individual tests don't override.
|
||||||
regenerateAPIToken: vi.fn()
|
regenerateAPIToken: vi.fn(),
|
||||||
|
getSubsonicPasswordStatus: vi.fn(),
|
||||||
|
generateSubsonicPassword: vi.fn(),
|
||||||
|
clearSubsonicPassword: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mutable holder so individual tests can inject populated metrics;
|
// Mutable holder so individual tests can inject populated metrics;
|
||||||
@@ -45,7 +48,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
updateProfile,
|
updateProfile,
|
||||||
changePassword,
|
changePassword,
|
||||||
regenerateAPIToken
|
regenerateAPIToken,
|
||||||
|
getSubsonicPasswordStatus,
|
||||||
|
generateSubsonicPassword,
|
||||||
|
clearSubsonicPassword
|
||||||
} from '$lib/api/me';
|
} from '$lib/api/me';
|
||||||
|
|
||||||
function mockStatusStore(data: LBStatus) {
|
function mockStatusStore(data: LBStatus) {
|
||||||
@@ -361,3 +367,48 @@ describe('Settings page — API Token card', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /^copy$/i })).toBeNull();
|
expect(screen.queryByRole('button', { name: /^copy$/i })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Settings page — Subsonic password card', () => {
|
||||||
|
function mockLB() {
|
||||||
|
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
|
||||||
|
);
|
||||||
|
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||||
|
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
|
||||||
|
}
|
||||||
|
|
||||||
|
test('with none set, Generate creates one on the first click and shows it once', async () => {
|
||||||
|
mockLB();
|
||||||
|
(getSubsonicPasswordStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ enabled: false });
|
||||||
|
(generateSubsonicPassword as ReturnType<typeof vi.fn>).mockResolvedValue({ password: 'gen_pw_123' });
|
||||||
|
render(SettingsPage);
|
||||||
|
await waitFor(() => expect(screen.getByText(/no subsonic password is set/i)).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('button', { name: /turn off/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /^generate$/i }));
|
||||||
|
await waitFor(() => expect(generateSubsonicPassword).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(screen.getByText('gen_pw_123')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText(/a subsonic password is set/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with one set, Regenerate and Turn off each need a second click', async () => {
|
||||||
|
mockLB();
|
||||||
|
(getSubsonicPasswordStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ enabled: true });
|
||||||
|
(clearSubsonicPassword as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
render(SettingsPage);
|
||||||
|
// The API token card has a Regenerate button too; the Subsonic card's is
|
||||||
|
// the one beside Turn off.
|
||||||
|
const turnOff = await screen.findByRole('button', { name: /turn off/i });
|
||||||
|
const subsonicRegen = within(turnOff.parentElement!).getByRole('button', { name: /^regenerate$/i });
|
||||||
|
|
||||||
|
await fireEvent.click(subsonicRegen);
|
||||||
|
expect(generateSubsonicPassword).not.toHaveBeenCalled();
|
||||||
|
expect(subsonicRegen).toHaveTextContent(/click again to confirm/i);
|
||||||
|
|
||||||
|
await fireEvent.click(turnOff);
|
||||||
|
expect(clearSubsonicPassword).not.toHaveBeenCalled();
|
||||||
|
await fireEvent.click(turnOff);
|
||||||
|
await waitFor(() => expect(clearSubsonicPassword).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(screen.getByText(/no subsonic password is set/i)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user