package httpx import ( "context" "errors" "strconv" "strings" "github.com/gin-gonic/gin" authentity "spark-server/internal/module/auth/entity" authlogic "spark-server/internal/module/auth/logic" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/ratelimit" "spark-server/internal/pkg/reqctx" ) const ( headerAuthorization = "Authorization" headerDeviceID = "X-Device-ID" headerAppVersion = "X-App-Version" headerLanguage = "Accept-Language" headerXLanguage = "X-Language" headerRealIP = "X-Real-IP" headerForwardedFor = "X-Forwarded-For" bearerPrefix = "bearer " ) func Auth(svc authlogic.Service) gin.HandlerFunc { return func(c *gin.Context) { if svc == nil { c.Next() return } if publicPath(c.Request.URL.Path) { if optionalAuthPath(c.Request.URL.Path) { if err := tryOptionalAuth(c, svc); err != nil { abortAuth(c, err) return } } c.Next() return } token, ok := bearerToken(c.GetHeader(headerAuthorization)) if !ok { abortAuth(c, errs.ErrUnauthorized) return } principal, err := svc.Authenticate(c.Request.Context(), token) if err != nil { abortAuth(c, err) return } if err := matchDeviceHeader(c.GetHeader(headerDeviceID), principal.DeviceID); err != nil { abortAuth(c, err) return } applyPrincipal(c, principal) c.Next() } } func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc { return func(c *gin.Context) { if l == nil || !authLimitPath(c.Request.URL.Path) { c.Next() return } ip := reqctx.RealIP(c.Request.Context()) if !l.Allow(ip) { c.Abort() JSONError(c, errs.ErrRateLimited) return } c.Next() } } func authLimitPath(path string) bool { switch path { case "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", "/v1/auth/password-login", "/v1/auth/password", "/v1/feedback": return true default: return false } } func publicPath(path string) bool { switch path { case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/", "/favicon.ico", "/apple-touch-icon.png": return true case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", "/v1/auth/password-login", "/v1/auth/password", "/v1/legal/agreements", "/v1/feedback", "/v1/subscriptions/plans", "/v1/music/playlist", "/v1/music/categories", "/v1/music/settings", "/v1/developer-apps", "/api/client/pop/developer-apps", "/api/client/spark/developer-apps": return true default: // Admin routes use AdminAuth (Java popadmin JWT), not client Bearer. if adminPath(path) { return true } return strings.HasPrefix(path, "/docs/") } } func bearerToken(h string) (string, bool) { if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) { return "", false } tok := strings.TrimSpace(h[len(bearerPrefix):]) return tok, tok != "" } func matchDeviceHeader(raw string, want uint64) error { if raw == "" { return nil } got, err := strconv.ParseUint(raw, 10, 64) if err != nil || got != want { return errs.ErrUnauthorized } return nil } // optionalAuthPath accepts anonymous calls; Bearer if present must be valid. func optionalAuthPath(path string) bool { switch path { case "/v1/feedback", "/v1/sms/codes": return true default: return false } } func tryOptionalAuth(c *gin.Context, svc authlogic.Service) error { token, ok := bearerToken(c.GetHeader(headerAuthorization)) if !ok { return nil } principal, err := svc.Authenticate(c.Request.Context(), token) if err != nil { return err } if err := matchDeviceHeader(c.GetHeader(headerDeviceID), principal.DeviceID); err != nil { return err } applyPrincipal(c, principal) return nil } func applyPrincipal(c *gin.Context, principal *authentity.Principal) { if principal == nil { return } rc := reqctx.From(c.Request.Context()) if rc == nil { rc = &reqctx.RequestContext{} c.Request = c.Request.WithContext(reqctx.With(c.Request.Context(), rc)) } rc.UserID = principal.UserID rc.DeviceID = principal.DeviceID rc.AppID = principal.AppID rc.JTI = principal.JTI rc.TokenExpires = principal.ExpiresAt } func abortAuth(c *gin.Context, err error) { c.Abort() if clientGone(c, err) { return } JSONError(c, err) } // clientGone is true when the peer closed the connection; writing a JSON body is pointless. func clientGone(c *gin.Context, err error) bool { if errors.Is(c.Request.Context().Err(), context.Canceled) { return true } return errors.Is(err, errs.ErrClosed) || errors.Is(err, context.Canceled) }