package subsonic import ( "crypto/md5" "crypto/subtle" "database/sql" "encoding/hex" "errors" "net/http" "strings" "github.com/mithral/vellum/internal/apitoken" "github.com/mithral/vellum/internal/auth" ) // authenticate resolves the Subsonic credentials on a request to a user. // // Every accepted form is checked against the user's recoverable Subsonic key // (apitoken.Plain), which is the only credential this protocol can verify: // // - t = md5(key + s), with s the salt — the preferred form. // - p = key directly (Navidrome accepts this, and so must we for older // clients). // - apiKey = key directly, the OpenSubsonic extension. // - token = key-derived md5 with s — the same as t under the alternate // parameter name some clients use. // // A missing user, a user with no Subsonic key, or a wrong credential all // collapse to "not authenticated": the caller must not be able to tell them // apart, and the response is always error code 40. func (h *Handlers) authenticate(r *http.Request) (auth.User, bool) { ctx := r.Context() q := r.FormValue username := strings.TrimSpace(q("u")) apiKey := strings.TrimSpace(q("apiKey")) // apiKey auth carries no username; resolve the key to its owner. if username == "" { if apiKey == "" { return auth.User{}, false } userID, err := apitoken.Resolve(ctx, h.db, apiKey) if err != nil { return auth.User{}, false } u, err := auth.GetUserByID(ctx, h.db, userID) if err != nil { return auth.User{}, false } return u, true } u, err := auth.GetUserByUsername(ctx, h.db, username) if errors.Is(err, sql.ErrNoRows) { return auth.User{}, false } if err != nil { h.log.Error("subsonic: load user", "err", err) return auth.User{}, false } key, err := apitoken.Plain(ctx, h.db, u.ID) if errors.Is(err, apitoken.ErrNotFound) { // The user has no Subsonic key yet; they must mint one from the web // UI. Treated as a credential failure, not a server error. return auth.User{}, false } if err != nil { h.log.Error("subsonic: load key", "err", err) return auth.User{}, false } // p = the key directly. "enc:" is the spec's hex-encoded form. if p := q("p"); p != "" { if raw, ok := strings.CutPrefix(p, "enc:"); ok { decoded, err := hex.DecodeString(raw) if err == nil { p = string(decoded) } } if constantTimeEqual(p, key) { return u, true } } // apiKey alongside u: same direct comparison. if apiKey != "" && constantTimeEqual(apiKey, key) { return u, true } // t (or token) = md5(key + s). token := q("t") if token == "" { token = q("token") } salt := q("s") if token != "" && salt != "" { sum := md5.Sum([]byte(key + salt)) want := hex.EncodeToString(sum[:]) if constantTimeEqual(strings.ToLower(token), want) { return u, true } } return auth.User{}, false } // constantTimeEqual compares two credential strings without leaking their // common prefix through timing. func constantTimeEqual(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 }