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
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:
@@ -169,7 +169,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
user, err := q.CreateUserAdmin(r.Context(), dbq.CreateUserAdminParams{
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hash),
|
||||
ApiToken: apiToken,
|
||||
ApiTokenHash: auth.HashAPIToken(apiToken),
|
||||
IsAdmin: req.IsAdmin,
|
||||
DisplayName: req.DisplayName,
|
||||
})
|
||||
|
||||
@@ -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/profile", h.handleUpdateMyProfile)
|
||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||
authed.Get("/me/sessions", h.handleListMySessions)
|
||||
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||
|
||||
@@ -161,7 +161,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hash),
|
||||
ApiToken: apiToken,
|
||||
ApiTokenHash: auth.HashAPIToken(apiToken),
|
||||
DisplayName: req.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -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{
|
||||
Username: prefixed,
|
||||
PasswordHash: string(hash),
|
||||
ApiToken: "test-api-token-" + prefixed,
|
||||
ApiTokenHash: "test-api-token-" + prefixed,
|
||||
IsAdmin: isAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -13,23 +13,11 @@ type apiTokenResp struct {
|
||||
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.
|
||||
//
|
||||
// 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) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
@@ -42,11 +30,10 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
updated, err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{
|
||||
ID: user.ID,
|
||||
ApiToken: newToken,
|
||||
})
|
||||
if err != nil {
|
||||
if err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{
|
||||
ID: user.ID,
|
||||
ApiTokenHash: auth.HashAPIToken(newToken),
|
||||
}); err != nil {
|
||||
h.logger.Error("regenerate api token: update failed", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
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)
|
||||
|
||||
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: updated.ApiToken})
|
||||
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: newToken})
|
||||
}
|
||||
|
||||
@@ -8,47 +8,23 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newMeTokenRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/me/api-token", h.handleGetMyAPIToken)
|
||||
r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken)
|
||||
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) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "tok2", "pw", false)
|
||||
oldToken := user.ApiToken
|
||||
oldHash := user.ApiTokenHash
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil)
|
||||
req = withUser(req, user)
|
||||
@@ -65,20 +41,20 @@ func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) {
|
||||
if resp.APIToken == "" {
|
||||
t.Fatalf("api_token is empty")
|
||||
}
|
||||
if resp.APIToken == oldToken {
|
||||
t.Errorf("api_token unchanged after regenerate")
|
||||
}
|
||||
|
||||
// Verify DB row has the new token and old token no longer matches.
|
||||
var dbToken string
|
||||
// The DB holds the new key's hash, never the key, and the old key no
|
||||
// longer matches.
|
||||
var dbHash string
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
"SELECT api_token FROM users WHERE id = $1", user.ID).Scan(&dbToken); err != nil {
|
||||
t.Fatalf("read token: %v", err)
|
||||
"SELECT api_token_hash FROM users WHERE id = $1", user.ID).Scan(&dbHash); err != nil {
|
||||
t.Fatalf("read token hash: %v", err)
|
||||
}
|
||||
if dbToken != resp.APIToken {
|
||||
t.Errorf("DB api_token = %q, want %q", dbToken, resp.APIToken)
|
||||
if dbHash != auth.HashAPIToken(resp.APIToken) {
|
||||
t.Errorf("DB api_token_hash = %q, want hash of the returned key", dbHash)
|
||||
}
|
||||
if dbToken == oldToken {
|
||||
t.Errorf("old token still in DB after regenerate")
|
||||
if dbHash == oldHash {
|
||||
t.Errorf("old key hash still in DB after regenerate")
|
||||
}
|
||||
if dbHash == resp.APIToken {
|
||||
t.Errorf("DB holds the raw key")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -41,6 +42,15 @@ func HashSessionToken(token string) []byte {
|
||||
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
|
||||
// malformed hash so callers don't need to distinguish "hash invalid" from
|
||||
// "password wrong" — both are auth failures from the client's perspective.
|
||||
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
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 {
|
||||
t.Fatalf("seed user %s: %v", name, err)
|
||||
|
||||
@@ -731,7 +731,6 @@ type User struct {
|
||||
ID pgtype.UUID
|
||||
Username string
|
||||
PasswordHash string
|
||||
ApiToken string
|
||||
IsAdmin bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
SubsonicPassword *string
|
||||
@@ -743,6 +742,7 @@ type User struct {
|
||||
Timezone string
|
||||
TimezoneUpdatedAt pgtype.Timestamptz
|
||||
DebugModeEnabled bool
|
||||
ApiTokenHash string
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
|
||||
@@ -52,15 +52,15 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
Username string
|
||||
PasswordHash string
|
||||
ApiToken string
|
||||
ApiTokenHash string
|
||||
IsAdmin bool
|
||||
DisplayName *string
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
row := q.db.QueryRow(ctx, createUser,
|
||||
arg.Username,
|
||||
arg.PasswordHash,
|
||||
arg.ApiToken,
|
||||
arg.ApiTokenHash,
|
||||
arg.IsAdmin,
|
||||
arg.DisplayName,
|
||||
)
|
||||
@@ -78,7 +78,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -90,20 +89,21 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
Username string
|
||||
PasswordHash string
|
||||
ApiToken string
|
||||
ApiTokenHash string
|
||||
IsAdmin bool
|
||||
DisplayName *string
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
||||
row := q.db.QueryRow(ctx, createUserAdmin,
|
||||
arg.Username,
|
||||
arg.PasswordHash,
|
||||
arg.ApiToken,
|
||||
arg.ApiTokenHash,
|
||||
arg.IsAdmin,
|
||||
arg.DisplayName,
|
||||
)
|
||||
@@ -124,7 +124,6 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -136,24 +135,25 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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 (
|
||||
$1, $2, $3,
|
||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||
$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 {
|
||||
Username string
|
||||
PasswordHash string
|
||||
ApiToken string
|
||||
ApiTokenHash string
|
||||
DisplayName *string
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
||||
row := q.db.QueryRow(ctx, createUserFirstAdminRace,
|
||||
arg.Username,
|
||||
arg.PasswordHash,
|
||||
arg.ApiToken,
|
||||
arg.ApiTokenHash,
|
||||
arg.DisplayName,
|
||||
)
|
||||
var i User
|
||||
@@ -182,7 +182,6 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -194,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
||||
}
|
||||
|
||||
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
|
||||
ORDER BY created_at, id
|
||||
LIMIT 1
|
||||
@@ -260,7 +260,6 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -272,22 +271,22 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :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
|
||||
const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, getUserByAPIToken, apiToken)
|
||||
func (q *Queries) GetUserByAPITokenHash(ctx context.Context, apiTokenHash string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByAPITokenHash, apiTokenHash)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -299,12 +298,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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
|
||||
@@ -317,7 +317,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -329,12 +328,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -344,7 +344,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -356,12 +355,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -371,7 +371,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -383,6 +382,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -471,39 +471,21 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
||||
UPDATE users SET api_token = $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
|
||||
const regenerateApiToken = `-- name: RegenerateApiToken :exec
|
||||
UPDATE users SET api_token_hash = $2 WHERE id = $1
|
||||
`
|
||||
|
||||
type RegenerateApiTokenParams struct {
|
||||
ID pgtype.UUID
|
||||
ApiToken string
|
||||
ID pgtype.UUID
|
||||
ApiTokenHash string
|
||||
}
|
||||
|
||||
// Self-service: caller wants a new API token. Used by the /settings
|
||||
// API Token card's "Regenerate" button.
|
||||
func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, regenerateApiToken, arg.ID, arg.ApiToken)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&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
|
||||
// API Token card's "Regenerate" button. Only the hash is stored; the
|
||||
// handler returns the raw key once.
|
||||
func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) error {
|
||||
_, err := q.db.Exec(ctx, regenerateApiToken, arg.ID, arg.ApiTokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const resetUserPassword = `-- name: ResetUserPassword :exec
|
||||
@@ -530,7 +512,7 @@ const setDebugMode = `-- name: SetDebugMode :one
|
||||
UPDATE users
|
||||
SET debug_mode_enabled = $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
|
||||
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 {
|
||||
@@ -548,7 +530,6 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -560,6 +541,7 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -617,7 +599,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
||||
UPDATE users
|
||||
SET is_admin = $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
|
||||
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 {
|
||||
@@ -634,7 +616,6 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -646,6 +627,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -654,7 +636,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
||||
UPDATE users
|
||||
SET auto_approve_requests = $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
|
||||
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 {
|
||||
@@ -670,7 +652,6 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -682,6 +663,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -691,7 +673,7 @@ UPDATE users
|
||||
SET display_name = $2,
|
||||
email = $3
|
||||
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 {
|
||||
@@ -709,7 +691,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -721,6 +702,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
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;
|
||||
@@ -1,5 +1,5 @@
|
||||
-- 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)
|
||||
RETURNING *;
|
||||
|
||||
@@ -17,7 +17,7 @@ RETURNING *;
|
||||
-- and the second caller's INSERT fails with a unique violation. The
|
||||
-- caller (registration handler) can retry as a regular non-admin in
|
||||
-- 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 (
|
||||
$1, $2, $3,
|
||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||
@@ -28,8 +28,8 @@ RETURNING *;
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM users WHERE username = $1;
|
||||
|
||||
-- name: GetUserByAPIToken :one
|
||||
SELECT * FROM users WHERE api_token = $1;
|
||||
-- name: GetUserByAPITokenHash :one
|
||||
SELECT * FROM users WHERE api_token_hash = $1;
|
||||
|
||||
-- name: CountUsers :one
|
||||
SELECT count(*) FROM users;
|
||||
@@ -87,7 +87,7 @@ WHERE u.id = $1;
|
||||
-- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace:
|
||||
-- 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.
|
||||
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)
|
||||
RETURNING *;
|
||||
|
||||
@@ -141,11 +141,11 @@ UPDATE users
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: RegenerateApiToken :one
|
||||
-- name: RegenerateApiToken :exec
|
||||
-- Self-service: caller wants a new API token. Used by the /settings
|
||||
-- API Token card's "Regenerate" button.
|
||||
UPDATE users SET api_token = $2 WHERE id = $1
|
||||
RETURNING *;
|
||||
-- API Token card's "Regenerate" button. Only the hash is stored; the
|
||||
-- handler returns the raw key once.
|
||||
UPDATE users SET api_token_hash = $2 WHERE id = $1;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
-- Used by forgot-password lookup. Lowercase comparison both sides
|
||||
|
||||
@@ -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{
|
||||
Username: dbtest.TestUserPrefix + name,
|
||||
PasswordHash: "test-hash",
|
||||
ApiToken: "test-token-" + name,
|
||||
ApiTokenHash: "test-token-" + name,
|
||||
IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -74,7 +74,7 @@ func newMergeFixture(t *testing.T) mergeFixture {
|
||||
user := func(name string) dbq.User {
|
||||
t.Helper()
|
||||
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 {
|
||||
t.Fatalf("user %s: %v", name, err)
|
||||
|
||||
@@ -46,7 +46,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||||
t.Helper()
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||||
ApiToken: name + "-token", IsAdmin: false,
|
||||
ApiTokenHash: name + "-token", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed user %s: %v", name, err)
|
||||
|
||||
@@ -50,7 +50,7 @@ func newPool(t *testing.T) *pgxpool.Pool {
|
||||
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
|
||||
t.Helper()
|
||||
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 {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
@@ -206,7 +206,7 @@ func TestListForUser_OnlyOwnRows(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
alice := seedUser(t, pool)
|
||||
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 {
|
||||
t.Fatalf("seed bob: %v", err)
|
||||
|
||||
@@ -51,7 +51,7 @@ func newFixture(t *testing.T, durationMs int32) fixture {
|
||||
pool := testPool(t)
|
||||
q := dbq.New(pool)
|
||||
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 {
|
||||
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{
|
||||
Username: dbtest.TestUserPrefix + name,
|
||||
PasswordHash: "x",
|
||||
ApiToken: name + "-token",
|
||||
ApiTokenHash: name + "-token",
|
||||
IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -42,7 +42,7 @@ func seedTestUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + "tester",
|
||||
PasswordHash: "x",
|
||||
ApiToken: "x",
|
||||
ApiTokenHash: "x",
|
||||
IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -50,7 +50,7 @@ func newFixture(t *testing.T, n int) fixture {
|
||||
pool := testPool(t)
|
||||
q := dbq.New(pool)
|
||||
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 {
|
||||
t.Fatalf("user: %v", err)
|
||||
@@ -197,7 +197,7 @@ func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
|
||||
func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
|
||||
f := newFixture(t, 2)
|
||||
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.
|
||||
_, _ = 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()
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||||
ApiToken: name + "-token", IsAdmin: false,
|
||||
ApiTokenHash: name + "-token", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
|
||||
@@ -57,7 +57,7 @@ func seed(t *testing.T, opts seedOpts) setup {
|
||||
pool, q := testPool(t)
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
t.Fatalf("user: %v", err)
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
|
||||
user, err := q.CreateUser(ctx, dbq.CreateUserParams{
|
||||
Username: "test-shadowing-admin",
|
||||
PasswordHash: "x",
|
||||
ApiToken: "test-shadowing-token",
|
||||
ApiTokenHash: "test-shadowing-token",
|
||||
IsAdmin: true,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -276,7 +276,7 @@ func TestRouter_ReacquisitionSettingsSavedThroughTheAPIReachTheSweeper(t *testin
|
||||
user, err := q.CreateUser(ctx, dbq.CreateUserParams{
|
||||
Username: "test-reacq-settings-admin",
|
||||
PasswordHash: "x",
|
||||
ApiToken: "test-reacq-settings-token",
|
||||
ApiTokenHash: "test-reacq-settings-token",
|
||||
IsAdmin: true,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -57,7 +57,7 @@ func newFixture(t *testing.T) fixture {
|
||||
pool, q := testPool(t)
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
t.Fatalf("user: %v", err)
|
||||
|
||||
@@ -36,7 +36,7 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) {
|
||||
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
|
||||
// envelope using the request's f= format; downstream handlers never see an
|
||||
// unauthenticated request.
|
||||
@@ -79,7 +79,7 @@ func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, in
|
||||
params := r.URL.Query()
|
||||
|
||||
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 errors.Is(err, pgx.ErrNoRows) {
|
||||
return dbq.User{}, ErrWrongCredentials, "Invalid apiKey"
|
||||
|
||||
@@ -39,7 +39,7 @@ func testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) {
|
||||
dbtest.ResetDB(t, pool)
|
||||
q := dbq.New(pool)
|
||||
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 {
|
||||
t.Fatalf("user: %v", err)
|
||||
|
||||
@@ -40,7 +40,7 @@ func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album,
|
||||
dbtest.ResetDB(t, pool)
|
||||
q := dbq.New(pool)
|
||||
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"})
|
||||
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)
|
||||
q := dbq.New(pool)
|
||||
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})
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||||
t.Helper()
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||||
ApiToken: name + "-token", IsAdmin: false,
|
||||
ApiTokenHash: name + "-token", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
|
||||
@@ -44,7 +44,7 @@ func seedAdmin(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||||
t.Helper()
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||||
ApiToken: name + "-token", IsAdmin: true,
|
||||
ApiTokenHash: name + "-token", IsAdmin: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed admin %s: %v", name, err)
|
||||
|
||||
@@ -46,10 +46,11 @@ export async function updateProfile(input: { display_name?: string; email?: stri
|
||||
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> {
|
||||
return api.post<APITokenResponse>('/api/me/api-token', {});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
import {
|
||||
updateProfile,
|
||||
changePassword,
|
||||
getAPIToken,
|
||||
regenerateAPIToken
|
||||
} from '$lib/api/me';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
@@ -167,15 +166,13 @@
|
||||
|
||||
// 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 tokenSaving = $state(false);
|
||||
let confirmRegen = $state(false);
|
||||
let regenTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
$effect(() => {
|
||||
getAPIToken().then(r => { apiToken = r.api_token; }).catch(() => {});
|
||||
});
|
||||
|
||||
async function copyToken() {
|
||||
if (!apiToken) return;
|
||||
try {
|
||||
@@ -199,7 +196,7 @@
|
||||
try {
|
||||
const r = await regenerateAPIToken();
|
||||
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) {
|
||||
pushToast(`Regenerate failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
@@ -531,19 +528,23 @@
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">API Token</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Used by Subsonic clients (DSub, Symfonium, etc.) to authenticate to your library.
|
||||
Regenerating invalidates clients that have the old token cached.
|
||||
Used by Subsonic clients that sign in with an API key (OpenSubsonic apiKey).
|
||||
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>
|
||||
{#if apiToken}
|
||||
<code class="block break-all rounded bg-background p-2 text-xs">
|
||||
{apiToken}
|
||||
</code>
|
||||
<p class="text-xs text-text-secondary">Copy this now. It won't be shown again.</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<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">
|
||||
Copy
|
||||
</button>
|
||||
{#if apiToken}
|
||||
<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">
|
||||
Copy
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" disabled={tokenSaving}
|
||||
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">
|
||||
|
||||
@@ -16,7 +16,6 @@ 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.
|
||||
getAPIToken: vi.fn().mockResolvedValue({ api_token: '' }),
|
||||
regenerateAPIToken: vi.fn()
|
||||
}));
|
||||
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
import {
|
||||
updateProfile,
|
||||
changePassword,
|
||||
getAPIToken,
|
||||
regenerateAPIToken
|
||||
} from '$lib/api/me';
|
||||
|
||||
@@ -133,7 +131,6 @@ function setupPage() {
|
||||
);
|
||||
(createTokenMutation 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', () => {
|
||||
@@ -350,5 +347,17 @@ describe('Settings page — API Token card', () => {
|
||||
);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /click again to confirm/i }));
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user