From cdf56801cd5cfbd462358e4bdb9a7579c34335cb Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:38:28 +0000 Subject: [PATCH] feat(subsonic): auth middleware (apiKey / t+s / p gated) apiKey (OpenSubsonic) is preferred. t+s uses md5(subsonic_password+salt) with a constant-time compare. p is disabled by default and gated by SubsonicConfig.AllowPlaintextPassword; enc:HEX obfuscation decoded. Auth failures write a Subsonic "failed" envelope so clients don't see HTTP 401. --- internal/subsonic/auth.go | 123 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 internal/subsonic/auth.go diff --git a/internal/subsonic/auth.go b/internal/subsonic/auth.go new file mode 100644 index 00000000..b651588d --- /dev/null +++ b/internal/subsonic/auth.go @@ -0,0 +1,123 @@ +package subsonic + +import ( + "context" + "crypto/md5" + "crypto/subtle" + "encoding/hex" + "errors" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Config controls wire-format concessions the server makes for Subsonic +// clients. At-rest policy (which users have a subsonic_password set) is +// driven per-user; this struct only covers the server-wide opt-ins. +type Config struct { + AllowPlaintextPassword bool +} + +type ctxKey int + +const userCtxKey ctxKey = 1 + +// UserFromContext returns the user attached by Middleware. +func UserFromContext(ctx context.Context) (dbq.User, bool) { + u, ok := ctx.Value(userCtxKey).(dbq.User) + return u, ok +} + +// Middleware authenticates the request against users.api_token (apiKey) or +// users.subsonic_password (t/s or p). On failure it writes a Subsonic failed +// envelope using the request's f= format; downstream handlers never see an +// unauthenticated request. +func Middleware(pool *pgxpool.Pool, cfg Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, code, msg := authenticate(r, pool, cfg) + if code != 0 { + WriteFail(w, r, code, msg) + return + } + ctx := context.WithValue(r.Context(), userCtxKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, int, string) { + q := dbq.New(pool) + params := r.URL.Query() + + if apiKey := params.Get("apiKey"); apiKey != "" { + user, err := q.GetUserByAPIToken(r.Context(), apiKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.User{}, ErrWrongCredentials, "Invalid apiKey" + } + return dbq.User{}, ErrGeneric, "Auth lookup failed" + } + return user, 0, "" + } + + username := params.Get("u") + if username == "" { + return dbq.User{}, ErrMissingParameter, "Missing required parameter: u or apiKey" + } + user, err := q.GetUserByUsername(r.Context(), username) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.User{}, ErrWrongCredentials, "Wrong username or password" + } + return dbq.User{}, ErrGeneric, "Auth lookup failed" + } + + if token, salt := params.Get("t"), params.Get("s"); token != "" && salt != "" { + if user.SubsonicPassword == nil { + return dbq.User{}, ErrTokenNotSupported, "Subsonic token auth not configured; use apiKey or set a Subsonic password" + } + sum := md5.Sum([]byte(*user.SubsonicPassword + salt)) + expected := hex.EncodeToString(sum[:]) + if subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 { + return dbq.User{}, ErrWrongCredentials, "Wrong username or password" + } + return user, 0, "" + } + + if password := params.Get("p"); password != "" { + if !cfg.AllowPlaintextPassword { + return dbq.User{}, ErrTokenNotSupported, "Plaintext password auth is disabled; use apiKey or t+s" + } + if user.SubsonicPassword == nil { + return dbq.User{}, ErrTokenNotSupported, "Subsonic password not configured for this user" + } + decoded, err := decodePassword(password) + if err != nil { + return dbq.User{}, ErrWrongCredentials, "Malformed password" + } + if subtle.ConstantTimeCompare([]byte(*user.SubsonicPassword), []byte(decoded)) != 1 { + return dbq.User{}, ErrWrongCredentials, "Wrong username or password" + } + return user, 0, "" + } + + return dbq.User{}, ErrMissingParameter, "Missing required parameter: p, t+s, or apiKey" +} + +// decodePassword unwraps Subsonic's enc:HEX obfuscation. Non-enc values pass +// through unchanged so the caller can treat the result as the literal secret. +func decodePassword(p string) (string, error) { + if !strings.HasPrefix(p, "enc:") { + return p, nil + } + b, err := hex.DecodeString(p[4:]) + if err != nil { + return "", err + } + return string(b), nil +}