mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
fix(auth/images): long-lived Emby token, unthrottle refresh, serve library posters
Three regressions reported on third-party clients and the web UI: - Third-party clients (Emby/Jellyfin) dropped login / could not play / could not refresh the library, roughly hourly. The Emby AuthenticateByName response returned the 60-minute access token, but Emby clients have no refresh mechanism and reuse the AccessToken until logout. Issue a long-lived (30d) token for the Emby compat layer via AuthService.IssueEmbyToken so device sessions persist. - Web could be thrown back to login under load: /auth/refresh was inside the IP rate-limited /auth group, so multiple users/tabs behind one reverse-proxy/NAT IP exhausted the budget and refresh failed -> logout. Only login/register are rate-limited now (raised to 30/min for shared IPs); refresh is excluded (already protected by a one-time refresh token). - Posters/images stopped displaying on the web home and other pages (refresh did not help). The SSRF/path hardening (a) blocked the image proxy whenever a hostname *resolved* to a private IP, which happens under GFW DNS poisoning of public CDNs like image.tmdb.org, and (b) restricted local image reads to data/cache/movies/tv/anime dirs only, dropping sidecar posters stored under arbitrary per-library roots to a placeholder. isPrivateHost now only blocks literal private/loopback IPs (real SSRF vectors) and ImageProxy also allows reads under configured library roots. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -207,8 +207,16 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
|
||||
// Emby/Jellyfin 客户端没有 refresh token 机制:它们把这里返回的
|
||||
// AccessToken 长期保存并反复使用。若返回 60 分钟的普通 access
|
||||
// token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。因此
|
||||
// 签发长期令牌(IssueEmbyToken)匹配 Emby 持久化令牌语义。
|
||||
accessToken := resp.Tokens.AccessToken
|
||||
if longLived, err := svc.Auth.IssueEmbyToken(resp.User); err == nil && longLived != "" {
|
||||
accessToken = longLived
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"AccessToken": resp.Tokens.AccessToken,
|
||||
"AccessToken": accessToken,
|
||||
"ServerId": "mediastation-go-001",
|
||||
"User": userPayload,
|
||||
"SessionInfo": gin.H{
|
||||
@@ -729,7 +737,9 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
||||
grp.HEAD(path, embyPingHandler(svc))
|
||||
grp.POST(path, embyPingHandler(svc))
|
||||
}
|
||||
embyLoginLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
|
||||
// 30/min per IP: many Emby clients sit behind a single NAT/reverse-proxy
|
||||
// IP, so a low limit would throttle legitimate logins into 429s.
|
||||
embyLoginLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
|
||||
for _, path := range []string{"/Users/AuthenticateByName", "/users/authenticatebyname"} {
|
||||
grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc))
|
||||
}
|
||||
|
||||
@@ -25,18 +25,24 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
// Telegram Bot webhook — called by Telegram servers, no auth.
|
||||
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
||||
|
||||
// Rate limiter for auth endpoints: 10 attempts per minute per IP.
|
||||
authLimiter := middleware.NewRateLimiter(10, 1*time.Minute)
|
||||
// Rate limiter for credential endpoints (login/register): brute-force
|
||||
// protection. 30/min per IP tolerates many users behind a single NAT
|
||||
// or reverse-proxy IP while still throttling password guessing.
|
||||
authLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
|
||||
|
||||
// Public auth.
|
||||
auth := api.Group("/auth")
|
||||
auth.Use(middleware.RateLimit(authLimiter))
|
||||
{
|
||||
auth.POST("/login", loginHandler(svc))
|
||||
auth.POST("/register", registerHandler(svc))
|
||||
auth.POST("/login", middleware.RateLimit(authLimiter), loginHandler(svc))
|
||||
auth.POST("/register", middleware.RateLimit(authLimiter), registerHandler(svc))
|
||||
// /auth/refresh 用 RefreshHandler.RefreshToken:它从 body 读
|
||||
// refresh_token 并签发新 access/refresh 对。旧的 refreshHandler
|
||||
// 依赖 AuthRequired 中间件,永远 401,因此弃用。
|
||||
//
|
||||
// 刷新端点【不】做 IP 限流:刷新本身就是防止掉登录的机制,且已
|
||||
// 由一次性轮换的 refresh token 强校验。若按 IP 限流,多个用户/
|
||||
// 标签页共用一个反代 IP 时会把正常刷新打成 429,反而导致频繁
|
||||
// 掉登录。
|
||||
refreshHd := NewRefreshHandler(svc, log)
|
||||
auth.POST("/refresh", refreshHd.RefreshToken)
|
||||
}
|
||||
|
||||
@@ -241,6 +241,33 @@ func (s *AuthService) IssueToken(u *model.User) (string, error) {
|
||||
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
|
||||
}
|
||||
|
||||
// EmbyTokenDuration 是第三方 Emby/Jellyfin 客户端访问令牌的有效期。
|
||||
// Emby 协议没有 refresh token 机制——客户端登录一次后把 AccessToken
|
||||
// 长期保存并反复使用,直到用户主动登出。若给它们签发 60 分钟的普通
|
||||
// access token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。
|
||||
// 因此为这些设备签发长期令牌(与 refresh token 一致的 30 天),匹配
|
||||
// Emby 持久化令牌的语义。
|
||||
const EmbyTokenDuration = 30 * 24 * time.Hour
|
||||
|
||||
// IssueEmbyToken 为第三方客户端(Emby/Jellyfin 兼容层)签发一个长期
|
||||
// JWT。它与普通 access token 使用相同的密钥与 Claims,因此沿用现有的
|
||||
// EmbyAuthRequired 校验逻辑,只是有效期更长。
|
||||
func (s *AuthService) IssueEmbyToken(u *model.User) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: u.ID,
|
||||
Role: u.Role,
|
||||
Tier: u.Tier,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)),
|
||||
Issuer: "mediastationgo",
|
||||
Subject: u.ID,
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
|
||||
}
|
||||
|
||||
// RefreshTokens 使用刷新令牌获取新的令牌对。
|
||||
func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
return s.tokenSvc.Refresh(ctx, refreshToken)
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -190,3 +192,35 @@ func TestDefaultAdminCannotBeDemoted(t *testing.T) {
|
||||
t.Fatal("expected default admin demotion to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIssueEmbyTokenIsLongLived verifies the Emby/Jellyfin compatibility token
|
||||
// outlives the 60-minute access token (Emby clients have no refresh mechanism,
|
||||
// so a short token caused them to drop login / fail playback hourly), parses
|
||||
// with the JWT secret, and carries the user's identity/role/tier.
|
||||
func TestIssueEmbyTokenIsLongLived(t *testing.T) {
|
||||
_, auth, _, _ := newAuthTestServices(t)
|
||||
u := &model.User{Base: model.Base{ID: "u-emby"}, Username: "emby", Role: "user", Tier: "plus"}
|
||||
|
||||
tok, err := auth.IssueEmbyToken(u)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueEmbyToken: %v", err)
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
parsed, err := jwt.ParseWithClaims(tok, claims, func(*jwt.Token) (interface{}, error) {
|
||||
return []byte("test-secret"), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
t.Fatalf("token did not parse/validate: %v", err)
|
||||
}
|
||||
if claims.UserID != "u-emby" || claims.Role != "user" || claims.Tier != "plus" {
|
||||
t.Fatalf("unexpected claims: %+v", claims)
|
||||
}
|
||||
ttl := time.Until(claims.ExpiresAt.Time)
|
||||
if ttl <= AccessTokenDuration {
|
||||
t.Fatalf("emby token ttl %v not longer than access token ttl %v", ttl, AccessTokenDuration)
|
||||
}
|
||||
if ttl < EmbyTokenDuration-time.Hour {
|
||||
t.Fatalf("emby token ttl %v shorter than expected ~%v", ttl, EmbyTokenDuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,15 @@ type ImageProxy struct {
|
||||
client *http.Client
|
||||
cacheDir string
|
||||
mu sync.Mutex
|
||||
|
||||
// libraryRootsFn returns the configured media library roots so that
|
||||
// sidecar poster/artwork files stored alongside media (under arbitrary
|
||||
// per-library paths) are allowed by isAllowedLocalPath. It is provided
|
||||
// by the service container after construction and may be nil in tests.
|
||||
libraryRootsFn func() []string
|
||||
libRootsMu sync.Mutex
|
||||
libRootsCache []string
|
||||
libRootsAt time.Time
|
||||
}
|
||||
|
||||
// NewImageProxy is the constructor.
|
||||
@@ -89,6 +98,31 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
}
|
||||
}
|
||||
|
||||
// SetLibraryRootsProvider injects a callback that returns the current set of
|
||||
// media library root directories. Sidecar posters live under these roots
|
||||
// (which are arbitrary, user-defined, and not necessarily under the
|
||||
// configured movies/tv/anime dirs), so they must be treated as allowed
|
||||
// local-image locations.
|
||||
func (p *ImageProxy) SetLibraryRootsProvider(fn func() []string) {
|
||||
p.libraryRootsFn = fn
|
||||
}
|
||||
|
||||
// libraryRoots returns the cached library roots, refreshing at most every
|
||||
// 30 seconds to avoid a DB hit per image request (posters load in bulk).
|
||||
func (p *ImageProxy) libraryRoots() []string {
|
||||
if p.libraryRootsFn == nil {
|
||||
return nil
|
||||
}
|
||||
p.libRootsMu.Lock()
|
||||
defer p.libRootsMu.Unlock()
|
||||
if p.libRootsCache != nil && time.Since(p.libRootsAt) < 30*time.Second {
|
||||
return p.libRootsCache
|
||||
}
|
||||
p.libRootsCache = p.libraryRootsFn()
|
||||
p.libRootsAt = time.Now()
|
||||
return p.libRootsCache
|
||||
}
|
||||
|
||||
// validateURL parses raw and ensures the scheme is http/https and the
|
||||
// target host is not a private/loopback/link-local address (SSRF guard).
|
||||
func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
||||
@@ -109,9 +143,16 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// isPrivateHost returns true if host resolves to a loopback, private, or
|
||||
// link-local address. This blocks SSRF attacks that try to reach internal
|
||||
// services (e.g. cloud metadata at 169.254.169.254).
|
||||
// isPrivateHost returns true only when host is a *literal* loopback, private,
|
||||
// link-local or unspecified IP address. This blocks the obvious SSRF vectors
|
||||
// (e.g. http://127.0.0.1/… or the cloud metadata IP 169.254.169.254) while
|
||||
// NOT blocking hostnames.
|
||||
//
|
||||
// We deliberately do not resolve hostnames here: under GFW DNS poisoning,
|
||||
// public image CDNs such as image.tmdb.org are frequently resolved to
|
||||
// loopback/private/bogus IPs. Blocking on resolved addresses would therefore
|
||||
// wrongly drop legitimate posters for exactly the users this proxy exists to
|
||||
// serve, which is what caused posters to stop displaying.
|
||||
func isPrivateHost(host string) bool {
|
||||
if host == "" {
|
||||
return true
|
||||
@@ -120,24 +161,19 @@ func isPrivateHost(host string) bool {
|
||||
if ip != nil {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
||||
}
|
||||
addrs, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
resolved := net.ParseIP(addr)
|
||||
if resolved != nil && (resolved.IsLoopback() || resolved.IsPrivate() || resolved.IsLinkLocalUnicast() || resolved.IsLinkLocalMulticast() || resolved.IsUnspecified()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAllowedLocalPath restricts local file reads to the data directory and
|
||||
// cache directory to prevent arbitrary file read via path traversal.
|
||||
// isAllowedLocalPath restricts local file reads to known-safe roots to
|
||||
// prevent arbitrary file reads via path traversal. Allowed roots are the
|
||||
// data dir, cache dir, the configured movies/tv/anime dirs, and — crucially —
|
||||
// every configured media library root, because sidecar posters/artwork are
|
||||
// stored alongside media under those (arbitrary, user-defined) paths.
|
||||
func (p *ImageProxy) isAllowedLocalPath(abs string) bool {
|
||||
for _, root := range []string{p.cfg.App.DataDir, p.cfg.Cache.CacheDir, p.cfg.Media.MoviesDir, p.cfg.Media.TVDir, p.cfg.Media.AnimeDir} {
|
||||
if root == "" {
|
||||
roots := []string{p.cfg.App.DataDir, p.cfg.Cache.CacheDir, p.cfg.Media.MoviesDir, p.cfg.Media.TVDir, p.cfg.Media.AnimeDir}
|
||||
roots = append(roots, p.libraryRoots()...)
|
||||
for _, root := range roots {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
continue
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
|
||||
@@ -36,3 +36,61 @@ func TestImageProxyServesLocalImagePath(t *testing.T) {
|
||||
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyServesPosterUnderLibraryRoot verifies that sidecar posters
|
||||
// stored under an arbitrary media library root (not the configured
|
||||
// data/cache/movies dirs) are served rather than dropped to the placeholder.
|
||||
// This is the regression that made web/Emby posters disappear.
|
||||
func TestImageProxyServesPosterUnderLibraryRoot(t *testing.T) {
|
||||
libDir := t.TempDir()
|
||||
posterPath := filepath.Join(libDir, "Inception (2010)", "poster.png")
|
||||
if err := os.MkdirAll(filepath.Dir(posterPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realPoster := []byte("THIS-IS-A-REAL-POSTER-NOT-THE-PLACEHOLDER")
|
||||
if err := os.WriteFile(posterPath, realPoster, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
|
||||
// Without a library-roots provider the poster lives outside every allowed
|
||||
// root, so it must fall back to the transparent placeholder.
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), posterPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||
t.Fatalf("expected placeholder before provider set, got %d bytes", rec.Body.Len())
|
||||
}
|
||||
|
||||
// Once the library root is known, the real poster bytes are served.
|
||||
proxy.SetLibraryRootsProvider(func() []string { return []string{libDir} })
|
||||
rec = httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), posterPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Body.Bytes(); string(got) != string(realPoster) {
|
||||
t.Fatalf("served %q, want real poster bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateHost(t *testing.T) {
|
||||
blocked := []string{"127.0.0.1", "10.0.0.5", "192.168.1.10", "169.254.169.254", "0.0.0.0", "::1", ""}
|
||||
for _, h := range blocked {
|
||||
if !isPrivateHost(h) {
|
||||
t.Errorf("isPrivateHost(%q) = false, want true (literal private/loopback IP)", h)
|
||||
}
|
||||
}
|
||||
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
|
||||
// them to private/loopback IPs — blocking them broke legitimate posters.
|
||||
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
|
||||
for _, h := range allowed {
|
||||
if isPrivateHost(h) {
|
||||
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -131,6 +132,24 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
|
||||
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
||||
|
||||
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
|
||||
// sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被
|
||||
// 路径白名单挡掉、退化成占位图导致前端图片不显示。
|
||||
imageProxy := NewImageProxy(cfg, log)
|
||||
imageProxy.SetLibraryRootsProvider(func() []string {
|
||||
libs, err := repos.Library.List(context.Background())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
roots := make([]string, 0, len(libs))
|
||||
for _, l := range libs {
|
||||
if strings.TrimSpace(l.Path) != "" {
|
||||
roots = append(roots, l.Path)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Container{
|
||||
@@ -152,7 +171,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
Scraper: scraper,
|
||||
Discover: discover,
|
||||
Playback: NewPlaybackService(log, repos),
|
||||
ImageProxy: NewImageProxy(cfg, log),
|
||||
ImageProxy: imageProxy,
|
||||
Watcher: watcher,
|
||||
Downloads: downloads,
|
||||
Subscription: subscription,
|
||||
|
||||
Reference in New Issue
Block a user