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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:42:35 -04:00
co-authored by Claude Opus 5.5
parent 2f3fbccab6
commit 327d49428f
33 changed files with 161 additions and 179 deletions
+9 -22
View File
@@ -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})
}