feat(auth): store Subsonic API keys hashed; a new key is shown once (M462 #4983)
test-web / test (push) Successful in 2m4s
test-go / test (push) Successful in 2m23s
test-go / integration (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 6m29s
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped

users.api_token held each user's apiKey in plaintext and was looked up by
equality, so a leaked row or backup handed out working keys. Migration
0063 replaces it with api_token_hash (sha256, hex), computed in place
from the existing keys so every Subsonic client keeps working.

The key can no longer be read back: GET /api/me/api-token is gone, and
POST returns the new key once. Settings shows it right after Regenerate
with a copy button and a "won't be shown again" note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:42:35 -04:00
co-authored by Claude Opus 5.5
parent 2f3fbccab6
commit 327d49428f
33 changed files with 161 additions and 179 deletions
+1 -1
View File
@@ -169,7 +169,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
user, err := q.CreateUserAdmin(r.Context(), dbq.CreateUserAdminParams{ user, err := q.CreateUserAdmin(r.Context(), dbq.CreateUserAdminParams{
Username: req.Username, Username: req.Username,
PasswordHash: string(hash), PasswordHash: string(hash),
ApiToken: apiToken, ApiTokenHash: auth.HashAPIToken(apiToken),
IsAdmin: req.IsAdmin, IsAdmin: req.IsAdmin,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
}) })
-1
View File
@@ -108,7 +108,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Put("/me/password", h.handleChangePassword) authed.Put("/me/password", h.handleChangePassword)
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.Get("/me/api-token", h.handleGetMyAPIToken)
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken) authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
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)
+1 -1
View File
@@ -161,7 +161,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{ user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{
Username: req.Username, Username: req.Username,
PasswordHash: string(hash), PasswordHash: string(hash),
ApiToken: apiToken, ApiTokenHash: auth.HashAPIToken(apiToken),
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
}) })
if err != nil { if err != nil {
+1 -1
View File
@@ -91,7 +91,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdm
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: prefixed, Username: prefixed,
PasswordHash: string(hash), PasswordHash: string(hash),
ApiToken: "test-api-token-" + prefixed, ApiTokenHash: "test-api-token-" + prefixed,
IsAdmin: isAdmin, IsAdmin: isAdmin,
}) })
if err != nil { if err != nil {
+8 -21
View File
@@ -13,23 +13,11 @@ type apiTokenResp struct {
APIToken string `json:"api_token"` APIToken string `json:"api_token"`
} }
// handleGetMyAPIToken implements GET /api/me/api-token.
func (h *handlers) handleGetMyAPIToken(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
current, err := q.GetUserByID(r.Context(), user.ID)
if err != nil {
h.logger.Error("get api token: lookup failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: current.ApiToken})
}
// handleRegenerateMyAPIToken implements POST /api/me/api-token. // handleRegenerateMyAPIToken implements POST /api/me/api-token.
//
// Only the key's hash is stored, so this response is the one time the key
// can be read. There is deliberately no GET: a key that has been shown and
// lost is replaced, not looked up.
func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r) user, ok := requireUser(w, r)
if !ok { if !ok {
@@ -42,11 +30,10 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
return return
} }
q := dbq.New(h.pool) q := dbq.New(h.pool)
updated, err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{ if err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{
ID: user.ID, ID: user.ID,
ApiToken: newToken, ApiTokenHash: auth.HashAPIToken(newToken),
}) }); err != nil {
if err != nil {
h.logger.Error("regenerate api token: update failed", "err", err) h.logger.Error("regenerate api token: update failed", "err", err)
writeErr(w, apierror.Internal(err)) writeErr(w, apierror.Internal(err))
return return
@@ -54,5 +41,5 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionTokenRegenerate, nil) audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionTokenRegenerate, nil)
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: updated.ApiToken}) writeJSON(w, http.StatusOK, apiTokenResp{APIToken: newToken})
} }
+14 -38
View File
@@ -8,47 +8,23 @@ import (
"os" "os"
"testing" "testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func newMeTokenRouter(h *handlers) chi.Router { func newMeTokenRouter(h *handlers) chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/api/me/api-token", h.handleGetMyAPIToken)
r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken) r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken)
return r return r
} }
func TestGetAPIToken_ReturnsCurrentToken(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, "tok1", "pw", false)
req := httptest.NewRequest(http.MethodGet, "/api/me/api-token", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newMeTokenRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp apiTokenResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.APIToken != user.ApiToken {
t.Errorf("api_token = %q, want %q", resp.APIToken, user.ApiToken)
}
}
func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) { func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set") t.Skip("MINSTREL_TEST_DATABASE_URL not set")
} }
h, pool := testHandlers(t) h, pool := testHandlers(t)
user := seedUser(t, pool, "tok2", "pw", false) user := seedUser(t, pool, "tok2", "pw", false)
oldToken := user.ApiToken oldHash := user.ApiTokenHash
req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil) req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil)
req = withUser(req, user) req = withUser(req, user)
@@ -65,20 +41,20 @@ func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) {
if resp.APIToken == "" { if resp.APIToken == "" {
t.Fatalf("api_token is empty") t.Fatalf("api_token is empty")
} }
if resp.APIToken == oldToken { // The DB holds the new key's hash, never the key, and the old key no
t.Errorf("api_token unchanged after regenerate") // longer matches.
} var dbHash string
// Verify DB row has the new token and old token no longer matches.
var dbToken string
if err := pool.QueryRow(context.Background(), if err := pool.QueryRow(context.Background(),
"SELECT api_token FROM users WHERE id = $1", user.ID).Scan(&dbToken); err != nil { "SELECT api_token_hash FROM users WHERE id = $1", user.ID).Scan(&dbHash); err != nil {
t.Fatalf("read token: %v", err) t.Fatalf("read token hash: %v", err)
} }
if dbToken != resp.APIToken { if dbHash != auth.HashAPIToken(resp.APIToken) {
t.Errorf("DB api_token = %q, want %q", dbToken, resp.APIToken) t.Errorf("DB api_token_hash = %q, want hash of the returned key", dbHash)
} }
if dbToken == oldToken { if dbHash == oldHash {
t.Errorf("old token still in DB after regenerate") t.Errorf("old key hash still in DB after regenerate")
}
if dbHash == resp.APIToken {
t.Errorf("DB holds the raw key")
} }
} }
+10
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -41,6 +42,15 @@ func HashSessionToken(token string) []byte {
return sum[:] return sum[:]
} }
// HashAPIToken maps a raw API key (the OpenSubsonic apiKey) to the
// `users.api_token_hash` column: sha256, hex. The key is minted with
// MintSessionToken, so it carries the same 256 bits and the same reasoning
// as HashSessionToken applies. Hex rather than bytes so the column stays
// text and a migration can compute it in SQL from the old plaintext.
func HashAPIToken(token string) string {
return hex.EncodeToString(HashSessionToken(token))
}
// VerifyPassword is the canonical bcrypt comparison. Returns false on a // VerifyPassword is the canonical bcrypt comparison. Returns false on a
// malformed hash so callers don't need to distinguish "hash invalid" from // malformed hash so callers don't need to distinguish "hash invalid" from
// "password wrong" — both are auth failures from the client's perspective. // "password wrong" — both are auth failures from the client's perspective.
+1 -1
View File
@@ -42,7 +42,7 @@ func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Disca
func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID { func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID {
t.Helper() t.Helper()
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false, Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-tok", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed user %s: %v", name, err) t.Fatalf("seed user %s: %v", name, err)
+1 -1
View File
@@ -731,7 +731,6 @@ type User struct {
ID pgtype.UUID ID pgtype.UUID
Username string Username string
PasswordHash string PasswordHash string
ApiToken string
IsAdmin bool IsAdmin bool
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
SubsonicPassword *string SubsonicPassword *string
@@ -743,6 +742,7 @@ type User struct {
Timezone string Timezone string
TimezoneUpdatedAt pgtype.Timestamptz TimezoneUpdatedAt pgtype.Timestamptz
DebugModeEnabled bool DebugModeEnabled bool
ApiTokenHash string
} }
type UserInvite struct { type UserInvite struct {
+44 -62
View File
@@ -52,15 +52,15 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
} }
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type CreateUserParams struct { type CreateUserParams struct {
Username string Username string
PasswordHash string PasswordHash string
ApiToken string ApiTokenHash string
IsAdmin bool IsAdmin bool
DisplayName *string DisplayName *string
} }
@@ -69,7 +69,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
row := q.db.QueryRow(ctx, createUser, row := q.db.QueryRow(ctx, createUser,
arg.Username, arg.Username,
arg.PasswordHash, arg.PasswordHash,
arg.ApiToken, arg.ApiTokenHash,
arg.IsAdmin, arg.IsAdmin,
arg.DisplayName, arg.DisplayName,
) )
@@ -78,7 +78,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -90,20 +89,21 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const createUserAdmin = `-- name: CreateUserAdmin :one const createUserAdmin = `-- name: CreateUserAdmin :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type CreateUserAdminParams struct { type CreateUserAdminParams struct {
Username string Username string
PasswordHash string PasswordHash string
ApiToken string ApiTokenHash string
IsAdmin bool IsAdmin bool
DisplayName *string DisplayName *string
} }
@@ -115,7 +115,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
row := q.db.QueryRow(ctx, createUserAdmin, row := q.db.QueryRow(ctx, createUserAdmin,
arg.Username, arg.Username,
arg.PasswordHash, arg.PasswordHash,
arg.ApiToken, arg.ApiTokenHash,
arg.IsAdmin, arg.IsAdmin,
arg.DisplayName, arg.DisplayName,
) )
@@ -124,7 +124,6 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -136,24 +135,25 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const createUserFirstAdminRace = `-- name: CreateUserFirstAdminRace :one const createUserFirstAdminRace = `-- name: CreateUserFirstAdminRace :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ( VALUES (
$1, $2, $3, $1, $2, $3,
(SELECT NOT EXISTS (SELECT 1 FROM users)), (SELECT NOT EXISTS (SELECT 1 FROM users)),
$4 $4
) )
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type CreateUserFirstAdminRaceParams struct { type CreateUserFirstAdminRaceParams struct {
Username string Username string
PasswordHash string PasswordHash string
ApiToken string ApiTokenHash string
DisplayName *string DisplayName *string
} }
@@ -174,7 +174,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
row := q.db.QueryRow(ctx, createUserFirstAdminRace, row := q.db.QueryRow(ctx, createUserFirstAdminRace,
arg.Username, arg.Username,
arg.PasswordHash, arg.PasswordHash,
arg.ApiToken, arg.ApiTokenHash,
arg.DisplayName, arg.DisplayName,
) )
var i User var i User
@@ -182,7 +182,6 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -194,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -240,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
} }
const getOldestAdmin = `-- name: GetOldestAdmin :one const getOldestAdmin = `-- name: GetOldestAdmin :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users
WHERE is_admin = true WHERE is_admin = true
ORDER BY created_at, id ORDER BY created_at, id
LIMIT 1 LIMIT 1
@@ -260,7 +260,6 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -272,22 +271,22 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const getUserByAPIToken = `-- name: GetUserByAPIToken :one const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1 SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE api_token_hash = $1
` `
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { func (q *Queries) GetUserByAPITokenHash(ctx context.Context, apiTokenHash string) (User, error) {
row := q.db.QueryRow(ctx, getUserByAPIToken, apiToken) row := q.db.QueryRow(ctx, getUserByAPITokenHash, apiTokenHash)
var i User var i User
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -299,12 +298,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const getUserByEmail = `-- name: GetUserByEmail :one const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1) SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE lower(email) = lower($1)
` `
// Used by forgot-password lookup. Lowercase comparison both sides // Used by forgot-password lookup. Lowercase comparison both sides
@@ -317,7 +317,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -329,12 +328,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1 SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE id = $1
` `
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
@@ -344,7 +344,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -356,12 +355,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const getUserByUsername = `-- name: GetUserByUsername :one const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1 SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE username = $1
` `
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -371,7 +371,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -383,6 +382,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -471,39 +471,21 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
return items, nil return items, nil
} }
const regenerateApiToken = `-- name: RegenerateApiToken :one const regenerateApiToken = `-- name: RegenerateApiToken :exec
UPDATE users SET api_token = $2 WHERE id = $1 UPDATE users SET api_token_hash = $2 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
` `
type RegenerateApiTokenParams struct { type RegenerateApiTokenParams struct {
ID pgtype.UUID ID pgtype.UUID
ApiToken string ApiTokenHash string
} }
// Self-service: caller wants a new API token. Used by the /settings // Self-service: caller wants a new API token. Used by the /settings
// API Token card's "Regenerate" button. // API Token card's "Regenerate" button. Only the hash is stored; the
func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) (User, error) { // handler returns the raw key once.
row := q.db.QueryRow(ctx, regenerateApiToken, arg.ID, arg.ApiToken) func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) error {
var i User _, err := q.db.Exec(ctx, regenerateApiToken, arg.ID, arg.ApiTokenHash)
err := row.Scan( return err
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
} }
const resetUserPassword = `-- name: ResetUserPassword :exec const resetUserPassword = `-- name: ResetUserPassword :exec
@@ -530,7 +512,7 @@ const setDebugMode = `-- name: SetDebugMode :one
UPDATE users UPDATE users
SET debug_mode_enabled = $2 SET debug_mode_enabled = $2
WHERE id = $1 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type SetDebugModeParams struct { type SetDebugModeParams struct {
@@ -548,7 +530,6 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -560,6 +541,7 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -617,7 +599,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
UPDATE users UPDATE users
SET is_admin = $2 SET is_admin = $2
WHERE id = $1 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type UpdateUserAdminParams struct { type UpdateUserAdminParams struct {
@@ -634,7 +616,6 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -646,6 +627,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -654,7 +636,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
UPDATE users UPDATE users
SET auto_approve_requests = $2 SET auto_approve_requests = $2
WHERE id = $1 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type UpdateUserAutoApproveParams struct { type UpdateUserAutoApproveParams struct {
@@ -670,7 +652,6 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -682,6 +663,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -691,7 +673,7 @@ UPDATE users
SET display_name = $2, SET display_name = $2,
email = $3 email = $3
WHERE id = $1 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type UpdateUserProfileParams struct { type UpdateUserProfileParams struct {
@@ -709,7 +691,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -721,6 +702,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -0,0 +1,7 @@
-- The keys cannot be recovered from their hashes. Rolling back gives every
-- user a new random key; Subsonic clients using apiKey need the new one.
ALTER TABLE users ADD COLUMN api_token text;
UPDATE users SET api_token = md5(random()::text || id::text || clock_timestamp()::text);
ALTER TABLE users ALTER COLUMN api_token SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_api_token_key UNIQUE (api_token);
ALTER TABLE users DROP COLUMN api_token_hash;
@@ -0,0 +1,10 @@
-- API keys (the OpenSubsonic apiKey) are stored as their sha256, hex, the
-- same way session tokens are. A leaked database row or backup no longer
-- hands out working keys. Existing keys are hashed in place, so every
-- Subsonic client keeps working; the key itself can no longer be shown
-- again, only replaced (M462 #4983).
ALTER TABLE users ADD COLUMN api_token_hash text;
UPDATE users SET api_token_hash = encode(sha256(convert_to(api_token, 'UTF8')), 'hex');
ALTER TABLE users ALTER COLUMN api_token_hash SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_api_token_hash_key UNIQUE (api_token_hash);
ALTER TABLE users DROP COLUMN api_token;
+9 -9
View File
@@ -1,5 +1,5 @@
-- name: CreateUser :one -- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING *; RETURNING *;
@@ -17,7 +17,7 @@ RETURNING *;
-- and the second caller's INSERT fails with a unique violation. The -- and the second caller's INSERT fails with a unique violation. The
-- caller (registration handler) can retry as a regular non-admin in -- caller (registration handler) can retry as a regular non-admin in
-- that case (or surface a "username taken" error to the user). -- that case (or surface a "username taken" error to the user).
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ( VALUES (
$1, $2, $3, $1, $2, $3,
(SELECT NOT EXISTS (SELECT 1 FROM users)), (SELECT NOT EXISTS (SELECT 1 FROM users)),
@@ -28,8 +28,8 @@ RETURNING *;
-- name: GetUserByUsername :one -- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1; SELECT * FROM users WHERE username = $1;
-- name: GetUserByAPIToken :one -- name: GetUserByAPITokenHash :one
SELECT * FROM users WHERE api_token = $1; SELECT * FROM users WHERE api_token_hash = $1;
-- name: CountUsers :one -- name: CountUsers :one
SELECT count(*) FROM users; SELECT count(*) FROM users;
@@ -87,7 +87,7 @@ WHERE u.id = $1;
-- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace: -- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace:
-- the caller (an admin) supplies all five fields explicitly including is_admin, -- the caller (an admin) supplies all five fields explicitly including is_admin,
-- so an admin can promote on creation. Used by POST /api/admin/users. -- so an admin can promote on creation. Used by POST /api/admin/users.
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING *; RETURNING *;
@@ -141,11 +141,11 @@ UPDATE users
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- name: RegenerateApiToken :one -- name: RegenerateApiToken :exec
-- Self-service: caller wants a new API token. Used by the /settings -- Self-service: caller wants a new API token. Used by the /settings
-- API Token card's "Regenerate" button. -- API Token card's "Regenerate" button. Only the hash is stored; the
UPDATE users SET api_token = $2 WHERE id = $1 -- handler returns the raw key once.
RETURNING *; UPDATE users SET api_token_hash = $2 WHERE id = $1;
-- name: GetUserByEmail :one -- name: GetUserByEmail :one
-- Used by forgot-password lookup. Lowercase comparison both sides -- Used by forgot-password lookup. Lowercase comparison both sides
+1 -1
View File
@@ -50,7 +50,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, Username: dbtest.TestUserPrefix + name,
PasswordHash: "test-hash", PasswordHash: "test-hash",
ApiToken: "test-token-" + name, ApiTokenHash: "test-token-" + name,
IsAdmin: false, IsAdmin: false,
}) })
if err != nil { if err != nil {
+1 -1
View File
@@ -74,7 +74,7 @@ func newMergeFixture(t *testing.T) mergeFixture {
user := func(name string) dbq.User { user := func(name string) dbq.User {
t.Helper() t.Helper()
u, err := q.CreateUser(ctx, dbq.CreateUserParams{ u, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-merge-token", Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-merge-token",
}) })
if err != nil { if err != nil {
t.Fatalf("user %s: %v", name, err) t.Fatalf("user %s: %v", name, err)
+1 -1
View File
@@ -46,7 +46,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper() t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: false, ApiTokenHash: name + "-token", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed user %s: %v", name, err) t.Fatalf("seed user %s: %v", name, err)
+2 -2
View File
@@ -50,7 +50,7 @@ func newPool(t *testing.T) *pgxpool.Pool {
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper() t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed user: %v", err) t.Fatalf("seed user: %v", err)
@@ -206,7 +206,7 @@ func TestListForUser_OnlyOwnRows(t *testing.T) {
pool := newPool(t) pool := newPool(t)
alice := seedUser(t, pool) alice := seedUser(t, pool)
bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "x2", IsAdmin: false, Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "x2", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed bob: %v", err) t.Fatalf("seed bob: %v", err)
+1 -1
View File
@@ -51,7 +51,7 @@ func newFixture(t *testing.T, durationMs int32) fixture {
pool := testPool(t) pool := testPool(t)
q := dbq.New(pool) q := dbq.New(pool)
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("user: %v", err) t.Fatalf("user: %v", err)
@@ -58,7 +58,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, Username: dbtest.TestUserPrefix + name,
PasswordHash: "x", PasswordHash: "x",
ApiToken: name + "-token", ApiTokenHash: name + "-token",
IsAdmin: false, IsAdmin: false,
}) })
if err != nil { if err != nil {
+1 -1
View File
@@ -42,7 +42,7 @@ func seedTestUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", Username: dbtest.TestUserPrefix + "tester",
PasswordHash: "x", PasswordHash: "x",
ApiToken: "x", ApiTokenHash: "x",
IsAdmin: false, IsAdmin: false,
}) })
if err != nil { if err != nil {
+2 -2
View File
@@ -50,7 +50,7 @@ func newFixture(t *testing.T, n int) fixture {
pool := testPool(t) pool := testPool(t)
q := dbq.New(pool) q := dbq.New(pool)
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("user: %v", err) t.Fatalf("user: %v", err)
@@ -197,7 +197,7 @@ func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
func TestLoadCandidates_CrossUserIsolation(t *testing.T) { func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
f := newFixture(t, 2) f := newFixture(t, 2)
bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{ bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "y", IsAdmin: false,
}) })
// Alice likes tracks[1]; Bob shouldn't see it. // Alice likes tracks[1]; Bob shouldn't see it.
_, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) _, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})
@@ -42,7 +42,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper() t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: false, ApiTokenHash: name + "-token", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed user: %v", err) t.Fatalf("seed user: %v", err)
+1 -1
View File
@@ -57,7 +57,7 @@ func seed(t *testing.T, opts seedOpts) setup {
pool, q := testPool(t) pool, q := testPool(t)
ctx := context.Background() ctx := context.Background()
u, err := q.CreateUser(ctx, dbq.CreateUserParams{ u, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("user: %v", err) t.Fatalf("user: %v", err)
+2 -2
View File
@@ -190,7 +190,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
user, err := q.CreateUser(ctx, dbq.CreateUserParams{ user, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: "test-shadowing-admin", Username: "test-shadowing-admin",
PasswordHash: "x", PasswordHash: "x",
ApiToken: "test-shadowing-token", ApiTokenHash: "test-shadowing-token",
IsAdmin: true, IsAdmin: true,
}) })
if err != nil { if err != nil {
@@ -276,7 +276,7 @@ func TestRouter_ReacquisitionSettingsSavedThroughTheAPIReachTheSweeper(t *testin
user, err := q.CreateUser(ctx, dbq.CreateUserParams{ user, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: "test-reacq-settings-admin", Username: "test-reacq-settings-admin",
PasswordHash: "x", PasswordHash: "x",
ApiToken: "test-reacq-settings-token", ApiTokenHash: "test-reacq-settings-token",
IsAdmin: true, IsAdmin: true,
}) })
if err != nil { if err != nil {
@@ -57,7 +57,7 @@ func newFixture(t *testing.T) fixture {
pool, q := testPool(t) pool, q := testPool(t)
ctx := context.Background() ctx := context.Background()
u, err := q.CreateUser(ctx, dbq.CreateUserParams{ u, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("user: %v", err) t.Fatalf("user: %v", err)
+2 -2
View File
@@ -36,7 +36,7 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) {
return u, ok return u, ok
} }
// Middleware authenticates the request against users.api_token (apiKey) or // Middleware authenticates the request against users.api_token_hash (apiKey) or
// users.subsonic_password (t/s or p). On failure it writes a Subsonic failed // users.subsonic_password (t/s or p). On failure it writes a Subsonic failed
// envelope using the request's f= format; downstream handlers never see an // envelope using the request's f= format; downstream handlers never see an
// unauthenticated request. // unauthenticated request.
@@ -79,7 +79,7 @@ func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, in
params := r.URL.Query() params := r.URL.Query()
if apiKey := params.Get("apiKey"); apiKey != "" { if apiKey := params.Get("apiKey"); apiKey != "" {
user, err := q.GetUserByAPIToken(r.Context(), apiKey) user, err := q.GetUserByAPITokenHash(r.Context(), auth.HashAPIToken(apiKey))
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return dbq.User{}, ErrWrongCredentials, "Invalid apiKey" return dbq.User{}, ErrWrongCredentials, "Invalid apiKey"
+1 -1
View File
@@ -39,7 +39,7 @@ func testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) {
dbtest.ResetDB(t, pool) dbtest.ResetDB(t, pool)
q := dbq.New(pool) q := dbq.New(pool)
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("user: %v", err) t.Fatalf("user: %v", err)
+2 -2
View File
@@ -40,7 +40,7 @@ func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album,
dbtest.ResetDB(t, pool) dbtest.ResetDB(t, pool)
q := dbq.New(pool) q := dbq.New(pool)
u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
}) })
a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"}) a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID}) al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
@@ -228,7 +228,7 @@ func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) {
pool, alice, track, _, _ := testStarPool(t) pool, alice, track, _, _ := testStarPool(t)
q := dbq.New(pool) q := dbq.New(pool)
bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "y", IsAdmin: false,
}) })
_, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID}) _, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID})
+1 -1
View File
@@ -50,7 +50,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper() t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: false, ApiTokenHash: name + "-token", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed user: %v", err) t.Fatalf("seed user: %v", err)
+1 -1
View File
@@ -44,7 +44,7 @@ func seedAdmin(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper() t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: true, ApiTokenHash: name + "-token", IsAdmin: true,
}) })
if err != nil { if err != nil {
t.Fatalf("seed admin %s: %v", name, err) t.Fatalf("seed admin %s: %v", name, err)
+5 -4
View File
@@ -46,10 +46,11 @@ export async function updateProfile(input: { display_name?: string; email?: stri
return api.put<MyProfile>('/api/me/profile', input); return api.put<MyProfile>('/api/me/profile', input);
} }
export async function getAPIToken(): Promise<APITokenResponse> { /**
return api.get<APITokenResponse>('/api/me/api-token'); * Mints a new API key and returns it. The server keeps only its hash, so
} * this is the one time the key can be read; there is no way to fetch it
* again later.
*/
export async function regenerateAPIToken(): Promise<APITokenResponse> { export async function regenerateAPIToken(): Promise<APITokenResponse> {
return api.post<APITokenResponse>('/api/me/api-token', {}); return api.post<APITokenResponse>('/api/me/api-token', {});
} }
+9 -8
View File
@@ -19,7 +19,6 @@
import { import {
updateProfile, updateProfile,
changePassword, changePassword,
getAPIToken,
regenerateAPIToken regenerateAPIToken
} from '$lib/api/me'; } from '$lib/api/me';
import { errCode } from '$lib/api/errors'; import { errCode } from '$lib/api/errors';
@@ -167,15 +166,13 @@
// API Token card ---------------------------------------------------------- // API Token card ----------------------------------------------------------
// Only set right after Regenerate: the server stores the key's hash, so
// this is the one moment the key exists outside the client it goes into.
let apiToken = $state<string | null>(null); let apiToken = $state<string | null>(null);
let tokenSaving = $state(false); let tokenSaving = $state(false);
let confirmRegen = $state(false); let confirmRegen = $state(false);
let regenTimer: ReturnType<typeof setTimeout> | undefined; let regenTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
getAPIToken().then(r => { apiToken = r.api_token; }).catch(() => {});
});
async function copyToken() { async function copyToken() {
if (!apiToken) return; if (!apiToken) return;
try { try {
@@ -199,7 +196,7 @@
try { try {
const r = await regenerateAPIToken(); const r = await regenerateAPIToken();
apiToken = r.api_token; apiToken = r.api_token;
pushToast('API token regenerated.'); pushToast('New API token created. Copy it now; it will not be shown again.');
} catch (e: unknown) { } catch (e: unknown) {
pushToast(`Regenerate failed: ${errCode(e)}`, 'error'); pushToast(`Regenerate failed: ${errCode(e)}`, 'error');
} finally { } finally {
@@ -531,19 +528,23 @@
<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">
<h2 class="text-lg font-semibold">API Token</h2> <h2 class="text-lg font-semibold">API Token</h2>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
Used by Subsonic clients (DSub, Symfonium, etc.) to authenticate to your library. Used by Subsonic clients that sign in with an API key (OpenSubsonic apiKey).
Regenerating invalidates clients that have the old token cached. Minstrel keeps only a fingerprint of the token, so it can't show you the current one.
Regenerate to get a new token; clients using the old one will need the new one.
</p> </p>
{#if apiToken} {#if apiToken}
<code class="block break-all rounded bg-background p-2 text-xs"> <code class="block break-all rounded bg-background p-2 text-xs">
{apiToken} {apiToken}
</code> </code>
<p class="text-xs text-text-secondary">Copy this now. It won't be shown again.</p>
{/if} {/if}
<div class="flex gap-2"> <div class="flex gap-2">
{#if apiToken}
<button type="button" onclick={copyToken} <button type="button" onclick={copyToken}
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"> 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 Copy
</button> </button>
{/if}
<button type="button" disabled={tokenSaving} <button type="button" disabled={tokenSaving}
onclick={onRegenerateToken} onclick={onRegenerateToken}
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"> 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">
+12 -3
View File
@@ -16,7 +16,6 @@ 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.
getAPIToken: vi.fn().mockResolvedValue({ api_token: '' }),
regenerateAPIToken: vi.fn() regenerateAPIToken: vi.fn()
})); }));
@@ -46,7 +45,6 @@ import {
import { import {
updateProfile, updateProfile,
changePassword, changePassword,
getAPIToken,
regenerateAPIToken regenerateAPIToken
} from '$lib/api/me'; } from '$lib/api/me';
@@ -133,7 +131,6 @@ function setupPage() {
); );
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore()); (createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore()); (createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
(getAPIToken as ReturnType<typeof vi.fn>).mockResolvedValue({ api_token: 'tok_abc123' });
} }
describe('Settings page — Profile card', () => { describe('Settings page — Profile card', () => {
@@ -350,5 +347,17 @@ describe('Settings page — API Token card', () => {
); );
await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i })); await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i }));
await waitFor(() => expect(regenerateAPIToken).toHaveBeenCalled()); await waitFor(() => expect(regenerateAPIToken).toHaveBeenCalled());
// The new key is shown once, with a way to copy it.
expect(await screen.findByText('new_tok_xyz')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^copy$/i })).toBeInTheDocument();
});
test('no token is shown or fetched before Regenerate', async () => {
setupPage();
render(SettingsPage);
await waitFor(() =>
expect(screen.getByRole('button', { name: /regenerate/i })).toBeInTheDocument()
);
expect(screen.queryByRole('button', { name: /^copy$/i })).toBeNull();
}); });
}); });