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 }