mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 22:07:08 +02:00
* 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>
913 lines
27 KiB
Go
913 lines
27 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/core/auth"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
func newGetRequest(queryParams ...string) *http.Request {
|
|
r := httptest.NewRequest("GET", "/ping?"+strings.Join(queryParams, "&"), nil)
|
|
ctx := r.Context()
|
|
return r.WithContext(log.NewContext(ctx))
|
|
}
|
|
|
|
func newPostRequest(queryParam string, formFields ...string) *http.Request {
|
|
r, err := http.NewRequest("POST", "/ping?"+queryParam, strings.NewReader(strings.Join(formFields, "&")))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
|
|
ctx := r.Context()
|
|
return r.WithContext(log.NewContext(ctx))
|
|
}
|
|
|
|
var _ = Describe("Middlewares", func() {
|
|
var ctx context.Context
|
|
var next *mockHandler
|
|
var w *httptest.ResponseRecorder
|
|
var ds model.DataStore
|
|
|
|
BeforeEach(func() {
|
|
ctx = GinkgoT().Context()
|
|
next = &mockHandler{}
|
|
w = httptest.NewRecorder()
|
|
ds = &tests.MockDataStore{}
|
|
})
|
|
|
|
Describe("ParsePostForm", func() {
|
|
It("converts any filed in a x-www-form-urlencoded POST into query params", func() {
|
|
r := newPostRequest("a=abc", "u=user", "v=1.15", "c=test")
|
|
cp := postFormToQueryParams(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.req.URL.Query().Get("a")).To(Equal("abc"))
|
|
Expect(next.req.URL.Query().Get("u")).To(Equal("user"))
|
|
Expect(next.req.URL.Query().Get("v")).To(Equal("1.15"))
|
|
Expect(next.req.URL.Query().Get("c")).To(Equal("test"))
|
|
})
|
|
It("adds repeated params", func() {
|
|
r := newPostRequest("a=abc", "id=1", "id=2")
|
|
cp := postFormToQueryParams(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.req.URL.Query().Get("a")).To(Equal("abc"))
|
|
Expect(next.req.URL.Query()["id"]).To(ConsistOf("1", "2"))
|
|
})
|
|
It("overrides query params with same key", func() {
|
|
r := newPostRequest("a=query", "a=body")
|
|
cp := postFormToQueryParams(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.req.URL.Query().Get("a")).To(Equal("body"))
|
|
})
|
|
})
|
|
|
|
Describe("CheckParams", func() {
|
|
It("passes when all required params are available (subsonicauth case)", func() {
|
|
r := newGetRequest("u=user", "v=1.15", "c=test")
|
|
cp := checkRequiredParameters(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
username, _ := request.UsernameFrom(next.req.Context())
|
|
Expect(username).To(Equal("user"))
|
|
version, _ := request.VersionFrom(next.req.Context())
|
|
Expect(version).To(Equal("1.15"))
|
|
client, _ := request.ClientFrom(next.req.Context())
|
|
Expect(client).To(Equal("test"))
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("passes when all required params are available (reverse-proxy case)", func() {
|
|
conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
|
|
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
|
|
|
r := newGetRequest("v=1.15", "c=test")
|
|
r.Header.Add("Remote-User", "user")
|
|
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "127.0.0.234"))
|
|
|
|
cp := checkRequiredParameters(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
username, _ := request.UsernameFrom(next.req.Context())
|
|
Expect(username).To(Equal("user"))
|
|
version, _ := request.VersionFrom(next.req.Context())
|
|
Expect(version).To(Equal("1.15"))
|
|
client, _ := request.ClientFrom(next.req.Context())
|
|
Expect(client).To(Equal("test"))
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("does not require u when apiKey is present", func() {
|
|
r := newGetRequest("apiKey=nds_abc", "v=1.15", "c=test")
|
|
cp := checkRequiredParameters(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("fails when user is missing", func() {
|
|
r := newGetRequest("v=1.15", "c=test")
|
|
cp := checkRequiredParameters(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
|
|
It("fails when version is missing", func() {
|
|
r := newGetRequest("u=user", "c=test")
|
|
cp := checkRequiredParameters(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
|
|
It("fails when client is missing", func() {
|
|
r := newGetRequest("u=user", "v=1.15")
|
|
cp := checkRequiredParameters(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("Authenticate", func() {
|
|
BeforeEach(func() {
|
|
ur := ds.User()
|
|
_ = ur.Put(ctx, &model.User{
|
|
UserName: "admin",
|
|
NewPassword: "wordpass",
|
|
})
|
|
})
|
|
|
|
When("using password authentication", func() {
|
|
It("passes authentication with correct credentials", func() {
|
|
r := newGetRequest("u=admin", "p=wordpass")
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
user, _ := request.UserFrom(next.req.Context())
|
|
Expect(user.UserName).To(Equal("admin"))
|
|
})
|
|
|
|
It("fails authentication with invalid user", func() {
|
|
r := newGetRequest("u=invalid", "p=wordpass")
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
|
|
It("fails authentication with invalid password", func() {
|
|
r := newGetRequest("u=admin", "p=INVALID")
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
When("using token authentication", func() {
|
|
var salt = "12345"
|
|
|
|
It("passes authentication with correct token", func() {
|
|
token := fmt.Sprintf("%x", md5.Sum([]byte("wordpass"+salt)))
|
|
r := newGetRequest("u=admin", "t="+token, "s="+salt)
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
user, _ := request.UserFrom(next.req.Context())
|
|
Expect(user.UserName).To(Equal("admin"))
|
|
})
|
|
|
|
It("fails authentication with invalid token", func() {
|
|
r := newGetRequest("u=admin", "t=INVALID", "s="+salt)
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
|
|
It("fails authentication with empty password", func() {
|
|
// Token generated with random Salt, empty password
|
|
token := fmt.Sprintf("%x", md5.Sum([]byte(""+salt)))
|
|
r := newGetRequest("u=NON_EXISTENT_USER", "t="+token, "s="+salt)
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
When("using JWT authentication", func() {
|
|
var validToken string
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.SessionTimeout = time.Minute
|
|
auth.Init(ds)
|
|
})
|
|
|
|
It("passes authentication with correct token", func() {
|
|
usr := &model.User{UserName: "admin"}
|
|
var err error
|
|
validToken, err = auth.CreateToken(usr)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
r := newGetRequest("u=admin", "jwt="+validToken)
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
user, _ := request.UserFrom(next.req.Context())
|
|
Expect(user.UserName).To(Equal("admin"))
|
|
})
|
|
|
|
It("fails authentication with invalid token", func() {
|
|
r := newGetRequest("u=admin", "jwt=INVALID_TOKEN")
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
When("using reverse proxy authentication", func() {
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
|
|
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
|
})
|
|
|
|
It("passes authentication with correct IP and header", func() {
|
|
r := newGetRequest("u=admin")
|
|
r.Header.Add("Remote-User", "admin")
|
|
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1"))
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
user, _ := request.UserFrom(next.req.Context())
|
|
Expect(user.UserName).To(Equal("admin"))
|
|
})
|
|
|
|
It("fails authentication with wrong IP", func() {
|
|
r := newGetRequest("u=admin")
|
|
r.Header.Add("Remote-User", "admin")
|
|
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.2.1"))
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
When("using internal authentication", func() {
|
|
It("passes authentication with correct internal credentials", func() {
|
|
// Simulate internal authentication by setting the context with WithInternalAuth
|
|
r := newGetRequest()
|
|
r = r.WithContext(request.WithInternalAuth(r.Context(), "admin"))
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
user, _ := request.UserFrom(next.req.Context())
|
|
Expect(user.UserName).To(Equal("admin"))
|
|
})
|
|
|
|
It("fails authentication with missing internal context", func() {
|
|
r := newGetRequest("u=admin")
|
|
// Do not set the internal auth context
|
|
cp := authenticate(ds)(next)
|
|
cp.ServeHTTP(w, r)
|
|
|
|
// Internal auth requires the context, so this should fail
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
When("using API key authentication", func() {
|
|
var key string
|
|
serve := func(params ...string) {
|
|
authenticate(ds)(next).ServeHTTP(w, newGetRequest(params...))
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
usr, err := ds.User().FindByUsername(ctx, "admin")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ds.Player().Put(ctx, &model.Player{ID: "player-1", Name: "My Phone", UserId: usr.ID, Client: "Symfonium"})).To(Succeed())
|
|
key = "nds_0123456789abcdefghijkl"
|
|
Expect(ds.Player().SetAPIKey(ctx, "player-1", key)).To(Succeed())
|
|
})
|
|
|
|
It("authenticates the owner and binds the key's player", func() {
|
|
serve("apiKey=" + key)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
user, _ := request.UserFrom(next.req.Context())
|
|
Expect(user.UserName).To(Equal("admin"))
|
|
username, _ := request.UsernameFrom(next.req.Context())
|
|
Expect(username).To(Equal("admin"))
|
|
player, ok := request.PlayerFrom(next.req.Context())
|
|
Expect(ok).To(BeTrue())
|
|
Expect(player.ID).To(Equal("player-1"))
|
|
})
|
|
|
|
It("accepts the key in a POST form body", func() {
|
|
r := newPostRequest("", "apiKey="+key)
|
|
cp := postFormToQueryParams(authenticate(ds)(next))
|
|
cp.ServeHTTP(w, r)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
player, _ := request.PlayerFrom(next.req.Context())
|
|
Expect(player.ID).To(Equal("player-1"))
|
|
})
|
|
|
|
It("rejects an unknown key with error 44", func() {
|
|
serve("apiKey=nds_unknown")
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="44"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
|
|
DescribeTable("rejects apiKey mixed with other credentials with error 43",
|
|
func(extra string) {
|
|
serve("apiKey="+key, extra)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="43"`))
|
|
Expect(next.called).To(BeFalse())
|
|
},
|
|
Entry("u", "u=admin"),
|
|
Entry("p", "p=wordpass"),
|
|
Entry("t", "t=abc"),
|
|
Entry("s", "s=abc"),
|
|
Entry("jwt", "jwt=abc"),
|
|
Entry("empty u", "u="),
|
|
Entry("empty p", "p="),
|
|
)
|
|
|
|
Context("key sent as the password", func() {
|
|
It("authenticates and binds the key's player", func() {
|
|
serve("u=admin", "p="+key)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
player, ok := request.PlayerFrom(next.req.Context())
|
|
Expect(ok).To(BeTrue())
|
|
Expect(player.ID).To(Equal("player-1"))
|
|
})
|
|
|
|
It("accepts the hex-encoded form", func() {
|
|
serve("u=admin", "p=enc:"+hex.EncodeToString([]byte(key)))
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("still accepts a real password that starts with the key prefix", func() {
|
|
Expect(ds.User().Put(ctx, &model.User{UserName: "prefixed", NewPassword: "nds_secret"})).To(Succeed())
|
|
serve("u=prefixed", "p=nds_secret")
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
_, ok := request.PlayerFrom(next.req.Context())
|
|
Expect(ok).To(BeFalse())
|
|
})
|
|
|
|
It("rejects another user's key with error 40", func() {
|
|
Expect(ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "pw"})).To(Succeed())
|
|
serve("u=other", "p="+key)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
})
|
|
})
|
|
|
|
When("failed attempts reach AuthRequestLimit", func() {
|
|
var cp http.Handler
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.AuthRequestLimit = 3
|
|
conf.Server.AuthWindowLength = time.Minute
|
|
cp = authenticate(ds)(next)
|
|
})
|
|
|
|
serve := func(r *http.Request) *httptest.ResponseRecorder {
|
|
next.called = false
|
|
rec := httptest.NewRecorder()
|
|
cp.ServeHTTP(rec, r)
|
|
return rec
|
|
}
|
|
failTimes := func(n int, params ...string) {
|
|
for range n {
|
|
Expect(serve(newGetRequest(params...)).Body.String()).To(ContainSubstring(`code="40"`))
|
|
}
|
|
}
|
|
|
|
It("rejects the correct password exactly like a wrong one", func() {
|
|
failTimes(3, "u=admin", "p=WRONG")
|
|
|
|
rec := serve(newGetRequest("u=admin", "p=wordpass"))
|
|
|
|
Expect(next.called).To(BeFalse())
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
Expect(rec.Body.String()).To(ContainSubstring(`code="40"`))
|
|
Expect(rec.Header().Get("Retry-After")).To(BeEmpty())
|
|
})
|
|
|
|
It("counts attempts against unknown usernames", func() {
|
|
failTimes(3, "u=newuser", "p=secret")
|
|
_ = ds.User().Put(ctx, &model.User{UserName: "newuser", NewPassword: "secret"})
|
|
|
|
serve(newGetRequest("u=newuser", "p=secret"))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
|
|
It("treats usernames case-insensitively", func() {
|
|
failTimes(3, "u=ADMIN", "p=WRONG")
|
|
|
|
serve(newGetRequest("u=admin", "p=wordpass"))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
|
|
It("does not count successful logins", func() {
|
|
for range 10 {
|
|
serve(newGetRequest("u=admin", "p=wordpass"))
|
|
Expect(next.called).To(BeTrue())
|
|
}
|
|
})
|
|
|
|
It("does not count server errors", func() {
|
|
userRepo := ds.User().(*tests.MockedUserRepo)
|
|
userRepo.Error = errors.New("db down")
|
|
failTimes(5, "u=admin", "p=wordpass")
|
|
userRepo.Error = nil
|
|
|
|
serve(newGetRequest("u=admin", "p=wordpass"))
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("does not count server errors when a key is sent as the password", func() {
|
|
usr, _ := ds.User().FindByUsername(ctx, "admin")
|
|
playerRepo := ds.Player().(*tests.MockPlayerRepo)
|
|
Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed())
|
|
key := "nds_0123456789abcdefghijkl"
|
|
Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed())
|
|
|
|
playerRepo.Error = errors.New("db down")
|
|
failTimes(5, "u=admin", "p="+key)
|
|
playerRepo.Error = nil
|
|
|
|
serve(newGetRequest("u=admin", "p="+key))
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("does not block other usernames from the same IP", func() {
|
|
_ = ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "otherpass"})
|
|
failTimes(3, "u=admin", "p=WRONG")
|
|
|
|
serve(newGetRequest("u=other", "p=otherpass"))
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("does not block the same username from another IP", func() {
|
|
failTimes(3, "u=admin", "p=WRONG")
|
|
|
|
r := newGetRequest("u=admin", "p=wordpass")
|
|
r.RemoteAddr = "198.51.100.7:1234"
|
|
serve(r)
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("does not limit reverse proxy authentication", func() {
|
|
conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
|
|
conf.Server.ExtAuth.UserHeader = "Remote-User"
|
|
failTimes(3, "u=admin", "p=WRONG")
|
|
|
|
r := newGetRequest()
|
|
r.Header.Add("Remote-User", "admin")
|
|
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1"))
|
|
serve(r)
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("throttles a repeated bad key without locking out valid keys from the same IP", func() {
|
|
usr, _ := ds.User().FindByUsername(ctx, "admin")
|
|
playerRepo := ds.Player().(*tests.MockPlayerRepo)
|
|
Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed())
|
|
key := "nds_0123456789abcdefghijkl"
|
|
Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed())
|
|
|
|
for range 3 {
|
|
Expect(serve(newGetRequest("apiKey=nds_bad")).Body.String()).To(ContainSubstring(`code="44"`))
|
|
}
|
|
playerRepo.APIKeys["nds_bad"] = "player-1"
|
|
rec := serve(newGetRequest("apiKey=nds_bad"))
|
|
Expect(next.called).To(BeFalse())
|
|
Expect(rec.Body.String()).To(ContainSubstring(`code="44"`))
|
|
|
|
serve(newGetRequest("apiKey=" + key))
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("is disabled when AuthRequestLimit is 0", func() {
|
|
conf.Server.AuthRequestLimit = 0
|
|
cp = authenticate(ds)(next)
|
|
failTimes(10, "u=admin", "p=WRONG")
|
|
|
|
serve(newGetRequest("u=admin", "p=wordpass"))
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
When("valid requests overlap", func() {
|
|
var gate *gatedUserRepo
|
|
var gatedDS model.DataStore
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.AuthRequestLimit = 5
|
|
conf.Server.AuthWindowLength = time.Minute
|
|
gate = &gatedUserRepo{
|
|
UserRepository: ds.User(),
|
|
entered: make(chan struct{}, 64),
|
|
proceed: make(chan struct{}),
|
|
}
|
|
gatedDS = &gatedDataStore{DataStore: ds, users: gate}
|
|
})
|
|
|
|
It("lets every valid request through while checks are in flight", func() {
|
|
const burst = 6
|
|
cp := authenticate(gatedDS)(&countingHandler{})
|
|
var passed atomic.Int32
|
|
var wg sync.WaitGroup
|
|
for range burst {
|
|
wg.Go(func() {
|
|
rec := httptest.NewRecorder()
|
|
cp.ServeHTTP(rec, newGetRequest("u=admin", "p=wordpass"))
|
|
if !strings.Contains(rec.Body.String(), `code="40"`) {
|
|
passed.Add(1)
|
|
}
|
|
})
|
|
}
|
|
for range conf.Server.AuthRequestLimit {
|
|
Eventually(gate.entered).Should(Receive())
|
|
}
|
|
close(gate.proceed)
|
|
wg.Wait()
|
|
|
|
Expect(passed.Load()).To(Equal(int32(burst)))
|
|
})
|
|
|
|
It("caps concurrent credential checks for wrong passwords", func() {
|
|
cp := authenticate(gatedDS)(&countingHandler{})
|
|
var wg sync.WaitGroup
|
|
for i := range 100 {
|
|
wg.Go(func() {
|
|
cp.ServeHTTP(httptest.NewRecorder(), newGetRequest("u=admin", fmt.Sprintf("p=wrong%d", i)))
|
|
})
|
|
}
|
|
|
|
limit := int32(conf.Server.AuthRequestLimit)
|
|
Eventually(gate.lookups.Load).Should(Equal(limit))
|
|
Consistently(gate.lookups.Load, 100*time.Millisecond).Should(Equal(limit))
|
|
close(gate.proceed)
|
|
wg.Wait()
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("AdminOnly", func() {
|
|
It("passes admin users", func() {
|
|
r := newGetRequest()
|
|
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true}))
|
|
|
|
adminOnly(next).ServeHTTP(w, r)
|
|
|
|
Expect(next.called).To(BeTrue())
|
|
})
|
|
|
|
It("rejects non-admin users", func() {
|
|
r := newGetRequest()
|
|
r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false}))
|
|
|
|
adminOnly(next).ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="50"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
|
|
It("returns an internal error when user is missing from context", func() {
|
|
r := newGetRequest()
|
|
|
|
adminOnly(next).ServeHTTP(w, r)
|
|
|
|
Expect(w.Body.String()).To(ContainSubstring(`code="0"`))
|
|
Expect(next.called).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("GetPlayer", func() {
|
|
var mockedPlayers *mockPlayers
|
|
var r *http.Request
|
|
BeforeEach(func() {
|
|
mockedPlayers = &mockPlayers{}
|
|
r = newGetRequest()
|
|
ctx := request.WithUsername(r.Context(), "someone")
|
|
ctx = request.WithClient(ctx, "client")
|
|
r = r.WithContext(ctx)
|
|
})
|
|
|
|
It("returns a new player in the cookies when none is specified", func() {
|
|
gp := getPlayer(mockedPlayers)(next)
|
|
gp.ServeHTTP(w, r)
|
|
|
|
cookieStr := w.Header().Get("Set-Cookie")
|
|
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone")))
|
|
})
|
|
|
|
It("does not add the cookie if there was an error", func() {
|
|
ctx := request.WithClient(r.Context(), "error")
|
|
r = r.WithContext(ctx)
|
|
|
|
gp := getPlayer(mockedPlayers)(next)
|
|
gp.ServeHTTP(w, r)
|
|
|
|
cookieStr := w.Header().Get("Set-Cookie")
|
|
Expect(cookieStr).To(BeEmpty())
|
|
})
|
|
|
|
Context("player bound by an API key", func() {
|
|
BeforeEach(func() {
|
|
r = r.WithContext(request.WithPlayer(r.Context(), model.Player{ID: "keyed"}))
|
|
gp := getPlayer(mockedPlayers)(next)
|
|
gp.ServeHTTP(w, r)
|
|
})
|
|
|
|
It("uses the key's player", func() {
|
|
Expect(mockedPlayers.touched).To(BeTrue())
|
|
player, _ := request.PlayerFrom(next.req.Context())
|
|
Expect(player.ID).To(Equal("keyed"))
|
|
})
|
|
|
|
It("does not set the player cookie", func() {
|
|
Expect(w.Header().Get("Set-Cookie")).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Context("PlayerId specified in Cookies", func() {
|
|
BeforeEach(func() {
|
|
cookie := &http.Cookie{
|
|
Name: playerIDCookieName("someone"),
|
|
Value: "123",
|
|
MaxAge: consts.CookieExpiry,
|
|
}
|
|
r.AddCookie(cookie)
|
|
|
|
gp := getPlayer(mockedPlayers)(next)
|
|
gp.ServeHTTP(w, r)
|
|
})
|
|
|
|
It("stores the player in the context", func() {
|
|
Expect(next.called).To(BeTrue())
|
|
player, _ := request.PlayerFrom(next.req.Context())
|
|
Expect(player.ID).To(Equal("123"))
|
|
_, ok := request.TranscodingFrom(next.req.Context())
|
|
Expect(ok).To(BeFalse())
|
|
})
|
|
|
|
It("returns the playerId in the cookie", func() {
|
|
cookieStr := w.Header().Get("Set-Cookie")
|
|
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone") + "=123"))
|
|
})
|
|
})
|
|
|
|
Context("Player has transcoding configured", func() {
|
|
BeforeEach(func() {
|
|
cookie := &http.Cookie{
|
|
Name: playerIDCookieName("someone"),
|
|
Value: "123",
|
|
MaxAge: consts.CookieExpiry,
|
|
}
|
|
r.AddCookie(cookie)
|
|
mockedPlayers.transcoding = &model.Transcoding{ID: "12"}
|
|
gp := getPlayer(mockedPlayers)(next)
|
|
gp.ServeHTTP(w, r)
|
|
})
|
|
|
|
It("stores the player in the context", func() {
|
|
player, _ := request.PlayerFrom(next.req.Context())
|
|
Expect(player.ID).To(Equal("123"))
|
|
transcoding, _ := request.TranscodingFrom(next.req.Context())
|
|
Expect(transcoding.ID).To(Equal("12"))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("validateCredentials", func() {
|
|
var usr *model.User
|
|
|
|
BeforeEach(func() {
|
|
ur := ds.User()
|
|
_ = ur.Put(ctx, &model.User{
|
|
UserName: "admin",
|
|
NewPassword: "wordpass",
|
|
})
|
|
|
|
var err error
|
|
usr, err = ur.FindByUsernameWithPassword(ctx, "admin")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
|
|
Context("Plaintext password", func() {
|
|
It("authenticates with plaintext password ", func() {
|
|
err := validateCredentials(usr, "wordpass", "", "", "")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
})
|
|
|
|
It("fails authentication with wrong password", func() {
|
|
err := validateCredentials(usr, "INVALID", "", "", "")
|
|
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
|
})
|
|
})
|
|
|
|
Context("Encoded password", func() {
|
|
It("authenticates with simple encoded password ", func() {
|
|
err := validateCredentials(usr, "enc:776f726470617373", "", "", "")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Context("Token based authentication", func() {
|
|
It("authenticates with token based authentication", func() {
|
|
err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
})
|
|
|
|
It("fails if salt is missing", func() {
|
|
err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "", "")
|
|
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
|
})
|
|
})
|
|
|
|
Context("JWT based authentication", func() {
|
|
var usr *model.User
|
|
var validToken string
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.SessionTimeout = time.Minute
|
|
auth.Init(ds)
|
|
|
|
usr = &model.User{UserName: "admin"}
|
|
var err error
|
|
validToken, err = auth.CreateToken(usr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
|
|
It("authenticates with JWT token based authentication", func() {
|
|
err := validateCredentials(usr, "", "", "", validToken)
|
|
|
|
Expect(err).NotTo(HaveOccurred())
|
|
})
|
|
|
|
It("fails if JWT token is invalid", func() {
|
|
err := validateCredentials(usr, "", "", "", "invalid.token")
|
|
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
|
})
|
|
|
|
It("fails if JWT token sub is different than username", func() {
|
|
u := &model.User{UserName: "hacker"}
|
|
validToken, _ = auth.CreateToken(u)
|
|
err := validateCredentials(usr, "", "", "", validToken)
|
|
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
|
})
|
|
})
|
|
|
|
Context("JWT credentials", func() {
|
|
var usr *model.User
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.SessionTimeout = time.Minute
|
|
auth.Init(ds)
|
|
usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1}
|
|
})
|
|
|
|
It("accepts an unscoped session token", func() {
|
|
tokenStr, err := auth.CreateToken(usr)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed())
|
|
})
|
|
|
|
It("rejects a jellyfin-scoped token", func() {
|
|
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth))
|
|
})
|
|
|
|
It("rejects a token with a stale epoch", func() {
|
|
tokenStr, err := auth.CreateToken(usr)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
usr.TokenEpoch = 2
|
|
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth))
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
type mockHandler struct {
|
|
req *http.Request
|
|
called bool
|
|
}
|
|
|
|
func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
mh.req = r
|
|
mh.called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("OK"))
|
|
}
|
|
|
|
type mockPlayers struct {
|
|
core.Players
|
|
transcoding *model.Transcoding
|
|
touched bool
|
|
}
|
|
|
|
func (mp *mockPlayers) Touch(_ context.Context, plr model.Player, _, _, _ string) (*model.Player, *model.Transcoding, error) {
|
|
mp.touched = true
|
|
return &plr, mp.transcoding, nil
|
|
}
|
|
|
|
func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) {
|
|
return &model.Player{ID: playerId}, nil
|
|
}
|
|
|
|
func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
|
|
if client == "error" {
|
|
return nil, nil, errors.New(client)
|
|
}
|
|
return &model.Player{ID: id}, mp.transcoding, nil
|
|
}
|
|
|
|
type gatedDataStore struct {
|
|
model.DataStore
|
|
users model.UserRepository
|
|
}
|
|
|
|
func (g *gatedDataStore) User() model.UserRepository { return g.users }
|
|
|
|
type gatedUserRepo struct {
|
|
model.UserRepository
|
|
entered chan struct{}
|
|
proceed chan struct{}
|
|
lookups atomic.Int32
|
|
}
|
|
|
|
func (g *gatedUserRepo) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) {
|
|
g.lookups.Add(1)
|
|
g.entered <- struct{}{}
|
|
<-g.proceed
|
|
return g.UserRepository.FindByUsernameWithPassword(ctx, username)
|
|
}
|
|
|
|
type countingHandler struct{ calls atomic.Int32 }
|
|
|
|
func (c *countingHandler) ServeHTTP(http.ResponseWriter, *http.Request) { c.calls.Add(1) }
|