mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 20:26: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:
@@ -41,6 +41,16 @@ var embyCompatSessions = struct {
|
|||||||
func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
|
func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
|
||||||
required := middleware.EmbyAuthRequired(secret)
|
required := middleware.EmbyAuthRequired(secret)
|
||||||
return func(c *gin.Context) {
|
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 embyRequestToken(c) == "" {
|
||||||
if token := embyCompatSessionToken(c); token != "" {
|
if token := embyCompatSessionToken(c); token != "" {
|
||||||
c.Request.Header.Set("X-Emby-Token", 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 {
|
func embyRealtimeSessionActivity(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
recordEmbySessionActivity(c, svc, embyUserID(c), embyContextUserName(c))
|
recordEmbySessionActivity(c, svc, embyUserID(c), embyContextUserName(c))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -166,3 +167,76 @@ func TestEmbyAuthenticateRecordsMediaBrowserClientInfo(t *testing.T) {
|
|||||||
t.Fatalf("device info not parsed from MediaBrowser header: %#v", devices[0])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,11 +113,28 @@ func embyTokenFromAuthHeader(value string) string {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return ""
|
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 "} {
|
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||||
if strings.HasPrefix(value, prefix) {
|
if strings.HasPrefix(value, prefix) {
|
||||||
return strings.TrimSpace(strings.TrimPrefix(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, ",") {
|
for _, part := range strings.Split(value, ",") {
|
||||||
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
|
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
|
||||||
if !strings.HasPrefix(part, "Token=") {
|
if !strings.HasPrefix(part, "Token=") {
|
||||||
@@ -126,10 +143,7 @@ func embyTokenFromAuthHeader(value string) string {
|
|||||||
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
|
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
|
||||||
return strings.Trim(token, `"`)
|
return strings.Trim(token, `"`)
|
||||||
}
|
}
|
||||||
if strings.Contains(value, "Token=") {
|
return ""
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func embyAppendAPIKey(raw, token string) string {
|
func embyAppendAPIKey(raw, token string) string {
|
||||||
|
|||||||
@@ -42,14 +42,14 @@ func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
|||||||
return []byte(secret), nil
|
return []byte(secret), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil || !parsed.Valid || claims.UserID == "" {
|
if err != nil || !parsed.Valid || claims.UserID == "" {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
"Code": 40101,
|
"Code": 40101,
|
||||||
"Message": "Invalid token",
|
"Message": "Invalid token",
|
||||||
})
|
})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Set(EmbyCtxUserID, claims.UserID)
|
c.Set(EmbyCtxUserID, claims.UserID)
|
||||||
c.Set(CtxUserID, claims.UserID)
|
c.Set(CtxUserID, claims.UserID)
|
||||||
@@ -83,12 +83,19 @@ func extractEmbyToken(c *gin.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func tokenFromAuthHeader(value string) 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 "} {
|
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||||
if strings.HasPrefix(value, prefix) {
|
if strings.HasPrefix(value, prefix) {
|
||||||
return strings.TrimSpace(strings.TrimPrefix(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 tokenFromMediaBrowserAuth(value)
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -29,6 +30,7 @@ func TestEmbyAuthRequiredAcceptsEmbyClientTokenFormats(t *testing.T) {
|
|||||||
{name: "query api key", query: "?api_key=" + token},
|
{name: "query api key", query: "?api_key=" + token},
|
||||||
{name: "query x emby token", query: "?X-Emby-Token=" + token},
|
{name: "query x emby token", query: "?X-Emby-Token=" + token},
|
||||||
{name: "query x mediabrowser token", query: "?X-MediaBrowser-Token=" + token},
|
{name: "query x mediabrowser token", query: "?X-MediaBrowser-Token=" + token},
|
||||||
|
{name: "query token", query: "?token=" + token},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -68,3 +70,90 @@ func signedTestToken(t *testing.T, secret string) string {
|
|||||||
}
|
}
|
||||||
return token
|
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