chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
34
internal/middleware/auth_middleware.go
Normal file
34
internal/middleware/auth_middleware.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
func AuthMiddleware(ctx *gin.Context) {
|
||||
if !authenticateRequest(ctx) {
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func authenticateRequest(ctx *gin.Context) bool {
|
||||
principal, err := services.AuthService.Authenticate(ctx)
|
||||
if err != nil {
|
||||
result := web.JsonError(err)
|
||||
result.Message = i18nx.T(ctx, "error.auth.expired")
|
||||
ctx.JSON(200, result)
|
||||
ctx.Abort()
|
||||
return false
|
||||
}
|
||||
if principal.UserType != enums.UserTypeEmployee {
|
||||
ctx.JSON(200, web.JsonErrorCode(3001, i18nx.T(ctx, "error.e0225")))
|
||||
ctx.Abort()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
27
internal/middleware/chat_middleware.go
Normal file
27
internal/middleware/chat_middleware.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mlogclub/simple/web"
|
||||
)
|
||||
|
||||
func ExternalUserMiddleware(ctx *gin.Context) {
|
||||
channel := services.ChannelService.GetEnabledChannel(ctx)
|
||||
if channel == nil {
|
||||
ctx.JSON(200, httpx.JsonErrorMsg(ctx, "error.e0210"))
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
result, err := services.CustomerSessionService.VerifyRequest(ctx, channel)
|
||||
if err != nil {
|
||||
ctx.JSON(200, web.JsonError(err))
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
services.CustomerSessionService.SetRefreshHeaders(ctx, result)
|
||||
httpx.SetExternalUser(ctx, result.ExternalUser)
|
||||
ctx.Next()
|
||||
}
|
||||
56
internal/middleware/ratelimit_middleware.go
Normal file
56
internal/middleware/ratelimit_middleware.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/ratelimit"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// rateLimitMessageKey is the localized explanation returned with a 429. It
|
||||
// deliberately does not disclose the limit or the remaining budget, which would
|
||||
// only tell a caller how much room they have left.
|
||||
const rateLimitMessageKey = "error.e0354"
|
||||
|
||||
// RateLimit bounds how often one client address may call a single public
|
||||
// endpoint.
|
||||
//
|
||||
// The address comes from ctx.ClientIP(), which is only meaningful because
|
||||
// NewServer configures Gin's trusted proxies and platform before any middleware
|
||||
// is registered. Without that a caller picks their own bucket by setting
|
||||
// X-Forwarded-For, and the limit is decoration.
|
||||
//
|
||||
// A nil limiter allows everything, so "disabled" is expressed by handing out nil
|
||||
// limiters rather than by branching at every call site.
|
||||
//
|
||||
// Rejections are not logged here. requestLogMiddleware already records the path,
|
||||
// status and client address of every request, so a 429 is visible there without
|
||||
// giving a flood a second way to fill the log.
|
||||
func RateLimit(limiter *ratelimit.Limiter) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
allowed, retryAfter := limiter.Allow(ctx.ClientIP())
|
||||
if allowed {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
// Retry-After is a whole number of seconds, so round up: telling a caller
|
||||
// to come back sooner than the window actually resets just earns another
|
||||
// 429. Rounding down and adding one overshoots when the remaining time is
|
||||
// already an exact number of seconds.
|
||||
seconds := int64(retryAfter / time.Second)
|
||||
if time.Duration(seconds)*time.Second < retryAfter {
|
||||
seconds++
|
||||
}
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
ctx.Header("Retry-After", strconv.FormatInt(seconds, 10))
|
||||
httpx.WriteHttpStatusJSON(ctx, http.StatusTooManyRequests, errorsx.InvalidParamI18n(rateLimitMessageKey))
|
||||
ctx.Abort()
|
||||
}
|
||||
}
|
||||
103
internal/middleware/ratelimit_middleware_test.go
Normal file
103
internal/middleware/ratelimit_middleware_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/pkg/ratelimit"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newRateLimitTestEngine(limiter *ratelimit.Limiter) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
app := gin.New()
|
||||
app.POST("/probe", RateLimit(limiter), func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{"success": true})
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func postProbe(app *gin.Engine, clientIP string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/probe", nil)
|
||||
req.RemoteAddr = clientIP + ":52000"
|
||||
app.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRateLimitRefusesAfterTheLimitAndSetsRetryAfter(t *testing.T) {
|
||||
app := newRateLimitTestEngine(ratelimit.New(2, time.Minute))
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
if rec := postProbe(app, "203.0.113.7"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("request %d got status %d, want 200", i, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
rec := postProbe(app, "203.0.113.7")
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("the third request got status %d, want 429", rec.Code)
|
||||
}
|
||||
|
||||
retryAfter := rec.Header().Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
t.Fatal("a 429 without Retry-After gives the caller nothing to act on")
|
||||
}
|
||||
seconds, err := strconv.Atoi(retryAfter)
|
||||
if err != nil {
|
||||
t.Fatalf("Retry-After = %q is not a number of seconds", retryAfter)
|
||||
}
|
||||
// The window is one minute and the requests are microseconds apart, so the
|
||||
// answer is 60 give or take a clock tick. What must not happen is a value
|
||||
// above the window, which would tell the caller to wait longer than needed, or
|
||||
// zero, which would have them retry immediately and collect another 429.
|
||||
if seconds < 1 || seconds > 60 {
|
||||
t.Errorf("Retry-After = %d seconds, want between 1 and the 60 second window", seconds)
|
||||
}
|
||||
|
||||
// The body has to stay a JsonResult, because web/lib/api/client.ts parses the
|
||||
// payload before it looks at response.ok and surfaces payload.message.
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("429 body is not JSON: %v (%s)", err, rec.Body.String())
|
||||
}
|
||||
if body.Success {
|
||||
t.Error("429 body reported success=true")
|
||||
}
|
||||
if body.Message == "" {
|
||||
t.Error("429 body carried no message, so the caller would see a blank error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitIsKeyedByClientAddress(t *testing.T) {
|
||||
app := newRateLimitTestEngine(ratelimit.New(1, time.Minute))
|
||||
|
||||
if rec := postProbe(app, "203.0.113.7"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first request got %d, want 200", rec.Code)
|
||||
}
|
||||
if rec := postProbe(app, "203.0.113.7"); rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second request from the same address got %d, want 429", rec.Code)
|
||||
}
|
||||
// Somebody else must not inherit the first caller's exhaustion. Behind a NAT
|
||||
// this is the difference between a working product and a shared lockout.
|
||||
if rec := postProbe(app, "198.51.100.9"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("a different address got %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitWithNilLimiterAllowsEverything(t *testing.T) {
|
||||
app := newRateLimitTestEngine(nil)
|
||||
for i := 0; i < 50; i++ {
|
||||
if rec := postProbe(app, "203.0.113.7"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("request %d got %d with a nil limiter, want 200", i, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user