diff --git a/internal/handler/emby_auth.go b/internal/handler/emby_auth.go index ffe32ac..b531631 100644 --- a/internal/handler/emby_auth.go +++ b/internal/handler/emby_auth.go @@ -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="",不是 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;不是该格式则返回空串。用于兼容小幻影视(RodelPlayer)等 +// 客户端把用户 ID 当作 token 提交的行为。 +// 识别三种来源: +// 1. URL query:X-Emby-Token=UserId="" +// 2. Authorization / X-Emby-Authorization / X-MediaBrowser-Authorization 头: +// Emby UserId="", Client="...", ...(无 Token=) +// 3. X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="" +func userIdDirectToken(c *gin.Context) string { + if c == nil || c.Request == nil { + return "" + } + const prefix = `UserId="` + // 从一段文本中提取 UserId="" 中的 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)) diff --git a/internal/handler/emby_auth_test.go b/internal/handler/emby_auth_test.go index 61d3f72..b88e0cd 100644 --- a/internal/handler/emby_auth_test.go +++ b/internal/handler/emby_auth_test.go @@ -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=""` 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="" 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="" 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) + } +} diff --git a/internal/handler/emby_authed_loop_test.go b/internal/handler/emby_authed_loop_test.go new file mode 100644 index 0000000..42e3ae1 --- /dev/null +++ b/internal/handler/emby_authed_loop_test.go @@ -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()) + } +} \ No newline at end of file diff --git a/internal/handler/emby_playback.go b/internal/handler/emby_playback.go index 89a45a9..52e6387 100644 --- a/internal/handler/emby_playback.go +++ b/internal/handler/emby_playback.go @@ -113,11 +113,28 @@ func embyTokenFromAuthHeader(value string) string { if value == "" { return "" } + // 优先提取 Token="..." 引号内的纯 token。RodelPlayer 等客户端会把 + // UserId 和 Token 一起放进同一个 Emby/MediaBrowser 头里,例如 + // `Emby UserId="..", Client="..", Token=""`。此时必须取 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 { diff --git a/internal/middleware/emby_auth.go b/internal/middleware/emby_auth.go index 8d17bab..b9130dd 100644 --- a/internal/middleware/emby_auth.go +++ b/internal/middleware/emby_auth.go @@ -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=""`。此时必须取 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 diff --git a/internal/middleware/emby_auth_test.go b/internal/middleware/emby_auth_test.go index 9777558..2ae180c 100644 --- a/internal/middleware/emby_auth_test.go +++ b/internal/middleware/emby_auth_test.go @@ -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="", Client="RodelPlayer", Device="WHILETRUE", +// DeviceId="...", Version="2.2607.7.0", Token="" +// +// 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) + } +}