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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user