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{
|
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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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', {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user