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:
zkvvoob 2026-05-20 13:51:55 +03:00
parent 1353243126
commit 42e4068c52
No known key found for this signature in database
GPG Key ID: 3CBAEDB5B3509ECE
2 changed files with 170 additions and 42 deletions

View File

@ -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 {

View File

@ -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