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)
}