navidrome/persistence/player_repository.go
Deluan Quintão 4cdffd5633
feat(subsonic): OpenSubsonic API key authentication (#6219)
* feat(persistence): store hashed API keys on players

* feat(core): refresh key-bound players without renaming them

Add Players.Touch, which records usage for a player already identified by
an API key without guessing its identity or overwriting its name. Register
also stops renaming players that have an API key.

Register no longer returns player save errors (or a stale FindMatch
ErrNotFound when the save is rate-limited); save failures are only logged,
and only the transcoding lookup error is returned, same as Touch.

* feat(subsonic): authenticate with OpenSubsonic API keys

Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>

* feat(subsonic): add tokenInfo and advertise apiKeyAuthentication

* feat(server): add endpoints to generate and revoke player API keys

* feat(ui): manage player API keys

Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>

* fix(subsonic): throttle API keys per key and IP

A stale key on one device exhausted the shared per-IP bucket and locked out
every valid key from the same IP. The limiter only stores a hash of the bucket
string, so the key is not retained. Also adds e2e coverage of API key auth
through the real repository, and clarifies the player resolution log message.

* fix(ui): keep the new API key dialog open until closed

The key is shown only once, so Escape and backdrop clicks no longer dismiss
it. Also clarifies when the key can be used as a password.

* refactor: simplify API key code paths

Share the player refresh tail between Register and Touch, fold the
ownership-filtered write tail into execOwned, parse the query once for
apiKey conflicts, derive HasAPIKey in the player mock, share the player
form inputs between create and edit, and pick the delete button by key
state instead of spreading conditional props.

* feat(players): set API keys through the player record

The key is a write-only apiKey field applied on save: required and owner-only on create, optional on edit, empty to revoke. Replaces the generate/revoke endpoints.

* fix(players): reject API keys already in use

Creating or editing a player with a key another player already has now returns a validation error instead of a 500, and a create that loses the race no longer leaves a keyless player behind. Ownership is checked before the key on create.

* feat(ui): edit player API keys as a form field

Replaces the show-once dialog, whose icon-less Close button was invisible on mobile. The key is generated in the browser, required and pre-filled on create.

* fix(ui): keep new player API keys out of the record cache

The json-server create response echoes the request body, and undoable edits merge the payload into the cache, so the key could reappear on the edit page. Strip it from the create result and save player edits pessimistically. Also fall back to a prompt when the clipboard write fails.

* fix(ui): polish player API key field

Set userId on the created player record so owner actions show immediately, and show a neutral no-key message to non-owners.

* refactor: simplify player API key create and field

Write the key hash in the create INSERT so the unique index settles
races, re-read the created player instead of hand-building the cached
record, reuse isWritable for the revoke check, and collapse the key
field's derived state and generate/regenerate buttons.

* fix(ui): let the API key field size like other inputs

fullWidth is now opt-in instead of forced.

* fix(ui): align the API key field with other player inputs

Apply react-admin's input className, move the actions (now including Copy) below the field, and use a monospace font so the whole key fits.

* fix(ui): redirect to the player list after create

Matches the other create pages.

* refactor(persistence): name the write-access rule for owned rows

Owned-row writes now say which row they target and who may write it: ownedRow(rowID, ownerOrAdmin|ownerOnly) builds the WHERE, updateOwnedRow applies it, and SetAPIKey uses ownerOnly instead of a hand-built user_id filter. updateOwned/deleteOwned keep their signatures.

* fix(players): apply an edit's key change and fields atomically

Update now runs SetAPIKey and the column update in one transaction. Also shares the key format check, drops FindByAPIKey's unneeded empty-key guard, and sets the context username only on the apiKey path.

* fix(subsonic): treat any credential param sent with apiKey as a conflict

The spec requires error 43 when u, p, t or s is present with apiKey, even with an empty value.

* refactor(subsonic): leave the player cookie code unchanged for key-bound requests

Return early instead of wrapping the cookie block, so the diff (and CodeQL's view of it) matches master.

* fix(subsonic): don't count key lookup errors as failed logins

A database error while checking a key sent as the password now surfaces as a server error instead of a bad password, so it no longer feeds the failed-login limiter.

* feat(players): use nds_ as the API key prefix

Part of a Navidrome secret prefix family (nd + a letter for the kind), alongside ndg_ for API v1 grants.

* feat(ui): make player API keys easier to find

Label the Settings menu entry "Players & API keys", add an API key
filter to the player list, show the key icon in the mobile list, and
add Brazilian Portuguese translations for the new player strings.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): always show the player API key filter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): hide the unset Last Seen date in the player list

Players created by hand have no last_seen yet, which showed as 12/31/1.

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: amCap1712 <amCap1712@users.noreply.github.com>
2026-09-27 21:56:58 -04:00

231 lines
6.9 KiB
Go

package persistence
import (
"context"
"crypto/sha256"
"encoding/hex"
"regexp"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
type playerRepository struct {
sqlRepository
}
func NewPlayerRepository(db dbx.Builder) model.PlayerRepository {
r := &playerRepository{}
r.db = db
r.registerModel(&model.Player{}, map[string]filterFunc{
"name": containsFilter("player.name"),
"hasapikey": hasAPIKeyFilter,
})
r.setSortMappings(map[string]string{
"user_name": "username", //TODO rename all user_name and userName to username
})
return r
}
func hasAPIKeyFilter(_ string, value any) Sqlizer {
if v, _ := value.(string); strings.EqualFold(v, "true") {
return NotEq{"player.api_key_hash": nil}
}
return Eq{"player.api_key_hash": nil}
}
func (r *playerRepository) Put(ctx context.Context, p *model.Player) error {
_, err := r.put(ctx, p.ID, p)
return err
}
func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
return r.newSelect(ctx, options...).
Columns("player.*", "player.api_key_hash is not null as has_api_key").
Join("user ON player.user_id = user.id").
Columns("user.user_name username")
}
func (r *playerRepository) Get(ctx context.Context, id string) (*model.Player, error) {
sel := r.selectPlayer(ctx).Where(Eq{"player.id": id})
var res model.Player
err := r.queryOne(ctx, sel, &res)
return &res, err
}
func (r *playerRepository) FindMatch(ctx context.Context, userId, client, userAgent string) (*model.Player, error) {
sel := r.selectPlayer(ctx).Where(And{
Eq{"client": client},
Eq{"user_agent": userAgent},
Eq{"user_id": userId},
})
var res model.Player
err := r.queryOne(ctx, sel, &res)
return &res, err
}
func (r *playerRepository) newRestSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
s := r.selectPlayer(ctx, options...)
return s.Where(r.addRestriction(ctx))
}
func (r *playerRepository) CountByClient(ctx context.Context, options ...model.QueryOptions) (map[string]int64, error) {
sel := r.newSelect(ctx, options...).
Columns(
"case when client = 'NavidromeUI' then name else client end as player",
"count(*) as count",
).GroupBy("client")
var res []struct {
Player string
Count int64
}
err := r.queryAll(ctx, sel, &res)
if err != nil {
return nil, err
}
counts := make(map[string]int64, len(res))
for _, c := range res {
counts[c.Player] = c.Count
}
return counts, nil
}
func (r *playerRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) {
return r.count(ctx, r.newRestSelect(ctx), options...)
}
func (r *playerRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) {
return r.CountAll(ctx, r.parseRestOptions(ctx, options...))
}
func (r *playerRepository) Read(ctx context.Context, id string) (*model.Player, error) {
sel := r.newRestSelect(ctx).Where(Eq{"player.id": id})
var res model.Player
err := r.queryOne(ctx, sel, &res)
return &res, err
}
func (r *playerRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Player, error) {
sel := r.newRestSelect(ctx, r.parseRestOptions(ctx, options...))
res := model.Players{}
err := r.queryAll(ctx, sel, &res)
return res, err
}
var apiKeyFormat = regexp.MustCompile(`^` + consts.APIKeyPrefix + `[0-9A-Za-z]{22}$`)
func apiKeyValidationError(msg string) error {
return &rest.ValidationError{Errors: map[string]string{"apiKey": msg}}
}
func validateAPIKey(key string) error {
if !apiKeyFormat.MatchString(key) {
return apiKeyValidationError("resources.player.validation.apiKeyFormat")
}
return nil
}
func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) {
u := loggedUser(ctx)
if t.UserId == "" && u.ID != invalidUserId {
t.UserId = u.ID
}
if t.UserId != u.ID {
return "", rest.ErrPermissionDenied
}
// Hand-made players are only reachable through a key, so one is required
if t.APIKey == nil || *t.APIKey == "" {
return "", apiKeyValidationError("ra.validation.required")
}
if err := validateAPIKey(*t.APIKey); err != nil {
return "", err
}
values, err := toSQLArgs(t)
if err != nil {
return "", err
}
// Save only creates, so the key hash goes in the same INSERT and the unique index settles races
values["id"] = id.NewRandom()
values["api_key_hash"] = hashAPIKey(*t.APIKey)
_, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values))
if isUniqueViolation(err) {
return "", apiKeyValidationError("ra.validation.unique")
}
if err != nil {
return "", err
}
return values["id"].(string), nil
}
func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error {
t := &entity
t.ID = id
if t.APIKey == nil {
return r.updateOwned(ctx, id, t, cols...)
}
// The key and the other columns are two writes; commit both or neither
return r.inTx(func(tx *playerRepository) error {
if err := tx.SetAPIKey(ctx, id, *t.APIKey); err != nil {
return err
}
return tx.updateOwned(ctx, id, t, cols...)
})
}
func (r *playerRepository) inTx(block func(tx *playerRepository) error) error {
conn, ok := r.db.(*dbx.DB)
if !ok {
return block(r) // already inside a transaction
}
return conn.Transactional(func(tx *dbx.Tx) error {
return block(NewPlayerRepository(tx).(*playerRepository))
})
}
func (r *playerRepository) Delete(ctx context.Context, ids ...string) error {
return r.deleteOwnedAll(ctx, ids...)
}
// Keys are long random strings, not user-chosen passwords, so a fast unsalted hash is enough and keeps lookups indexed.
func hashAPIKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func (r *playerRepository) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) {
sel := r.selectPlayer(ctx).Where(Eq{"player.api_key_hash": hashAPIKey(key)})
var res model.Player
if err := r.queryOne(ctx, sel, &res); err != nil {
return nil, err
}
return &res, nil
}
// SetAPIKey stores the key's hash, or revokes it when key is empty. Setting is owner-only, even for
// admins, so nobody can mint a login for someone else.
func (r *playerRepository) SetAPIKey(ctx context.Context, playerID, key string) error {
if key == "" {
return r.updateOwnedRow(ctx, playerID, ownerOrAdmin, map[string]any{"api_key_hash": nil})
}
if err := validateAPIKey(key); err != nil {
return err
}
err := r.updateOwnedRow(ctx, playerID, ownerOnly, map[string]any{"api_key_hash": hashAPIKey(key)})
if isUniqueViolation(err) {
return apiKeyValidationError("ra.validation.unique")
}
return err
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
var _ model.PlayerRepository = (*playerRepository)(nil)
var _ rest.Repository[model.Player] = (*playerRepository)(nil)
var _ rest.Persistable[model.Player] = (*playerRepository)(nil)