mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(subsonic): accept app passwords as Subsonic credentials
Falls back to validating against the user's active app passwords when the primary password match fails and no JWT is presented. Supports both p= (plaintext / enc:) and t=/s= (token+salt md5) shapes, uses crypto/subtle for constant-time comparison, and updates last_used_at asynchronously after a successful match. Lets Subsonic clients (DSub, play:Sub, Symfonium, Feishin, etc.) authenticate with named per-application secrets instead of the primary account password.
This commit is contained in:
parent
1353243126
commit
42e4068c52
@ -4,6 +4,7 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -138,6 +139,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
default:
|
||||
err = validateCredentials(usr, pass, token, salt, jwt)
|
||||
if err != nil && jwt == "" {
|
||||
err = validateAppPasswordCredentials(ctx, ds, usr, pass, token, salt)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
}
|
||||
@ -155,21 +159,51 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// validateAppPasswordCredentials is the Subsonic-side fallback for users who
|
||||
// authenticate with a per-application secondary password instead of their
|
||||
// primary one. It only kicks in when the request is NOT presenting a JWT
|
||||
// (those are exclusively for Navidrome-issued tokens).
|
||||
//
|
||||
// Both p= (plaintext / "enc:" hex-encoded) and t=/s= (md5(secret+salt)) auth
|
||||
// shapes are supported. Comparisons use crypto/subtle to avoid leaking
|
||||
// whether a username has any active app passwords through timing side
|
||||
// channels.
|
||||
//
|
||||
// On success, last_used_at is bumped asynchronously using a context that
|
||||
// outlives the HTTP request — the user-facing response has already been
|
||||
// committed by the time the UPDATE returns.
|
||||
func validateAppPasswordCredentials(ctx context.Context, ds model.DataStore, user *model.User, pass, token, salt string) error {
|
||||
aps, err := ds.AppPassword(ctx).GetActiveForUser(ctx, user.ID)
|
||||
if err != nil || len(aps) == 0 {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
|
||||
if !loggedUser.IsAdmin {
|
||||
sendError(w, r, newError(responses.ErrorAuthorizationFail))
|
||||
return
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
pass = string(dec)
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
for _, ap := range aps {
|
||||
if ap.Secret == "" {
|
||||
continue
|
||||
}
|
||||
var matches bool
|
||||
switch {
|
||||
case pass != "":
|
||||
matches = subtle.ConstantTimeCompare([]byte(pass), []byte(ap.Secret)) == 1
|
||||
case token != "":
|
||||
t := fmt.Sprintf("%x", md5.Sum([]byte(ap.Secret+salt)))
|
||||
matches = subtle.ConstantTimeCompare([]byte(t), []byte(token)) == 1
|
||||
}
|
||||
if matches {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
id := ap.ID
|
||||
go func() { _ = ds.AppPassword(asyncCtx).UpdateLastUsedAt(asyncCtx, id) }()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
|
||||
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
|
||||
|
||||
@ -251,6 +251,130 @@ var _ = Describe("Middlewares", func() {
|
||||
})
|
||||
})
|
||||
|
||||
When("using app password authentication", func() {
|
||||
var (
|
||||
appRepo *tests.MockAppPasswordRepo
|
||||
usedCh chan string
|
||||
userID string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// The primary password remains "wordpass"; an app password
|
||||
// for the same user is "app-secret". The fallback path is only
|
||||
// hit when validateCredentials fails (i.e. p != "wordpass"),
|
||||
// so every test below uses "app-secret" or its derivatives.
|
||||
existing, err := ds.User(context.TODO()).FindByUsername("admin")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
userID = existing.ID
|
||||
|
||||
usedCh = make(chan string, 1)
|
||||
appRepo = &tests.MockAppPasswordRepo{
|
||||
Active: model.AppPasswords{
|
||||
{
|
||||
ID: "ap-active",
|
||||
UserID: userID,
|
||||
Name: "iOS",
|
||||
Secret: "app-secret",
|
||||
},
|
||||
},
|
||||
UpdateLastUsedAtFn: func(id string) {
|
||||
usedCh <- id
|
||||
},
|
||||
}
|
||||
ds.(*tests.MockDataStore).MockedAppPassword = appRepo
|
||||
})
|
||||
|
||||
It("authenticates with the plaintext app password via p=", func() {
|
||||
r := newGetRequest("u=admin", "p=app-secret")
|
||||
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"))
|
||||
Eventually(usedCh).Should(Receive(Equal("ap-active")))
|
||||
})
|
||||
|
||||
It("authenticates with the hex-encoded app password via enc:", func() {
|
||||
// hex("app-secret") = 6170702d736563726574
|
||||
r := newGetRequest("u=admin", "p=enc:6170702d736563726574")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
Eventually(usedCh).Should(Receive(Equal("ap-active")))
|
||||
})
|
||||
|
||||
It("authenticates with md5(secret+salt) via t=/s=", func() {
|
||||
salt := "abcdef"
|
||||
token := fmt.Sprintf("%x", md5.Sum([]byte("app-secret"+salt)))
|
||||
r := newGetRequest("u=admin", "t="+token, "s="+salt)
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
Eventually(usedCh).Should(Receive(Equal("ap-active")))
|
||||
})
|
||||
|
||||
It("rejects a request whose p= matches no primary nor app password", func() {
|
||||
r := newGetRequest("u=admin", "p=nope")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
Consistently(usedCh).ShouldNot(Receive())
|
||||
})
|
||||
|
||||
It("rejects when the user has no active app passwords", func() {
|
||||
appRepo.Active = nil
|
||||
r := newGetRequest("u=admin", "p=app-secret")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
Consistently(usedCh).ShouldNot(Receive())
|
||||
})
|
||||
|
||||
It("falls through to the existing-password check when only the primary password matches", func() {
|
||||
// p=wordpass would succeed at validateCredentials; the
|
||||
// fallback should not even be queried.
|
||||
appRepo.Active = nil
|
||||
appRepo.GetActiveErr = errors.New("repo should not be queried")
|
||||
r := newGetRequest("u=admin", "p=wordpass")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not bump last_used_at on failed auth", func() {
|
||||
r := newGetRequest("u=admin", "p=app-secret-wrong")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeFalse())
|
||||
Consistently(usedCh).ShouldNot(Receive())
|
||||
})
|
||||
|
||||
It("does not run the app-password fallback for JWT auth", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
auth.Init(ds)
|
||||
|
||||
// A valid app secret presented via the jwt= param must not
|
||||
// authenticate — the fallback is gated on jwt == "".
|
||||
appRepo.GetActiveErr = errors.New("fallback must not run for jwt requests")
|
||||
r := newGetRequest("u=admin", "jwt=app-secret")
|
||||
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())
|
||||
@ -308,36 +432,6 @@ var _ = Describe("Middlewares", func() {
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user