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:
@@ -36,7 +36,7 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) {
|
||||
return u, ok
|
||||
}
|
||||
|
||||
// Middleware authenticates the request against users.api_token (apiKey) or
|
||||
// Middleware authenticates the request against users.api_token_hash (apiKey) or
|
||||
// users.subsonic_password (t/s or p). On failure it writes a Subsonic failed
|
||||
// envelope using the request's f= format; downstream handlers never see an
|
||||
// unauthenticated request.
|
||||
@@ -79,7 +79,7 @@ func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, in
|
||||
params := r.URL.Query()
|
||||
|
||||
if apiKey := params.Get("apiKey"); apiKey != "" {
|
||||
user, err := q.GetUserByAPIToken(r.Context(), apiKey)
|
||||
user, err := q.GetUserByAPITokenHash(r.Context(), auth.HashAPIToken(apiKey))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return dbq.User{}, ErrWrongCredentials, "Invalid apiKey"
|
||||
|
||||
@@ -39,7 +39,7 @@ func testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) {
|
||||
dbtest.ResetDB(t, pool)
|
||||
q := dbq.New(pool)
|
||||
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
||||
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user: %v", err)
|
||||
|
||||
@@ -40,7 +40,7 @@ func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album,
|
||||
dbtest.ResetDB(t, pool)
|
||||
q := dbq.New(pool)
|
||||
u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
||||
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
|
||||
})
|
||||
a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
|
||||
al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
|
||||
@@ -228,7 +228,7 @@ func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) {
|
||||
pool, alice, track, _, _ := testStarPool(t)
|
||||
q := dbq.New(pool)
|
||||
bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
|
||||
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "y", IsAdmin: false,
|
||||
})
|
||||
_, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user