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:
truewhile
2026-08-17 23:31:15 +08:00
parent 42ef19b7e5
commit 8071285ff2
6 changed files with 412 additions and 13 deletions
+68
View File
@@ -41,6 +41,16 @@ var embyCompatSessions = struct {
func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
required := middleware.EmbyAuthRequired(secret)
return func(c *gin.Context) {
// 兼容小幻影视等客户端的「UserId 直连」凭据格式:
// token 形如 X-Emby-Token=UserId="<uuid>",不是 JWT。解析出 uuid
// 注入 CtxUserID,交由后续 activeEmbyUserRequired 查库验证用户
// 存在且有效(未禁用/未过期),避免这类客户端每次 401 Invalid token。
if uid := userIdDirectToken(c); uid != "" {
c.Set(middleware.CtxUserID, uid)
c.Set(middleware.EmbyCtxUserID, uid)
c.Next()
return
}
if embyRequestToken(c) == "" {
if token := embyCompatSessionToken(c); token != "" {
c.Request.Header.Set("X-Emby-Token", token)
@@ -50,6 +60,64 @@ func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
}
}
// userIdDirectToken 从 Emby token 来源中识别形如 UserId="<uuid>" 的直连凭据,
// 返回其中的 uuid;不是该格式则返回空串。用于兼容小幻影视(RodelPlayer)等
// 客户端把用户 ID 当作 token 提交的行为。
// 识别三种来源:
// 1. URL query:X-Emby-Token=UserId="<uuid>"
// 2. Authorization / X-Emby-Authorization / X-MediaBrowser-Authorization 头:
// Emby UserId="<uuid>", Client="...", ...(无 Token=)
// 3. X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="<uuid>"
func userIdDirectToken(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
const prefix = `UserId="`
// 从一段文本中提取 UserId="<uuid>" 中的 uuid;不存在则返回空。
extract := func(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
idx := strings.Index(raw, prefix)
if idx < 0 {
return ""
}
rest := raw[idx+len(prefix):]
end := strings.Index(rest, `"`)
if end <= 0 {
return ""
}
uid := strings.TrimSpace(rest[:end])
if len(uid) > 0 && len(uid) <= 64 {
return uid
}
return ""
}
// 1) URL query 参数直传 UserId="..."。
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token", "token", "api_key", "apiKey", "ApiKey"} {
if uid := extract(c.Query(key)); uid != "" {
return uid
}
}
// 2) 认证头中的 Emby/MediaBrowser UserId="..."(无 Token= 的直连凭据)。
for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
if uid := extract(c.GetHeader(header)); uid != "" {
// 仅当该头不是标准 Token= 形式时才当作直连凭据,避免误拦截。
if !strings.Contains(c.GetHeader(header), "Token=") {
return uid
}
}
}
// 3) X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="..."。
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
if uid := extract(c.GetHeader(header)); uid != "" {
return uid
}
}
return ""
}
func embyRealtimeSessionActivity(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
recordEmbySessionActivity(c, svc, embyUserID(c), embyContextUserName(c))
+74
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
@@ -166,3 +167,76 @@ func TestEmbyAuthenticateRecordsMediaBrowserClientInfo(t *testing.T) {
t.Fatalf("device info not parsed from MediaBrowser header: %#v", devices[0])
}
}
// TestEmbyUserIdDirectTokenCompatibility covers clients (e.g. 小幻影视 / Hills)
// that send `X-Emby-Token=UserId="<uuid>"` as the auth credential instead of a
// JWT. The server must accept a valid, non-disabled user id in that format and
// let the request through (user validity is enforced by activeEmbyUserRequired).
func TestEmbyUserIdDirectTokenCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
// :memory: SQLite 每个连接是独立库,必须锁单连接。
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
user, _, err := auth.Register(context.Background(), "viewer", "secret-pass")
if err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Device: service.NewDeviceService(log, repos),
Audit: service.NewAuditService(log, repos),
Permissions: permissions,
})
// 1) UserId="<uuid>" direct credential must pass for a valid user.
req := httptest.NewRequest(http.MethodGet, "/emby/Users/Me?X-Emby-Token="+url.QueryEscape(`UserId="`+user.ID+`"`), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("userId direct token (query) = %d body=%s", w.Code, w.Body.String())
}
// 1b) Emby UserId="<uuid>" in X-Emby-Authorization header (RodelPlayer / 小幻影视 style).
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
req.Header.Set("X-Emby-Authorization", `Emby UserId="`+user.ID+`", Client="RodelPlayer", Device="WHILETRUE", DeviceId="dev-1", Version="2.2607.7.0"`)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("userId direct token (X-Emby-Authorization header) = %d body=%s", w.Code, w.Body.String())
}
// 2) A random / non-existent user id in the same format must be rejected.
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me?X-Emby-Token="+url.QueryEscape(`UserId="does-not-exist"`), nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("bogus userId direct token = %d, want 401", w.Code)
}
// 2b) Non-existent user in X-Emby-Authorization header must be rejected too.
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
req.Header.Set("X-Emby-Authorization", `Emby UserId="does-not-exist", Client="RodelPlayer"`)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("bogus userId direct token (header) = %d, want 401", w.Code)
}
}
+147
View File
@@ -0,0 +1,147 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible
// client performs: POST /Users/AuthenticateByName to obtain an AccessToken,
// then immediately reuses that token to fetch /Users/Me and /Items.
//
// This guards against regressions where login succeeds but the returned token
// fails downstream auth (the "每次登录后立刻 401 Invalid token" report).
func TestEmbyLoginThenAuthedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
tokenSvc := service.NewTokenService(cfg, log, repos)
auth := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Device: service.NewDeviceService(log, repos),
Audit: service.NewAuditService(log, repos),
Permissions: permissions,
})
loginBody := `{"Username":"viewer","Pw":"secret-pass"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var login struct {
AccessToken string `json:"AccessToken"`
}
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil {
t.Fatalf("decode login: %v", err)
}
if login.AccessToken == "" {
t.Fatalf("empty AccessToken from login")
}
// Reuse the returned token against authenticated endpoints.
for _, path := range []string{"/emby/Users/Me", "/emby/Items", "/emby/Library/VirtualFolders"} {
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", login.AccessToken)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s with returned token = %d body=%s", path, w.Code, w.Body.String())
}
}
}
// TestEmbyLoginWithAuthorizationHeaderToken covers clients that place the
// bearer token in Authorization instead of X-Emby-Token.
func TestEmbyLoginWithAuthorizationHeaderToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
// :memory: SQLite 每个连接是独立库,必须锁单连接否则表在不同连接上互相不可见。
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Device: service.NewDeviceService(log, repos),
Audit: service.NewAuditService(log, repos),
Permissions: permissions,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var login struct {
AccessToken string `json:"AccessToken"`
}
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil {
t.Fatalf("decode login: %v", err)
}
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
req.Header.Set("Authorization", "MediaBrowser Token=\""+login.AccessToken+"\"")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Authorization-token request = %d body=%s", w.Code, w.Body.String())
}
}
+18 -4
View File
@@ -113,11 +113,28 @@ func embyTokenFromAuthHeader(value string) string {
if value == "" {
return ""
}
// 优先提取 Token="..." 引号内的纯 token。RodelPlayer 等客户端会把
// UserId 和 Token 一起放进同一个 Emby/MediaBrowser 头里,例如
// `Emby UserId="..", Client="..", Token="<jwt>"`。此时必须取 Token 引号内的
// 纯 JWT,不能取整个头,否则 JWT 解析会因多余杂质失败。
if strings.Contains(value, "Token=") {
return embyTokenFromAuthHeaderTokenPart(value)
}
for _, prefix := range []string{"Bearer ", "Emby "} {
if strings.HasPrefix(value, prefix) {
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
}
}
// 其它格式(例如只带 Client/Device 信息的 MediaBrowser 头)不是令牌,
// 不能整串返回,否则会被当作 JWT 解析导致 "Invalid token"。
if strings.HasPrefix(value, "MediaBrowser ") || strings.HasPrefix(value, "Emby ") {
return ""
}
return value
}
// embyTokenFromAuthHeaderTokenPart 从 "Token=..." 形如的字段中取出引号内的纯 token。
func embyTokenFromAuthHeaderTokenPart(value string) string {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
if !strings.HasPrefix(part, "Token=") {
@@ -126,10 +143,7 @@ func embyTokenFromAuthHeader(value string) string {
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
return strings.Trim(token, `"`)
}
if strings.Contains(value, "Token=") {
return ""
}
return value
return ""
}
func embyAppendAPIKey(raw, token string) string {
+16 -9
View File
@@ -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
+89
View File
@@ -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)
}
}