diff --git a/internal/api/admin_users.go b/internal/api/admin_users.go index c8fbc95a..9c729e79 100644 --- a/internal/api/admin_users.go +++ b/internal/api/admin_users.go @@ -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, }) diff --git a/internal/api/api.go b/internal/api/api.go index 66d8ab87..808e50ea 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go index 1c14ad64..eec2fd72 100644 --- a/internal/api/auth_register.go +++ b/internal/api/auth_register.go @@ -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 { diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 4e445e6f..4f04a6ef 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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 { diff --git a/internal/api/me_token.go b/internal/api/me_token.go index a9ddf1d4..37d5e9a6 100644 --- a/internal/api/me_token.go +++ b/internal/api/me_token.go @@ -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}) } diff --git a/internal/api/me_token_test.go b/internal/api/me_token_test.go index b3d26118..a7ba9e1d 100644 --- a/internal/api/me_token_test.go +++ b/internal/api/me_token_test.go @@ -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") } } diff --git a/internal/auth/session.go b/internal/auth/session.go index 49eda982..8f2ecdfa 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -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. diff --git a/internal/coplay/worker_integration_test.go b/internal/coplay/worker_integration_test.go index 72737eaf..e38475f0 100644 --- a/internal/coplay/worker_integration_test.go +++ b/internal/coplay/worker_integration_test.go @@ -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) diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 5c719e9c..88b1901d 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -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 { diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 3ffe3499..96000206 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -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 } diff --git a/internal/db/migrations/0063_api_token_hash.down.sql b/internal/db/migrations/0063_api_token_hash.down.sql new file mode 100644 index 00000000..8b80a9f4 --- /dev/null +++ b/internal/db/migrations/0063_api_token_hash.down.sql @@ -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; diff --git a/internal/db/migrations/0063_api_token_hash.up.sql b/internal/db/migrations/0063_api_token_hash.up.sql new file mode 100644 index 00000000..fd161a19 --- /dev/null +++ b/internal/db/migrations/0063_api_token_hash.up.sql @@ -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; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index bbdaac3e..9014e631 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -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 diff --git a/internal/gc/worker_test.go b/internal/gc/worker_test.go index 9cfd24c9..19868cb7 100644 --- a/internal/gc/worker_test.go +++ b/internal/gc/worker_test.go @@ -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 { diff --git a/internal/library/duplicate_merge_test.go b/internal/library/duplicate_merge_test.go index 729cc942..5651e7c8 100644 --- a/internal/library/duplicate_merge_test.go +++ b/internal/library/duplicate_merge_test.go @@ -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) diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go index 8129b01a..cfa21359 100644 --- a/internal/lidarrquarantine/service_test.go +++ b/internal/lidarrquarantine/service_test.go @@ -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) diff --git a/internal/lidarrrequests/service_test.go b/internal/lidarrrequests/service_test.go index 49891988..c1c90149 100644 --- a/internal/lidarrrequests/service_test.go +++ b/internal/lidarrrequests/service_test.go @@ -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) diff --git a/internal/playevents/writer_test.go b/internal/playevents/writer_test.go index bab96826..24893d50 100644 --- a/internal/playevents/writer_test.go +++ b/internal/playevents/writer_test.go @@ -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) diff --git a/internal/playlists/service_test_helpers_test.go b/internal/playlists/service_test_helpers_test.go index a50118d0..f206cfd3 100644 --- a/internal/playlists/service_test_helpers_test.go +++ b/internal/playlists/service_test_helpers_test.go @@ -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 { diff --git a/internal/playsessions/service_test.go b/internal/playsessions/service_test.go index c0a7da0a..613eb3d0 100644 --- a/internal/playsessions/service_test.go +++ b/internal/playsessions/service_test.go @@ -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 { diff --git a/internal/recommendation/candidates_test.go b/internal/recommendation/candidates_test.go index 2545ded2..4da79e8a 100644 --- a/internal/recommendation/candidates_test.go +++ b/internal/recommendation/candidates_test.go @@ -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}) diff --git a/internal/recommendation/suggestions_integration_test.go b/internal/recommendation/suggestions_integration_test.go index 7a8cd40c..9c86e76c 100644 --- a/internal/recommendation/suggestions_integration_test.go +++ b/internal/recommendation/suggestions_integration_test.go @@ -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) diff --git a/internal/scrobble/queue_test.go b/internal/scrobble/queue_test.go index 10b92c00..e3a22c94 100644 --- a/internal/scrobble/queue_test.go +++ b/internal/scrobble/queue_test.go @@ -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) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 29c0eaf1..bc037214 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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 { diff --git a/internal/similarity/worker_integration_test.go b/internal/similarity/worker_integration_test.go index c8fa0fde..d323d978 100644 --- a/internal/similarity/worker_integration_test.go +++ b/internal/similarity/worker_integration_test.go @@ -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) diff --git a/internal/subsonic/auth.go b/internal/subsonic/auth.go index 0973011f..68f33fb7 100644 --- a/internal/subsonic/auth.go +++ b/internal/subsonic/auth.go @@ -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" diff --git a/internal/subsonic/scrobble_test.go b/internal/subsonic/scrobble_test.go index 6567e5df..8cfcfcce 100644 --- a/internal/subsonic/scrobble_test.go +++ b/internal/subsonic/scrobble_test.go @@ -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) diff --git a/internal/subsonic/star_test.go b/internal/subsonic/star_test.go index c609e01b..40a0d323 100644 --- a/internal/subsonic/star_test.go +++ b/internal/subsonic/star_test.go @@ -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}) diff --git a/internal/taste/profile_db_test.go b/internal/taste/profile_db_test.go index 8f301154..fe9a27b3 100644 --- a/internal/taste/profile_db_test.go +++ b/internal/taste/profile_db_test.go @@ -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) diff --git a/internal/tracks/service_test.go b/internal/tracks/service_test.go index 02c9aa57..0456619a 100644 --- a/internal/tracks/service_test.go +++ b/internal/tracks/service_test.go @@ -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) diff --git a/web/src/lib/api/me.ts b/web/src/lib/api/me.ts index e9a9ab66..831d2dd5 100644 --- a/web/src/lib/api/me.ts +++ b/web/src/lib/api/me.ts @@ -46,10 +46,11 @@ export async function updateProfile(input: { display_name?: string; email?: stri return api.put('/api/me/profile', input); } -export async function getAPIToken(): Promise { - return api.get('/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 { return api.post('/api/me/api-token', {}); } diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 30fb0aef..4a804787 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -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(null); let tokenSaving = $state(false); let confirmRegen = $state(false); let regenTimer: ReturnType | 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 @@

API Token

- 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.

{#if apiToken} {apiToken} +

Copy this now. It won't be shown again.

{/if}
- + {#if apiToken} + + {/if}