mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Mark secret values with log.WithSecrets on a separate line instead of nesting the call in argument lists. Also mark Last.fm/ListenBrainz session keys written through SessionKeys.Put and the PasswordEncryptionKey checksum, which still reached trace logs, and ignore values shorter than 8 characters so a short plaintext marked after a failed encryption cannot mangle SQL text or the [REDACTED] marker.
521 lines
15 KiB
Go
521 lines
15 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
"github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/utils"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
type userRepository struct {
|
|
sqlRepository
|
|
}
|
|
|
|
type dbUser struct {
|
|
*model.User `structs:",flatten"`
|
|
LibrariesJSON string `structs:"-" json:"-"`
|
|
}
|
|
|
|
func (u *dbUser) PostScan() error {
|
|
if u.LibrariesJSON != "" {
|
|
if err := json.Unmarshal([]byte(u.LibrariesJSON), &u.User.Libraries); err != nil {
|
|
return fmt.Errorf("parsing user libraries from db: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type dbUsers []dbUser
|
|
|
|
func (us dbUsers) toModels() model.Users {
|
|
return slice.Map(us, func(u dbUser) model.User { return *u.User })
|
|
}
|
|
|
|
var (
|
|
once sync.Once
|
|
encKey []byte
|
|
)
|
|
|
|
func NewUserRepository(db dbx.Builder) model.UserRepository {
|
|
r := &userRepository{}
|
|
r.db = db
|
|
r.tableName = "user"
|
|
r.registerModel(&model.User{}, map[string]filterFunc{
|
|
"id": idFilter(r.tableName),
|
|
"password": invalidFilter,
|
|
"name": startsWithFilter(r.tableName + ".name"),
|
|
})
|
|
once.Do(func() {
|
|
_ = r.initPasswordEncryptionKey(context.Background())
|
|
})
|
|
return r
|
|
}
|
|
|
|
// selectUserWithLibraries returns a SelectBuilder that includes library information
|
|
func (r *userRepository) selectUserWithLibraries(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
|
|
return r.newSelect(ctx, options...).
|
|
Columns(`user.*`,
|
|
`COALESCE(json_group_array(json_object(
|
|
'id', library.id,
|
|
'name', library.name,
|
|
'path', library.path,
|
|
'remote_path', library.remote_path,
|
|
'last_scan_at', library.last_scan_at,
|
|
'last_scan_started_at', library.last_scan_started_at,
|
|
'full_scan_in_progress', library.full_scan_in_progress,
|
|
'updated_at', library.updated_at,
|
|
'created_at', library.created_at
|
|
)) FILTER (WHERE library.id IS NOT NULL), '[]') AS libraries_json`).
|
|
LeftJoin("user_library ul ON user.id = ul.user_id").
|
|
LeftJoin("library ON ul.library_id = library.id").
|
|
GroupBy("user.id")
|
|
}
|
|
|
|
func (r *userRepository) CountAll(ctx context.Context, qo ...model.QueryOptions) (int64, error) {
|
|
return r.count(ctx, Select(), qo...)
|
|
}
|
|
|
|
func (r *userRepository) Get(ctx context.Context, id string) (*model.User, error) {
|
|
sel := r.selectUserWithLibraries(ctx).Where(Eq{"user.id": id})
|
|
var res dbUser
|
|
err := r.queryOne(ctx, sel, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.User, nil
|
|
}
|
|
|
|
func (r *userRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Users, error) {
|
|
sel := r.selectUserWithLibraries(ctx, options...)
|
|
var res dbUsers
|
|
err := r.queryAll(ctx, sel, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.toModels(), nil
|
|
}
|
|
|
|
func (r *userRepository) Put(ctx context.Context, u *model.User) error {
|
|
if u.ID == "" {
|
|
u.ID = id.NewRandom()
|
|
}
|
|
u.UpdatedAt = time.Now()
|
|
if u.NewPassword != "" {
|
|
_ = r.encryptPassword(ctx, u)
|
|
ctx = log.WithSecrets(ctx, u.NewPassword)
|
|
}
|
|
values, err := toSQLArgs(*u)
|
|
if err != nil {
|
|
return fmt.Errorf("error converting user to SQL args: %w", err)
|
|
}
|
|
delete(values, "current_password")
|
|
|
|
// The epoch bump rides the password UPDATE: as two statements they can interleave with a
|
|
// concurrent change and leave a session valid that the other change should have revoked.
|
|
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
|
|
var isNewUser bool
|
|
var epoch int
|
|
if u.NewPassword != "" {
|
|
var res struct{ TokenEpoch int }
|
|
err = r.queryOne(ctx, update.Set("token_epoch", Expr("token_epoch + 1")).
|
|
Suffix("RETURNING token_epoch"), &res)
|
|
switch {
|
|
case errors.Is(err, model.ErrNotFound):
|
|
isNewUser = true
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
epoch = res.TokenEpoch
|
|
}
|
|
} else {
|
|
count, err := r.executeSQL(ctx, update)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
isNewUser = count == 0
|
|
}
|
|
if isNewUser {
|
|
values["created_at"] = time.Now()
|
|
insert := Insert(r.tableName).SetMap(values)
|
|
_, err = r.executeSQL(ctx, insert)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Auto-assign all libraries to admin users in a single SQL operation
|
|
if u.IsAdmin {
|
|
sql := Expr(
|
|
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library",
|
|
u.ID,
|
|
)
|
|
if _, err := r.executeSQL(ctx, sql); err != nil {
|
|
return fmt.Errorf("failed to assign all libraries to admin user: %w", err)
|
|
}
|
|
} else if isNewUser { // Only for new regular users
|
|
// Auto-assign default libraries to new regular users
|
|
sql := Expr(
|
|
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true",
|
|
u.ID,
|
|
)
|
|
if _, err := r.executeSQL(ctx, sql); err != nil {
|
|
return fmt.Errorf("failed to assign default libraries to new user: %w", err)
|
|
}
|
|
}
|
|
|
|
// Only the caller's own token can be refreshed in-flight; an admin resetting another
|
|
// user must keep their own epoch.
|
|
if u.NewPassword != "" && !isNewUser && loggedUser(ctx).ID == u.ID {
|
|
request.SetTokenEpoch(ctx, epoch)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *userRepository) FindFirstAdmin(ctx context.Context) (*model.User, error) {
|
|
sel := r.selectUserWithLibraries(ctx, model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true})
|
|
var usr dbUser
|
|
err := r.queryOne(ctx, sel, &usr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return usr.User, nil
|
|
}
|
|
|
|
func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
|
|
sel := r.selectUserWithLibraries(ctx).Where(Expr("user.user_name = ? COLLATE NOCASE", username))
|
|
var usr dbUser
|
|
err := r.queryOne(ctx, sel, &usr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return usr.User, nil
|
|
}
|
|
|
|
func (r *userRepository) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) {
|
|
usr, err := r.FindByUsername(ctx, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = r.decryptPassword(ctx, usr)
|
|
return usr, nil
|
|
}
|
|
|
|
func (r *userRepository) UpdateLastLoginAt(ctx context.Context, id string) error {
|
|
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now())
|
|
_, err := r.executeSQL(ctx, upd)
|
|
return err
|
|
}
|
|
|
|
func (r *userRepository) UpdateLastAccessAt(ctx context.Context, id string) error {
|
|
now := time.Now()
|
|
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_access_at", now)
|
|
_, err := r.executeSQL(ctx, upd)
|
|
return err
|
|
}
|
|
|
|
func (r *userRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) {
|
|
usr := loggedUser(ctx)
|
|
if !usr.IsAdmin {
|
|
return 0, rest.ErrPermissionDenied
|
|
}
|
|
return r.CountAll(ctx, r.parseRestOptions(ctx, options...))
|
|
}
|
|
|
|
func (r *userRepository) Read(ctx context.Context, id string) (*model.User, error) {
|
|
usr := loggedUser(ctx)
|
|
if !usr.IsAdmin && usr.ID != id {
|
|
return nil, rest.ErrPermissionDenied
|
|
}
|
|
return r.Get(ctx, id)
|
|
}
|
|
|
|
func (r *userRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.User, error) {
|
|
usr := loggedUser(ctx)
|
|
if !usr.IsAdmin {
|
|
return nil, rest.ErrPermissionDenied
|
|
}
|
|
return r.GetAll(ctx, r.parseRestOptions(ctx, options...))
|
|
}
|
|
|
|
func (r *userRepository) Save(ctx context.Context, u *model.User) (string, error) {
|
|
usr := loggedUser(ctx)
|
|
if !usr.IsAdmin {
|
|
return "", rest.ErrPermissionDenied
|
|
}
|
|
if err := validateUsernameUnique(ctx, r, u); err != nil {
|
|
return "", err
|
|
}
|
|
if err := validateScrobbleFilter(u); err != nil {
|
|
return "", err
|
|
}
|
|
err := r.Put(ctx, u)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return u.ID, err
|
|
}
|
|
|
|
func (r *userRepository) Update(ctx context.Context, id string, entity model.User, _ ...string) error {
|
|
u := &entity
|
|
u.ID = id
|
|
usr := loggedUser(ctx)
|
|
if !usr.IsAdmin && usr.ID != u.ID {
|
|
return rest.ErrPermissionDenied
|
|
}
|
|
if !usr.IsAdmin {
|
|
if !conf.Server.EnableUserEditing {
|
|
return rest.ErrPermissionDenied
|
|
}
|
|
u.IsAdmin = false
|
|
u.UserName = usr.UserName
|
|
}
|
|
|
|
// Decrypt the user's existing password before validating. This is required otherwise the existing password entered by the user will never match.
|
|
if err := r.decryptPassword(ctx, usr); err != nil {
|
|
return err
|
|
}
|
|
if err := validatePasswordChange(u, usr); err != nil {
|
|
return err
|
|
}
|
|
if err := validateUsernameUnique(ctx, r, u); err != nil {
|
|
return err
|
|
}
|
|
if err := validateScrobbleFilter(u); err != nil {
|
|
return err
|
|
}
|
|
return r.Put(ctx, u)
|
|
}
|
|
|
|
func validatePasswordChange(newUser *model.User, logged *model.User) error {
|
|
err := &rest.ValidationError{Errors: map[string]string{}}
|
|
if logged.IsAdmin && newUser.ID != logged.ID {
|
|
return nil
|
|
}
|
|
if newUser.NewPassword == "" {
|
|
if newUser.CurrentPassword == "" {
|
|
return nil
|
|
}
|
|
err.Errors["password"] = "ra.validation.required"
|
|
}
|
|
|
|
if !strings.HasPrefix(logged.Password, consts.PasswordAutogenPrefix) {
|
|
if newUser.CurrentPassword == "" {
|
|
err.Errors["currentPassword"] = "ra.validation.required"
|
|
}
|
|
if newUser.CurrentPassword != logged.Password {
|
|
err.Errors["currentPassword"] = "ra.validation.passwordDoesNotMatch"
|
|
}
|
|
}
|
|
if len(err.Errors) > 0 {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateUsernameUnique(ctx context.Context, r model.UserRepository, u *model.User) error {
|
|
usr, err := r.FindByUsername(ctx, u.UserName)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if usr.ID != u.ID {
|
|
return &rest.ValidationError{Errors: map[string]string{"userName": "ra.validation.unique"}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateScrobbleFilter(u *model.User) error {
|
|
u.ScrobbleFilter = strings.TrimSpace(u.ScrobbleFilter)
|
|
if u.ScrobbleFilter == "" {
|
|
return nil
|
|
}
|
|
var c criteria.Criteria
|
|
if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil {
|
|
return invalidScrobbleFilter()
|
|
}
|
|
// A filter is a per-track test, so a result-set size means nothing here. Reject it
|
|
// rather than silently ignoring part of a rule copied from a smart playlist.
|
|
if c.Limit > 0 || c.LimitPercent > 0 || c.Offset > 0 || c.RefreshDelay > 0 {
|
|
return invalidScrobbleFilter()
|
|
}
|
|
// Building the WHERE clause is what validates field names and operators
|
|
if _, err := newSmartPlaylistCriteria(c).where(); err != nil {
|
|
return invalidScrobbleFilter()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func invalidScrobbleFilter() error {
|
|
return &rest.ValidationError{Errors: map[string]string{
|
|
"scrobbleFilter": "resources.user.validation.invalidScrobbleFilter",
|
|
}}
|
|
}
|
|
|
|
func (r *userRepository) Delete(ctx context.Context, ids ...string) error {
|
|
usr := loggedUser(ctx)
|
|
if !usr.IsAdmin {
|
|
return rest.ErrPermissionDenied
|
|
}
|
|
for _, id := range ids {
|
|
if err := r.deleteByID(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
if err := cleanupPluginUserReferences(r.db, id); err != nil {
|
|
log.Error(ctx, "Failed to cleanup plugin user references", "userID", id, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func keyTo32Bytes(input string) []byte {
|
|
data := sha256.Sum256([]byte(input))
|
|
return data[0:]
|
|
}
|
|
|
|
func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error {
|
|
encKey = keyTo32Bytes(consts.DefaultEncryptionKey)
|
|
if conf.Server.PasswordEncryptionKey == "" {
|
|
return nil
|
|
}
|
|
|
|
key := keyTo32Bytes(conf.Server.PasswordEncryptionKey)
|
|
keySum := fmt.Sprintf("%x", sha256.Sum256(key))
|
|
ctx = log.WithSecrets(ctx, keySum)
|
|
|
|
props := NewPropertyRepository(r.db)
|
|
savedKeySum, err := props.Get(ctx, consts.PasswordsEncryptedKey)
|
|
|
|
// If passwords are already encrypted
|
|
if err == nil {
|
|
if savedKeySum != keySum {
|
|
log.Error("Password Encryption Key changed! Users won't be able to login!")
|
|
return errors.New("passwordEncryptionKey changed")
|
|
}
|
|
encKey = key
|
|
return nil
|
|
}
|
|
|
|
// if not, try to re-encrypt all current passwords with new encryption key,
|
|
// assuming they were encrypted with the DefaultEncryptionKey
|
|
sql := r.newSelect(ctx).Columns("id", "user_name", "password")
|
|
users := model.Users{}
|
|
err = r.queryAll(ctx, sql, &users)
|
|
if err != nil {
|
|
log.Error("Could not encrypt all passwords", err)
|
|
return err
|
|
}
|
|
log.Warn("New PasswordEncryptionKey set. Encrypting all passwords", "numUsers", len(users))
|
|
if err = r.decryptAllPasswords(ctx, users); err != nil {
|
|
return err
|
|
}
|
|
encKey = key
|
|
for i := range users {
|
|
u := users[i]
|
|
u.NewPassword = u.Password
|
|
if err := r.encryptPassword(ctx, &u); err == nil {
|
|
upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID})
|
|
userCtx := log.WithSecrets(ctx, u.NewPassword)
|
|
_, err = r.executeSQL(userCtx, upd)
|
|
if err != nil {
|
|
log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err)
|
|
} else {
|
|
log.Warn("Password encrypted successfully", "user", u.UserName, "id", u.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
err = props.Put(ctx, consts.PasswordsEncryptedKey, keySum)
|
|
if err != nil {
|
|
log.Error("Could not flag passwords as encrypted. It will cause login errors", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// encrypts u.NewPassword
|
|
func (r *userRepository) encryptPassword(ctx context.Context, u *model.User) error {
|
|
encPassword, err := utils.Encrypt(ctx, encKey, u.NewPassword)
|
|
if err != nil {
|
|
log.Error(ctx, "Error encrypting user's password", "user", u.UserName, err)
|
|
return err
|
|
}
|
|
u.NewPassword = encPassword
|
|
return nil
|
|
}
|
|
|
|
// decrypts u.Password
|
|
func (r *userRepository) decryptPassword(ctx context.Context, u *model.User) error {
|
|
plaintext, err := utils.Decrypt(ctx, encKey, u.Password)
|
|
if err != nil {
|
|
log.Error(ctx, "Error decrypting user's password", "user", u.UserName, err)
|
|
return err
|
|
}
|
|
u.Password = plaintext
|
|
return nil
|
|
}
|
|
|
|
func (r *userRepository) decryptAllPasswords(ctx context.Context, users model.Users) error {
|
|
for i := range users {
|
|
if err := r.decryptPassword(ctx, &users[i]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Library association methods
|
|
|
|
func (r *userRepository) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
|
|
sel := Select("l.*").
|
|
From("library l").
|
|
Join("user_library ul ON l.id = ul.library_id").
|
|
Where(Eq{"ul.user_id": userID}).
|
|
OrderBy("l.name")
|
|
|
|
var res model.Libraries
|
|
err := r.queryAll(ctx, sel, &res)
|
|
return res, err
|
|
}
|
|
|
|
func (r *userRepository) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
|
|
// Remove existing associations
|
|
delSql := Delete("user_library").Where(Eq{"user_id": userID})
|
|
if _, err := r.executeSQL(ctx, delSql); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add new associations
|
|
if len(libraryIDs) > 0 {
|
|
insert := Insert("user_library").Columns("user_id", "library_id")
|
|
for _, libID := range libraryIDs {
|
|
insert = insert.Values(userID, libID)
|
|
}
|
|
_, err := r.executeSQL(ctx, insert)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var _ model.UserRepository = (*userRepository)(nil)
|
|
var _ rest.Repository[model.User] = (*userRepository)(nil)
|
|
var _ rest.Persistable[model.User] = (*userRepository)(nil)
|