mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix(emby): extract pure JWT from combined UserId+Token auth header (RodelPlayer 401)
fix(emby): extract pure JWT from combined UserId+Token auth header (RodelPlayer 401)
This commit is contained in:
@@ -42,14 +42,14 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !parsed.Valid || claims.UserID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"Code": 40101,
|
||||
"Message": "Invalid token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err != nil || !parsed.Valid || claims.UserID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"Code": 40101,
|
||||
"Message": "Invalid token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(EmbyCtxUserID, claims.UserID)
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
@@ -83,12 +83,19 @@ func extractEmbyToken(c *gin.Context) string {
|
||||
}
|
||||
|
||||
func tokenFromAuthHeader(value string) string {
|
||||
// 先尝试提取 Token="..." 引号内的纯 token(RodelPlayer 等客户端会把
|
||||
// UserId 和 Token 一起放进同一个 Emby/MediaBrowser 头里,例如
|
||||
// `Emby UserId="..", Client="..", Token="<jwt>"`。此时必须取 Token 引号内的
|
||||
// 纯 JWT,不能取整个头,否则 JWT 解析会因多余杂质失败。
|
||||
if strings.Contains(value, "Token=") {
|
||||
return tokenFromMediaBrowserAuth(value)
|
||||
}
|
||||
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(value, "MediaBrowser ") || strings.Contains(value, "Token=") {
|
||||
if strings.HasPrefix(value, "MediaBrowser ") {
|
||||
return tokenFromMediaBrowserAuth(value)
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -29,6 +30,7 @@ func TestEmbyAuthRequiredAcceptsEmbyClientTokenFormats(t *testing.T) {
|
||||
{name: "query api key", query: "?api_key=" + token},
|
||||
{name: "query x emby token", query: "?X-Emby-Token=" + token},
|
||||
{name: "query x mediabrowser token", query: "?X-MediaBrowser-Token=" + token},
|
||||
{name: "query token", query: "?token=" + token},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -68,3 +70,90 @@ func signedTestToken(t *testing.T, secret string) string {
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// TestEmbyAuthRequiredRejectsMediaBrowserHeaderWithoutToken guards against a
|
||||
// subtle parsing bug: some clients (e.g. Hills) send an Authorization header
|
||||
// like `MediaBrowser Client="Hills", DeviceId="..."` that contains no Token=.
|
||||
// The parser must NOT treat the whole header value as the token — otherwise it
|
||||
// is fed to JWT parsing and fails with 40101 "Invalid token" on every request
|
||||
// after a successful login.
|
||||
func TestEmbyAuthRequiredRejectsMediaBrowserHeaderWithoutToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const secret = "test-secret"
|
||||
token := signedTestToken(t, secret)
|
||||
|
||||
// 1) A MediaBrowser header with a Token= must still work.
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Header("X-Emby-Token", token)
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/with/header/token", EmbyAuthRequired(secret), func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/with/header/token", nil)
|
||||
req.Header.Set("Authorization", `MediaBrowser Client="Hills", Token="`+token+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 with Token=, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 2) A MediaBrowser header WITHOUT Token= must be treated as "no token",
|
||||
// i.e. the request must fail with 40101 (Unauthorized), NOT "Invalid token".
|
||||
req = httptest.NewRequest(http.MethodGet, "/with/header/token", nil)
|
||||
req.Header.Set("Authorization", `MediaBrowser Client="Hills", DeviceId="device-42"`)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for no-token MediaBrowser header, got %d", w.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if msg, _ := body["Message"].(string); msg != "Unauthorized" {
|
||||
t.Fatalf("expected Message=Unauthorized for no-token header, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyAuthRequiredExtractsTokenFromEmbyHeaderWithUserId covers the real
|
||||
// RodelPlayer / 小幻影视 request shape: all credentials live in a single
|
||||
// X-Emby-Authorization header that carries BOTH UserId and Token:
|
||||
//
|
||||
// Emby UserId="<uuid>", Client="RodelPlayer", Device="WHILETRUE",
|
||||
// DeviceId="...", Version="2.2607.7.0", Token="<jwt>"
|
||||
//
|
||||
// The parser must extract the pure JWT from Token="...", NOT the whole header
|
||||
// value — otherwise JWT parsing fails with 40101 "Invalid token".
|
||||
func TestEmbyAuthRequiredExtractsTokenFromEmbyHeaderWithUserId(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const secret = "test-secret"
|
||||
token := signedTestToken(t, secret)
|
||||
|
||||
// 1) Full RodelPlayer-style header with both UserId and Token must pass.
|
||||
router := gin.New()
|
||||
router.GET("/with/header/token", EmbyAuthRequired(secret), func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/with/header/token", nil)
|
||||
req.Header.Set("X-Emby-Authorization",
|
||||
`Emby UserId="23d5bec4-9ffc-4178-be9b-f5496f8b1b54", Client="RodelPlayer", Device="WHILETRUE", DeviceId="7aacf5c9-815a-4e9c-8b26-6a842da9fbd4", Version="2.2607.7.0", Token="`+token+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 with Emby UserId+Token header, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 2) The same header WITHOUT Token= (UserId only) must NOT be treated as a
|
||||
// token — it should fail with 40101 "Unauthorized" (no token), not
|
||||
// "Invalid token" from misparsed JWT.
|
||||
req = httptest.NewRequest(http.MethodGet, "/with/header/token", nil)
|
||||
req.Header.Set("X-Emby-Authorization",
|
||||
`Emby UserId="23d5bec4-9ffc-4178-be9b-f5496f8b1b54", Client="RodelPlayer", Device="WHILETRUE", DeviceId="7aacf5c9-815a-4e9c-8b26-6a842da9fbd4"`)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for Emby UserId-only header, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user