spark-server/internal/transport/http/auth.go

135 lines
3.1 KiB
Go

package httpx
import (
"context"
"errors"
"strconv"
"strings"
"github.com/gin-gonic/gin"
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 || publicPath(c.Request.URL.Path) {
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
}
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.JTI = principal.JTI
rc.TokenExpires = principal.ExpiresAt
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/email-codes", "/v1/auth/token/refresh":
return true
default:
return false
}
}
func publicPath(path string) bool {
switch path {
case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/":
return true
case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh":
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
}
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)
}