🏡 index : quine-global/quintodrome.git

package subsonic

import (
	"cmp"
	"context"
	"crypto/md5"
	"encoding/hex"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/go-chi/chi/v5/middleware"
	ua "github.com/mileusna/useragent"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/core"
	"github.com/navidrome/navidrome/core/auth"
	"github.com/navidrome/navidrome/core/metrics"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/server"
	"github.com/navidrome/navidrome/server/subsonic/responses"
	. "github.com/navidrome/navidrome/utils/gg"
	"github.com/navidrome/navidrome/utils/req"
)

func postFormToQueryParams(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10MB
		err := r.ParseForm()
		if err != nil {
			sendError(w, r, newError(responses.ErrorGeneric, err.Error()))
			return
		}
		var parts []string
		for key, values := range r.Form {
			for _, v := range values {
				parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v))
			}
		}
		r.URL.RawQuery = strings.Join(parts, "&")

		next.ServeHTTP(w, r)
	})
}

func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
	username := server.InternalAuth(r)

	// If the username comes from internal auth, do not also do reverse proxy auth, as
	// the request will have no reverse proxy IP
	if username != "" {
		return username, true
	}

	return server.UsernameFromExtAuthHeader(r), false
}

func checkRequiredParameters(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var requiredParameters []string

		p := req.Params(r)
		username, _ := fromInternalOrProxyAuth(r)
		apiKey, _ := p.String("apiKey")
		if username != "" || apiKey != "" {
			requiredParameters = []string{"v", "c"}
		} else {
			requiredParameters = []string{"u", "v", "c"}
		}

		for _, param := range requiredParameters {
			if _, err := p.String(param); err != nil {
				log.Warn(r, err)
				sendError(w, r, err)
				return
			}
		}

		if username == "" {
			username, _ = p.String("u")
		}
		client, _ := p.String("c")
		version, _ := p.String("v")

		ctx := r.Context()
		ctx = request.WithUsername(ctx, username)
		ctx = request.WithClient(ctx, client)
		ctx = request.WithVersion(ctx, version)
		log.Debug(ctx, "API: New request "+r.URL.Path, "username", username, "client", client, "version", version)

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
	limiter := newAuthLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := r.Context()

			var usr *model.User
			var keyPlayer *model.Player
			var err error

			p := req.Params(r)
			apiKey, _ := p.String("apiKey")
			username, isInternalAuth := fromInternalOrProxyAuth(r)
			switch {
			case username != "":
				authType := If(isInternalAuth, "internal", "reverse-proxy")
				usr, err = ds.User().FindByUsername(ctx, username)
				if errors.Is(err, context.Canceled) {
					log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
					return
				}
				if errors.Is(err, model.ErrNotFound) {
					log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
				} else if err != nil {
					log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
				}
			case apiKey != "":
				usr, keyPlayer, err = authenticateAPIKey(ctx, ds, limiter, r, apiKey)
				if err != nil {
					if ctx.Err() == nil {
						sendError(w, r, err)
					}
					return
				}
				ctx = request.WithUsername(ctx, usr.UserName)
			default:
				username, _ := p.String("u")
				pass, _ := p.String("p")
				token, _ := p.String("t")
				salt, _ := p.String("s")
				jwt, _ := p.String("jwt")

				// Blocked attempts get the same response as a wrong password, so they reveal nothing
				limitKey := server.ClientIP(r) + "\x00" + strings.ToLower(username)
				slot, allowed := limiter.acquire(ctx, limitKey)
				if !allowed {
					if ctx.Err() != nil {
						return
					}
					log.Warn(ctx, "API: Too many failed login attempts", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr)
					sendError(w, r, newError(responses.ErrorAuthenticationFail))
					return
				}

				usr, err = ds.User().FindByUsernameWithPassword(ctx, username)
				if err == nil {
					err = validateCredentials(usr, pass, token, salt, jwt)
					if errors.Is(err, model.ErrInvalidAuth) && pass != "" && jwt == "" {
						keyPlayer, err = playerFromPasswordKey(ctx, ds, usr, pass)
					}
				}
				invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth)
				slot.release(invalidLogin)
				switch {
				case errors.Is(err, context.Canceled):
					log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
					return
				case invalidLogin:
					log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
				case err != nil:
					log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
				}
			}

			if err != nil {
				sendError(w, r, newError(responses.ErrorAuthenticationFail))
				return
			}

			ctx = request.WithUser(ctx, *usr)
			if keyPlayer != nil {
				ctx = request.WithPlayer(ctx, *keyPlayer)
			}
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

var apiKeyConflicts = []string{"u", "p", "t", "s", "jwt"}

func authenticateAPIKey(ctx context.Context, ds model.DataStore, limiter *authLimiter, r *http.Request, key string) (*model.User, *model.Player, error) {
	query := r.URL.Query()
	for _, param := range apiKeyConflicts {
		if query.Has(param) {
			log.Warn(ctx, "API: apiKey sent with other credentials", "auth", "apikey", "param", param, "remoteAddr", r.RemoteAddr)
			return nil, nil, newError(responses.ErrorMultipleAuthMechanismsProvided)
		}
	}

	// Per key, so a stale key on one device cannot lock out valid keys sharing the IP
	slot, allowed := limiter.acquire(ctx, "apikey\x00"+server.ClientIP(r)+"\x00"+key)
	if !allowed {
		if err := ctx.Err(); err != nil {
			return nil, nil, err
		}
		log.Warn(ctx, "API: Too many failed API key attempts", "auth", "apikey", "remoteAddr", r.RemoteAddr)
		return nil, nil, newError(responses.ErrorInvalidAPIKey)
	}

	player, err := ds.Player().FindByAPIKey(ctx, key)
	var usr *model.User
	if err == nil {
		usr, err = ds.User().Get(ctx, player.UserId)
	}
	slot.release(errors.Is(err, model.ErrNotFound))
	switch {
	case errors.Is(err, context.Canceled):
		return nil, nil, err
	case errors.Is(err, model.ErrNotFound):
		log.Warn(ctx, "API: Invalid API key", "auth", "apikey", "remoteAddr", r.RemoteAddr)
		return nil, nil, newError(responses.ErrorInvalidAPIKey)
	case err != nil:
		log.Error(ctx, "API: Error authenticating API key", "auth", "apikey", "remoteAddr", r.RemoteAddr, err)
		return nil, nil, newError(responses.ErrorAuthenticationFail)
	}
	return usr, player, nil
}

// playerFromPasswordKey lets clients that only have a password field log in with an API key.
// It returns ErrInvalidAuth when pass is not a key of usr, so only real failures skip the limiter count.
func playerFromPasswordKey(ctx context.Context, ds model.DataStore, usr *model.User, pass string) (*model.Player, error) {
	key := decodePassword(pass)
	if !strings.HasPrefix(key, consts.APIKeyPrefix) {
		return nil, model.ErrInvalidAuth
	}
	plr, err := ds.Player().FindByAPIKey(ctx, key)
	if errors.Is(err, model.ErrNotFound) || (err == nil && plr.UserId != usr.ID) {
		return nil, model.ErrInvalidAuth
	}
	return plr, err
}

func decodePassword(pass string) string {
	if strings.HasPrefix(pass, "enc:") {
		if dec, err := hex.DecodeString(pass[4:]); err == nil {
			return string(dec)
		}
	}
	return pass
}

func adminOnly(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		loggedUser, ok := request.UserFrom(r.Context())
		if !ok {
			sendError(w, r, newError(responses.ErrorGeneric, "Internal error"))
			return
		}

		if !loggedUser.IsAdmin {
			sendError(w, r, newError(responses.ErrorAuthorizationFail))
			return
		}

		next.ServeHTTP(w, r)
	})
}

func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
	valid := false

	switch {
	case jwt != "":
		claims, err := auth.Validate(jwt)
		valid = err == nil &&
			claims.Subject == user.UserName &&
			auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil
	case pass != "":
		valid = decodePassword(pass) == user.Password
	case token != "":
		t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
		valid = t == token
	}

	if !valid {
		return model.ErrInvalidAuth
	}
	return nil
}

func getPlayer(players core.Players) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := r.Context()
			userName, _ := request.UsernameFrom(ctx)
			client, _ := request.ClientFrom(ctx)
			ip, _, _ := net.SplitHostPort(r.RemoteAddr)
			userAgent := canonicalUserAgent(r)

			var player *model.Player
			var trc *model.Transcoding
			var err error
			keyPlayer, boundByKey := request.PlayerFrom(ctx)
			if boundByKey {
				player, trc, err = players.Touch(ctx, keyPlayer, client, userAgent, ip)
			} else {
				player, trc, err = players.Register(ctx, playerIDFromCookie(r, userName), client, userAgent, ip)
			}
			if err != nil {
				log.Error(ctx, "Could not resolve player", "username", userName, "client", client, err)
			} else {
				ctx = request.WithPlayer(ctx, *player)
				if trc != nil {
					ctx = request.WithTranscoding(ctx, *trc)
				}
				r = r.WithContext(ctx)

				// A key already identifies the player, so the cookie would only add a second, weaker signal
				if boundByKey {
					next.ServeHTTP(w, r)
					return
				}
				cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP
					Name:     playerIDCookieName(userName),
					Value:    player.ID,
					MaxAge:   consts.CookieExpiry,
					HttpOnly: true,
					SameSite: http.SameSiteStrictMode,
					Path:     cmp.Or(conf.Server.BasePath, "/"),
				}
				http.SetCookie(w, cookie)
			}

			next.ServeHTTP(w, r)
		})
	}
}

func canonicalUserAgent(r *http.Request) string {
	u := ua.Parse(r.Header.Get("user-agent"))
	userAgent := u.Name
	if u.OS != "" {
		userAgent = userAgent + "/" + u.OS
	}
	return userAgent
}

func playerIDFromCookie(r *http.Request, userName string) string {
	cookieName := playerIDCookieName(userName)
	var playerId string
	if c, err := r.Cookie(cookieName); err == nil {
		playerId = c.Value
		log.Trace(r, "playerId found in cookies", "playerId", playerId)
	}
	return playerId
}

func playerIDCookieName(userName string) string {
	cookieName := fmt.Sprintf("nd-player-%x", userName)
	return cookieName
}

type contextKey string

const subsonicErrorPointer contextKey = "subsonicErrorPointer"

func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		fn := func(w http.ResponseWriter, r *http.Request) {
			ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)

			status := int32(-1)
			contextWithStatus := context.WithValue(r.Context(), subsonicErrorPointer, &status)

			start := time.Now()
			defer func() {
				elapsed := time.Since(start).Milliseconds()

				// We want to get the client name (even if not present for certain endpoints)
				p := req.Params(r)
				client, _ := p.String("c")

				// If there is no Subsonic status (e.g., HTTP 501 not implemented), fallback to HTTP
				if status == -1 {
					status = int32(ww.Status())
				}

				shortPath := strings.Replace(r.URL.Path, ".view", "", 1)

				metrics.RecordRequest(r.Context(), shortPath, r.Method, client, status, elapsed)
			}()

			next.ServeHTTP(ww, r.WithContext(contextWithStatus))
		}
		return http.HandlerFunc(fn)
	}
}