优化客服云权限安全与访客IP解析
This commit is contained in:
@@ -13,3 +13,4 @@ server/bin/
|
||||
.dev/
|
||||
*.exe
|
||||
*.test
|
||||
.codex_artifacts/
|
||||
|
||||
@@ -126,7 +126,7 @@ chmod +x deploy/scripts/*.sh
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Go 1.22+(仓库 `go.mod` 声明见 `server/go.mod`)
|
||||
- Go 1.26+(与仓库 `go.mod` 和生产构建镜像一致)
|
||||
- Node.js 20+
|
||||
- Docker(数据库与 MinIO)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ DB_SSLMODE=disable
|
||||
|
||||
# ========== JWT(deploy.sh 可自动生成)==========
|
||||
JWT_SECRET=请改成随机长字符串
|
||||
CORS_ALLOW_ORIGINS=https://你的域名
|
||||
|
||||
# ========== MinIO 对象存储 ==========
|
||||
STORAGE_ACCESS_KEY=kefu_minio
|
||||
|
||||
@@ -155,6 +155,12 @@ STORAGE_PUBLIC_BASE_URL=https://kefu.example.com/files
|
||||
|
||||
## HTTPS 其它方式
|
||||
|
||||
### 访客 IP 显示说明
|
||||
|
||||
本地通过 Docker 网桥访问时,访客 IP 可能显示为 `172.19.0.1`;直接在本机开发服务访问时则常见为 `127.0.0.1` 或 `::1`,这两种都表示本地回环/网桥地址,不是公网 IP。
|
||||
|
||||
生产链路由 Caddy → 应用 Nginx → API 时,API 优先读取 `X-Forwarded-For` 最左侧地址,`X-Real-IP` 仅作为回退,因此外部访客会显示其公网地址。请保持示例反代配置中的 `X-Forwarded-For` 转发,不要在边缘代理处清空该请求头。
|
||||
|
||||
任选其一(与上一节「Caddy 共存」二选一即可):
|
||||
|
||||
1. **CLB / CDN** 终结 HTTPS,回源 CVM `HTTP_PORT`(或仍回源 Caddy)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# docker build --build-arg ALPINE_MIRROR=dl-cdn.alpinelinux.org \
|
||||
# --build-arg GOPROXY=https://proxy.golang.org,direct ...
|
||||
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
ARG ALPINE_MIRROR=mirrors.cloud.tencent.com
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
@@ -45,7 +45,7 @@ need_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
err "缺少命令: $1"
|
||||
case "$1" in
|
||||
go) err "请安装 Go 1.22+:https://go.dev/dl/ 或 brew install go" ;;
|
||||
go) err "请安装 Go 1.26+:https://go.dev/dl/ 或 brew install go" ;;
|
||||
node|npm) err "请安装 Node.js 20+:https://nodejs.org/ 或 brew install node" ;;
|
||||
docker) err "请安装 Docker Desktop for Mac:https://www.docker.com/products/docker-desktop/" ;;
|
||||
esac
|
||||
@@ -118,7 +118,7 @@ check_prereqs() {
|
||||
go_major="$(go env GOVERSION 2>/dev/null | sed -E 's/^go([0-9]+).*/\1/' || echo 0)"
|
||||
go_minor="$(go env GOVERSION 2>/dev/null | sed -E 's/^go[0-9]+\.([0-9]+).*/\1/' || echo 0)"
|
||||
if [[ "${go_major:-0}" -lt 1 ]] || { [[ "${go_major:-0}" -eq 1 && "${go_minor:-0}" -lt 22 ]]; }; then
|
||||
warn "建议 Go 1.22+,当前: $(go version 2>/dev/null || echo unknown)"
|
||||
warn "建议 Go 1.26+,当前: $(go version 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
|
||||
local node_major
|
||||
|
||||
@@ -88,6 +88,7 @@ services:
|
||||
DB_NAME: ${DB_NAME:-kefu_cloud}
|
||||
DB_SSLMODE: ${DB_SSLMODE:-disable}
|
||||
JWT_SECRET: ${JWT_SECRET:?请设置 JWT_SECRET}
|
||||
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-*}
|
||||
STORAGE_ENDPOINT: minio:9000
|
||||
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY}
|
||||
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
||||
|
||||
@@ -9,3 +9,4 @@ DB_NAME=kefu_cloud
|
||||
DB_SSLMODE=disable
|
||||
|
||||
JWT_SECRET=kefu-cloud-dev-secret-key
|
||||
CORS_ALLOW_ORIGINS=*
|
||||
|
||||
+18
-1
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -38,6 +39,7 @@ func main() {
|
||||
}
|
||||
|
||||
// 启动 WebSocket Hub
|
||||
ws.SetAllowedOrigins(cfg.AllowedOrigins)
|
||||
go ws.DefaultHub.Run()
|
||||
|
||||
// 设置 Gin 模式
|
||||
@@ -49,10 +51,25 @@ func main() {
|
||||
|
||||
// CORS
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
origin := c.GetHeader("Origin")
|
||||
allowed := false
|
||||
for _, candidate := range cfg.AllowedOrigins {
|
||||
if candidate == "*" || candidate == origin {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if allowed && origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Visitor-Token")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
if origin != "" && !allowed {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
JWT JWTConfig
|
||||
Storage StorageConfig
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
JWT JWTConfig
|
||||
Storage StorageConfig
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -77,9 +79,24 @@ func Load() *Config {
|
||||
MaxImageEdge: getEnvInt("STORAGE_MAX_IMAGE_EDGE", 1920),
|
||||
WebPQuality: float32(getEnvInt("STORAGE_WEBP_QUALITY", 80)),
|
||||
},
|
||||
AllowedOrigins: splitCSV(getEnv("CORS_ALLOW_ORIGINS", "*")),
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value := strings.TrimSpace(part); value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return []string{"*"}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *DatabaseConfig) DSN() string {
|
||||
return "host=" + d.Host + " port=" + d.Port + " user=" + d.User +
|
||||
" password=" + d.Password + " dbname=" + d.Name + " sslmode=" + d.SSLMode
|
||||
|
||||
@@ -3,11 +3,19 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"kefu-cloud/server/internal/model"
|
||||
"kefu-cloud/server/internal/ws"
|
||||
)
|
||||
|
||||
var assignmentLocks sync.Map
|
||||
|
||||
func assignmentLock(tenantID uint) *sync.Mutex {
|
||||
lock, _ := assignmentLocks.LoadOrStore(tenantID, &sync.Mutex{})
|
||||
return lock.(*sync.Mutex)
|
||||
}
|
||||
|
||||
const (
|
||||
assignStrategyLeastLoad = "least_load"
|
||||
assignStrategyRoundRobin = "round_robin"
|
||||
@@ -138,6 +146,16 @@ func tryAutoAssign(session *model.Session) (*model.User, error) {
|
||||
if session.Status != "waiting" || session.AgentID != nil {
|
||||
return nil, nil
|
||||
}
|
||||
// 负载统计与会话更新必须串行,否则并发新会话会同时看到旧负载并突破最大并发。
|
||||
lock := assignmentLock(session.TenantID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if err := model.DB.First(session, session.ID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.Status != "waiting" || session.AgentID != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
setting, err := getOrCreateTenantSettings(session.TenantID)
|
||||
if err != nil {
|
||||
|
||||
@@ -321,7 +321,7 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Delete(c *gin.Context) {
|
||||
if !middleware.HasAnyPermission(c, "customer.export") {
|
||||
if !middleware.HasAnyPermission(c, "customer.delete") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可删除客户"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func (h *CustomerHandler) Export(c *gin.Context) {
|
||||
source := c.Query("source")
|
||||
|
||||
query := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if !middleware.CanAccessAllData(c, "chat_history") {
|
||||
if !middleware.CanAccessAllData(c, "customer") {
|
||||
assignedCustomers := model.DB.Model(&model.Session{}).
|
||||
Select("customer_id").
|
||||
Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c))
|
||||
@@ -76,7 +76,7 @@ func (h *SessionHandler) Export(c *gin.Context) {
|
||||
to := c.Query("to")
|
||||
|
||||
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID)
|
||||
if !middleware.CanAccessAllData(c, "customer") {
|
||||
if !middleware.CanAccessAllData(c, "chat_history") {
|
||||
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
|
||||
}
|
||||
if status != "" {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-cloud/server/internal/config"
|
||||
"kefu-cloud/server/internal/middleware"
|
||||
@@ -33,10 +35,11 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
api := r.Group("/api")
|
||||
|
||||
// 公开接口
|
||||
api.POST("/login", auth.Login)
|
||||
api.POST("/login", middleware.RateLimitByIP(20, time.Minute), auth.Login)
|
||||
|
||||
// widget 接口
|
||||
widgetApi := api.Group("/widget")
|
||||
widgetApi.Use(middleware.RateLimitByIP(240, time.Minute))
|
||||
widgetApi.POST("/init", widget.Init)
|
||||
widgetApi.GET("/init", widget.Init)
|
||||
widgetApi.POST("/message", widget.SendMessage)
|
||||
@@ -88,7 +91,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
customers.GET("/:id", middleware.RequirePermission("customer.view"), customer.Get)
|
||||
customers.POST("", middleware.RequirePermission("customer.create"), customer.Create)
|
||||
customers.PUT("/:id", middleware.RequirePermission("customer.edit"), customer.Update)
|
||||
customers.DELETE("/:id", middleware.RequirePermission("customer.export"), customer.Delete)
|
||||
customers.DELETE("/:id", middleware.RequirePermission("customer.delete"), customer.Delete)
|
||||
|
||||
// 黑名单(拉黑 IP / 设备)
|
||||
bl := authRequired.Group("/blacklist")
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -779,6 +781,91 @@ func TestCustomerAndStatisticsDataScopesPreventCrossAgentAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsAndExportsHonorFineGrainedPermissions(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "细粒度权限租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "fine-admin", "admin")
|
||||
seed := httptest.NewRecorder()
|
||||
router.ServeHTTP(seed, bearerRequest(t, http.MethodGet, "/api/roles", nil, admin))
|
||||
if seed.Code != http.StatusOK {
|
||||
t.Fatalf("初始化权限数据失败: %d %s", seed.Code, seed.Body.String())
|
||||
}
|
||||
createRole := httptest.NewRecorder()
|
||||
roleBody := []byte(`{"name":"只读设置导出","permissions":["settings.basic","customer.view","customer.export","chat_history.view","chat_history.export"],"data_scopes":{"customer":"self","chat_history":"self"}}`)
|
||||
router.ServeHTTP(createRole, bearerRequest(t, http.MethodPost, "/api/roles", roleBody, admin))
|
||||
if createRole.Code != http.StatusOK {
|
||||
t.Fatalf("创建测试角色失败: %d %s", createRole.Code, createRole.Body.String())
|
||||
}
|
||||
var roleResp struct {
|
||||
Data model.Role `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(createRole.Body.Bytes(), &roleResp); err != nil {
|
||||
t.Fatalf("解析测试角色失败: %v", err)
|
||||
}
|
||||
operator := createUser(t, tenant.ID, "fine-operator", roleResp.Data.Code)
|
||||
|
||||
settings := httptest.NewRecorder()
|
||||
router.ServeHTTP(settings, bearerRequest(t, http.MethodPut, "/api/settings", []byte(`{"assign_strategy":"round_robin"}`), operator))
|
||||
if settings.Code != http.StatusForbidden {
|
||||
t.Fatalf("缺少分配规则权限仍可修改设置: %d %s", settings.Code, settings.Body.String())
|
||||
}
|
||||
|
||||
customerOne := model.Customer{TenantID: tenant.ID, Name: "客户一"}
|
||||
customerTwo := model.Customer{TenantID: tenant.ID, Name: "客户二"}
|
||||
if err := model.DB.Create(&customerOne).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := model.DB.Create(&customerTwo).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionOne := model.Session{TenantID: tenant.ID, CustomerID: customerOne.ID, AgentID: &operator.ID, Status: "ended"}
|
||||
other := createUser(t, tenant.ID, "fine-other", "agent")
|
||||
sessionTwo := model.Session{TenantID: tenant.ID, CustomerID: customerTwo.ID, AgentID: &other.ID, Status: "ended"}
|
||||
if err := model.DB.Create(&sessionOne).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := model.DB.Create(&sessionTwo).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
customerExport := httptest.NewRecorder()
|
||||
router.ServeHTTP(customerExport, bearerRequest(t, http.MethodGet, "/api/customers/export", nil, operator))
|
||||
if customerExport.Code != http.StatusOK || strings.Contains(customerExport.Body.String(), "客户二") {
|
||||
t.Fatalf("客户导出越过 customer=self 范围: %d %s", customerExport.Code, customerExport.Body.String())
|
||||
}
|
||||
sessionExport := httptest.NewRecorder()
|
||||
router.ServeHTTP(sessionExport, bearerRequest(t, http.MethodGet, "/api/sessions/export", nil, operator))
|
||||
if sessionExport.Code != http.StatusOK || strings.Contains(sessionExport.Body.String(), ","+strconv.FormatUint(uint64(sessionTwo.ID), 10)+",") {
|
||||
t.Fatalf("会话导出越过 chat_history=self 范围: %d %s", sessionExport.Code, sessionExport.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerExportPermissionDoesNotGrantDelete(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "删除权限租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "delete-admin", "admin")
|
||||
seed := httptest.NewRecorder()
|
||||
router.ServeHTTP(seed, bearerRequest(t, http.MethodGet, "/api/roles", nil, admin))
|
||||
createRole := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRole, bearerRequest(t, http.MethodPost, "/api/roles", []byte(`{"name":"仅导出","permissions":["customer.view","customer.export"]}`), admin))
|
||||
var roleResp struct {
|
||||
Data model.Role `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(createRole.Body.Bytes(), &roleResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operator := createUser(t, tenant.ID, "export-only", roleResp.Data.Code)
|
||||
customer := model.Customer{TenantID: tenant.ID, Name: "不可删除客户"}
|
||||
if err := model.DB.Create(&customer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, bearerRequest(t, http.MethodDelete, "/api/customers/"+strconv.FormatUint(uint64(customer.ID), 10), nil, operator))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("仅导出角色可删除客户: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerAndKnowledgeCRUD(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "业务CRUD租户", "normal")
|
||||
|
||||
@@ -24,9 +24,9 @@ const (
|
||||
defaultAgentNickname = "在线客服"
|
||||
defaultTimezone = "Asia/Shanghai"
|
||||
|
||||
maxWelcomeSegments = 10
|
||||
maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计)
|
||||
maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限
|
||||
maxWelcomeSegments = 10
|
||||
maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计)
|
||||
maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限
|
||||
)
|
||||
|
||||
// stripHTMLTags 粗暴去标签,用于字数与摘要(欢迎语 HTML)。
|
||||
@@ -54,7 +54,7 @@ func stripHTMLTags(s string) string {
|
||||
|
||||
// WelcomeSegment 单段欢迎语:文本或图片(content 为正文或对象存储 URL)。
|
||||
type WelcomeSegment struct {
|
||||
Type string `json:"type"` // text | image
|
||||
Type string `json:"type"` // text | image
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
@@ -311,20 +311,20 @@ func settingsResponse(setting *model.TenantSetting) gin.H {
|
||||
}
|
||||
welcomeSegs := resolveWelcomeSegments(setting)
|
||||
return gin.H{
|
||||
"tenant_id": setting.TenantID,
|
||||
"display_name": setting.DisplayName,
|
||||
"agent_nickname": setting.AgentNickname,
|
||||
"timezone": setting.Timezone,
|
||||
"welcome_message": firstWelcomeText(welcomeSegs),
|
||||
"welcome_messages": welcomeSegs,
|
||||
"offline_prompt": setting.OfflinePrompt,
|
||||
"work_hours": parseWorkHours(setting.WorkHoursJSON),
|
||||
"worktime_prompt": setting.WorktimePrompt,
|
||||
"notify_new_session": setting.NotifyNewSession,
|
||||
"notify_offline_leave": setting.NotifyOfflineLeave,
|
||||
"notify_daily_report": setting.NotifyDailyReport,
|
||||
"assign_strategy": strategy,
|
||||
"max_active_per_agent": maxActive,
|
||||
"tenant_id": setting.TenantID,
|
||||
"display_name": setting.DisplayName,
|
||||
"agent_nickname": setting.AgentNickname,
|
||||
"timezone": setting.Timezone,
|
||||
"welcome_message": firstWelcomeText(welcomeSegs),
|
||||
"welcome_messages": welcomeSegs,
|
||||
"offline_prompt": setting.OfflinePrompt,
|
||||
"work_hours": parseWorkHours(setting.WorkHoursJSON),
|
||||
"worktime_prompt": setting.WorktimePrompt,
|
||||
"notify_new_session": setting.NotifyNewSession,
|
||||
"notify_offline_leave": setting.NotifyOfflineLeave,
|
||||
"notify_daily_report": setting.NotifyDailyReport,
|
||||
"assign_strategy": strategy,
|
||||
"max_active_per_agent": maxActive,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +379,31 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
// 设置权限按字段分区校验,避免仅拥有基本设置权限即可修改分配、工作时间或通知策略。
|
||||
if req.AssignStrategy != nil || req.MaxActivePerAgent != nil {
|
||||
if !middleware.HasPermission(c, "settings.assign_rule") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改客服分配规则"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.WelcomeMessage != nil || req.WelcomeMessages != nil || req.OfflinePrompt != nil {
|
||||
if !middleware.HasPermission(c, "settings.auto_reply") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改自动回复设置"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.WorkHours != nil || req.WorktimePrompt != nil {
|
||||
if !middleware.HasPermission(c, "settings.worktime") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改工作时间设置"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifyNewSession != nil || req.NotifyOfflineLeave != nil || req.NotifyDailyReport != nil {
|
||||
if !middleware.HasPermission(c, "settings.notification") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改通知设置"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.DisplayName != nil {
|
||||
|
||||
@@ -12,7 +12,6 @@ func resolveVisitorIP(c *gin.Context) string {
|
||||
candidates := []string{
|
||||
c.GetHeader("CF-Connecting-IP"),
|
||||
c.GetHeader("True-Client-IP"),
|
||||
c.GetHeader("X-Real-IP"),
|
||||
}
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
// 取最左侧第一个非空 IP
|
||||
@@ -24,6 +23,9 @@ func resolveVisitorIP(c *gin.Context) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nginx 经 Caddy/网关转发时,X-Real-IP 通常是上一跳代理地址;
|
||||
// X-Forwarded-For 才包含访客地址,因此放在 XFF 之后作为回退。
|
||||
candidates = append(candidates, c.GetHeader("X-Real-IP"))
|
||||
for _, raw := range candidates {
|
||||
ip := strings.TrimSpace(raw)
|
||||
if ip == "" {
|
||||
|
||||
@@ -23,6 +23,16 @@ func TestResolveVisitorIPAndRegion(t *testing.T) {
|
||||
t.Fatalf("内网地区应为内网, got %s", region)
|
||||
}
|
||||
|
||||
// Caddy -> Nginx 链路:X-Real-IP 是上一跳代理,XFF 左侧才是访客。
|
||||
c3, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c3.Request = httptest.NewRequest("GET", "/", nil)
|
||||
c3.Request.RemoteAddr = "172.18.0.3:42100"
|
||||
c3.Request.Header.Set("X-Real-IP", "172.18.0.1")
|
||||
c3.Request.Header.Set("X-Forwarded-For", "203.0.113.24, 172.18.0.1")
|
||||
if ip3 := resolveVisitorIP(c3); ip3 != "203.0.113.24" {
|
||||
t.Fatalf("代理链 IP 解析错误: %s", ip3)
|
||||
}
|
||||
|
||||
// CF 国家头
|
||||
c2, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c2.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
@@ -197,7 +197,7 @@ func legacyRolePermissions(role string) map[string]bool {
|
||||
"session.view": true, "session.reply": true, "session.transfer": true,
|
||||
"session.end": true, "session.note": true, "session.priority": true,
|
||||
"customer.view": true, "customer.create": true, "customer.edit": true,
|
||||
"customer.export": true, "customer.tag": true,
|
||||
"customer.export": true, "customer.tag": true, "customer.delete": true,
|
||||
"chat_history.view": true, "chat_history.detail": true,
|
||||
"chat_history.export": true, "chat_history.batch_archive": true,
|
||||
"knowledge.view": true, "knowledge.create": true, "knowledge.edit": true,
|
||||
@@ -216,7 +216,7 @@ func legacyRolePermissions(role string) map[string]bool {
|
||||
"session.view": true, "session.reply": true, "session.transfer": true,
|
||||
"session.end": true, "session.note": true, "session.priority": true,
|
||||
"customer.view": true, "customer.create": true, "customer.edit": true,
|
||||
"customer.export": true, "customer.tag": true,
|
||||
"customer.export": true, "customer.tag": true, "customer.delete": true,
|
||||
"chat_history.view": true, "chat_history.detail": true,
|
||||
"chat_history.export": true, "chat_history.batch_archive": true,
|
||||
"knowledge.view": true, "knowledge.create": true, "knowledge.edit": true,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type rateWindow struct {
|
||||
start time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
var rateWindows sync.Map
|
||||
var rateMu sync.Mutex
|
||||
|
||||
// RateLimitByIP applies a fixed-window limit to public endpoints.
|
||||
// A shared gateway limiter is still needed when running multiple replicas.
|
||||
func RateLimitByIP(max int, window time.Duration) gin.HandlerFunc {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
if window <= 0 {
|
||||
window = time.Minute
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
key := c.Request.Method + ":" + c.FullPath() + ":" + c.ClientIP()
|
||||
now := time.Now()
|
||||
rateMu.Lock()
|
||||
allowed := false
|
||||
var current rateWindow
|
||||
if raw, ok := rateWindows.Load(key); ok {
|
||||
current, _ = raw.(rateWindow)
|
||||
}
|
||||
if current.start.IsZero() || now.Sub(current.start) >= window {
|
||||
current = rateWindow{start: now, count: 1}
|
||||
allowed = true
|
||||
} else if current.count < max {
|
||||
current.count++
|
||||
allowed = true
|
||||
}
|
||||
rateWindows.Store(key, current)
|
||||
rateMu.Unlock()
|
||||
if !allowed {
|
||||
c.Header("Retry-After", "60")
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 429, "message": "请求过于频繁,请稍后重试"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRateLimitByIPReturns429AfterWindowIsConsumed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/rate-test", RateLimitByIP(1, time.Minute), func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
first := httptest.NewRecorder()
|
||||
r.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/rate-test", nil))
|
||||
if first.Code != http.StatusNoContent {
|
||||
t.Fatalf("首个请求状态码 = %d", first.Code)
|
||||
}
|
||||
second := httptest.NewRecorder()
|
||||
r.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/rate-test", nil))
|
||||
if second.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("超限请求状态码 = %d,期望 429", second.Code)
|
||||
}
|
||||
}
|
||||
@@ -24,16 +24,17 @@ var predefinedPermissions = []Permission{
|
||||
{Code: "customer.edit", Name: "编辑客户资料", Module: "客户管理", Category: "operate", SortOrder: 9},
|
||||
{Code: "customer.export", Name: "导出客户", Module: "客户管理", Category: "operate", SortOrder: 10},
|
||||
{Code: "customer.tag", Name: "管理客户标签", Module: "客户管理", Category: "operate", SortOrder: 11},
|
||||
{Code: "chat_history.view", Name: "查看对话记录", Module: "对话记录", Category: "view", SortOrder: 12},
|
||||
{Code: "chat_history.detail", Name: "查看会话详情", Module: "对话记录", Category: "view", SortOrder: 13},
|
||||
{Code: "chat_history.export", Name: "导出对话记录", Module: "对话记录", Category: "operate", SortOrder: 14},
|
||||
{Code: "chat_history.batch_archive", Name: "批量归档记录", Module: "对话记录", Category: "operate", SortOrder: 15},
|
||||
{Code: "knowledge.view", Name: "查看知识库", Module: "知识库", Category: "view", SortOrder: 16},
|
||||
{Code: "knowledge.create", Name: "新建知识条目", Module: "知识库", Category: "operate", SortOrder: 17},
|
||||
{Code: "knowledge.edit", Name: "编辑知识条目", Module: "知识库", Category: "operate", SortOrder: 18},
|
||||
{Code: "knowledge.publish", Name: "发布/下架条目", Module: "知识库", Category: "operate", SortOrder: 19},
|
||||
{Code: "knowledge.delete", Name: "删除知识条目/分类", Module: "知识库", Category: "operate", SortOrder: 20},
|
||||
{Code: "quick_reply.view", Name: "查看快捷回复", Module: "快捷回复", Category: "view", SortOrder: 21},
|
||||
{Code: "customer.delete", Name: "删除客户", Module: "客户管理", Category: "operate", SortOrder: 12},
|
||||
{Code: "chat_history.view", Name: "查看对话记录", Module: "对话记录", Category: "view", SortOrder: 13},
|
||||
{Code: "chat_history.detail", Name: "查看会话详情", Module: "对话记录", Category: "view", SortOrder: 14},
|
||||
{Code: "chat_history.export", Name: "导出对话记录", Module: "对话记录", Category: "operate", SortOrder: 15},
|
||||
{Code: "chat_history.batch_archive", Name: "批量归档记录", Module: "对话记录", Category: "operate", SortOrder: 16},
|
||||
{Code: "knowledge.view", Name: "查看知识库", Module: "知识库", Category: "view", SortOrder: 17},
|
||||
{Code: "knowledge.create", Name: "新建知识条目", Module: "知识库", Category: "operate", SortOrder: 18},
|
||||
{Code: "knowledge.edit", Name: "编辑知识条目", Module: "知识库", Category: "operate", SortOrder: 19},
|
||||
{Code: "knowledge.publish", Name: "发布/下架条目", Module: "知识库", Category: "operate", SortOrder: 20},
|
||||
{Code: "knowledge.delete", Name: "删除知识条目/分类", Module: "知识库", Category: "operate", SortOrder: 21},
|
||||
{Code: "quick_reply.view", Name: "查看快捷回复", Module: "快捷回复", Category: "view", SortOrder: 22},
|
||||
{Code: "quick_reply.team_create", Name: "新建团队快捷回复", Module: "快捷回复", Category: "operate", SortOrder: 22},
|
||||
{Code: "quick_reply.team_edit", Name: "编辑/删除团队回复", Module: "快捷回复", Category: "operate", SortOrder: 23},
|
||||
{Code: "quick_reply.personal_manage", Name: "管理我的快捷回复", Module: "快捷回复", Category: "operate", SortOrder: 24},
|
||||
@@ -89,7 +90,7 @@ func BuiltinRolePermissionCodes() map[string][]string {
|
||||
return map[string][]string{
|
||||
"admin": {
|
||||
"session.view", "session.reply", "session.transfer", "session.end", "session.note", "session.priority",
|
||||
"customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag",
|
||||
"customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag", "customer.delete",
|
||||
"chat_history.view", "chat_history.detail", "chat_history.export", "chat_history.batch_archive",
|
||||
"knowledge.view", "knowledge.create", "knowledge.edit", "knowledge.publish", "knowledge.delete",
|
||||
"quick_reply.view", "quick_reply.team_create", "quick_reply.team_edit", "quick_reply.personal_manage",
|
||||
@@ -101,7 +102,7 @@ func BuiltinRolePermissionCodes() map[string][]string {
|
||||
},
|
||||
"supervisor": {
|
||||
"session.view", "session.reply", "session.transfer", "session.end", "session.note", "session.priority",
|
||||
"customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag",
|
||||
"customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag", "customer.delete",
|
||||
"chat_history.view", "chat_history.detail", "chat_history.export", "chat_history.batch_archive",
|
||||
"knowledge.view", "knowledge.create", "knowledge.edit", "knowledge.publish", "knowledge.delete",
|
||||
"quick_reply.view", "quick_reply.team_create", "quick_reply.team_edit", "quick_reply.personal_manage",
|
||||
|
||||
@@ -24,10 +24,40 @@ func processDraftContacts(tenantID, customerID, sessionID uint, text string) {
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
CheckOrigin: checkOrigin,
|
||||
Subprotocols: []string{"kefu-v1", "kefu-visitor-v1"},
|
||||
}
|
||||
|
||||
var (
|
||||
originMu sync.RWMutex
|
||||
allowedOrigins = []string{"*"}
|
||||
)
|
||||
|
||||
// SetAllowedOrigins configures the origins accepted during WebSocket upgrade.
|
||||
func SetAllowedOrigins(origins []string) {
|
||||
originMu.Lock()
|
||||
defer originMu.Unlock()
|
||||
allowedOrigins = append([]string(nil), origins...)
|
||||
if len(allowedOrigins) == 0 {
|
||||
allowedOrigins = []string{"*"}
|
||||
}
|
||||
}
|
||||
|
||||
func checkOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
originMu.RLock()
|
||||
defer originMu.RUnlock()
|
||||
for _, allowed := range allowedOrigins {
|
||||
if allowed == "*" || allowed == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
Conn *websocket.Conn
|
||||
UserID uint
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheckOriginUsesConfiguredAllowlist(t *testing.T) {
|
||||
SetAllowedOrigins([]string{"https://portal.example"})
|
||||
defer SetAllowedOrigins([]string{"*"})
|
||||
allowed := httptest.NewRequest("GET", "/api/ws", nil)
|
||||
allowed.Header.Set("Origin", "https://portal.example")
|
||||
if !checkOrigin(allowed) {
|
||||
t.Fatal("白名单来源应允许 WebSocket 升级")
|
||||
}
|
||||
denied := httptest.NewRequest("GET", "/api/ws", nil)
|
||||
denied.Header.Set("Origin", "https://evil.example")
|
||||
if checkOrigin(denied) {
|
||||
t.Fatal("非白名单来源不应允许 WebSocket 升级")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastToSessionRestrictsRecipients(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
Reference in New Issue
Block a user