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:
soldosluka857
2026-05-30 07:08:29 +00:00
committed by Shuke
parent 93c9cb7dfb
commit 7cc59f095c
7 changed files with 215 additions and 25 deletions
+12 -2
View File
@@ -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))
}
+11 -5
View File
@@ -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)
}
+27
View File
@@ -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)
+34
View File
@@ -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)
}
}
+53 -17
View File
@@ -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)
+58
View File
@@ -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)
}
}
}
+20 -1
View File
@@ -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,