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
|
||||
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
|
||||
// (plaintext, required for Subsonic t+s token verification) so neither
|
||||
// auth path is left stale. Recovers a locked-out operator when the
|
||||
// bootstrap password was missed or the DB volume was recreated (Fable
|
||||
// #321) without DB surgery.
|
||||
// adminResetPassword resets a user's login password (password_hash). It
|
||||
// recovers a locked-out operator when the bootstrap password was missed or
|
||||
// the DB volume was recreated (Fable #321) without DB surgery.
|
||||
//
|
||||
// It deliberately leaves subsonic_password alone. That column is stored in
|
||||
// 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 {
|
||||
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
||||
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||
@@ -112,13 +114,6 @@ func adminResetPassword(args []string) error {
|
||||
}); err != nil {
|
||||
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 {
|
||||
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
|
||||
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
|
||||
can read. That is the `subsonic_password` column, and it is the only
|
||||
credential Minstrel keeps unhashed.
|
||||
can read. That is the Subsonic password, and it is the only credential
|
||||
Minstrel keeps unhashed.
|
||||
|
||||
- **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
|
||||
replaced at any time in Settings.
|
||||
- **`t`/`s` and `p=` sign-in are off for an account until its
|
||||
`subsonic_password` is set**, and nothing in the app sets it. Plain `p=`
|
||||
sign-in is additionally off server-wide unless
|
||||
- **The Subsonic password is never your login password.** Minstrel generates
|
||||
it (**Settings → Subsonic password**), shows it once, and lets you replace
|
||||
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.
|
||||
- **Known issue:** `minstrel admin reset-password` writes the new login
|
||||
password into `subsonic_password` as well, so `t`/`s` clients keep working
|
||||
after a recovery. For an account reset that way, the login password is
|
||||
stored in plain text until the column is cleared.
|
||||
- `minstrel admin reset-password` changes only the login password. Older
|
||||
versions also copied it into the Subsonic password; upgrading clears every
|
||||
Subsonic password once, so those copies are gone. An account that used
|
||||
`t`/`s` sign-in needs a new Subsonic password generated in Settings.
|
||||
|
||||
## 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/timezone", h.handlePutTimezone)
|
||||
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.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||
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
|
||||
// log can answer "where did that file go" long after the report is gone.
|
||||
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;
|
||||
|
||||
@@ -169,6 +169,8 @@ func TestWrite_AllActionConstantsArePersisted(t *testing.T) {
|
||||
audit.ActionForgotPasswordInit,
|
||||
audit.ActionPasswordResetByEmail,
|
||||
audit.ActionDuplicateMerge,
|
||||
audit.ActionSubsonicPasswordSet,
|
||||
audit.ActionSubsonicPasswordClear,
|
||||
}
|
||||
for _, a := range actions {
|
||||
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
|
||||
// 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 {
|
||||
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
|
||||
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
|
||||
-- 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;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
|
||||
@@ -55,6 +55,25 @@ export async function regenerateAPIToken(): Promise<APITokenResponse> {
|
||||
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
|
||||
// user. Called from the auth store on login + bootstrap + once weekly
|
||||
// (cadence tracked client-side in localStorage). Failures are
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import type { CreateQueryResult, CreateMutationResult } from '@tanstack/svelte-query';
|
||||
@@ -19,7 +20,10 @@
|
||||
import {
|
||||
updateProfile,
|
||||
changePassword,
|
||||
regenerateAPIToken
|
||||
regenerateAPIToken,
|
||||
getSubsonicPasswordStatus,
|
||||
generateSubsonicPassword,
|
||||
clearSubsonicPassword
|
||||
} from '$lib/api/me';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
@@ -203,6 +207,77 @@
|
||||
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>
|
||||
|
||||
<svelte:head><title>{pageTitle('Settings')}</title></svelte:head>
|
||||
@@ -553,9 +628,51 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sits with Password and API Token rather than near the bottom: these
|
||||
three are the account-security group, and this is the one that tells
|
||||
you the other two need attention. -->
|
||||
<!-- Subsonic password card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<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 />
|
||||
|
||||
<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 { 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 type { LBStatus } from '$lib/api/listenbrainz';
|
||||
|
||||
@@ -16,7 +16,10 @@ vi.mock('$lib/api/me', () => ({
|
||||
changePassword: vi.fn(),
|
||||
// Default to a resolved value so the page's $effect doesn't crash
|
||||
// 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;
|
||||
@@ -45,7 +48,10 @@ import {
|
||||
import {
|
||||
updateProfile,
|
||||
changePassword,
|
||||
regenerateAPIToken
|
||||
regenerateAPIToken,
|
||||
getSubsonicPasswordStatus,
|
||||
generateSubsonicPassword,
|
||||
clearSubsonicPassword
|
||||
} from '$lib/api/me';
|
||||
|
||||
function mockStatusStore(data: LBStatus) {
|
||||
@@ -361,3 +367,48 @@ describe('Settings page — API Token card', () => {
|
||||
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