覆盖游客缺 contact 失败与登录态 uid/app_version 回填,避免分流逻辑回归。 Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
4.4 KiB
Go
185 lines
4.4 KiB
Go
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":
|
|
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)
|
|
}
|