diff --git a/.env b/.env index 5753c5f..ab2497a 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ # [Redis] -REDIS_ADDR=127.0.0.1:6379 -REDIS_PASSWORD= +REDIS_ADDR=127.0.0.1:63791 +REDIS_PASSWORD=xH5FRfKizaDpJR4H # [Mysql] DB_DSN=root:root@tcp(localhost:3310)/z_xk?charset=utf8mb4&parseTime=True&loc=Local @@ -19,4 +19,7 @@ NODE_ID=ecs1 # 日志系统配置 # LOG_DIR=.\\ws-logs # 日志目录,默认 ./logs # LOG_ROTATE_FREQ=hourly # 轮转频率,可选: daily/hourly/10min -LOG_PREFIX=ws-server # 日志文件前缀 \ No newline at end of file +LOG_PREFIX=ws-server # 日志文件前缀 + +OPEN_API_SECRET_KEY=3a8f9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a +OPEN_API_TIMESTAMP_TOLERANCE=300 \ No newline at end of file diff --git a/controller/websocket_controller.go b/controller/websocket_controller.go index 8053626..4b6e6ce 100644 --- a/controller/websocket_controller.go +++ b/controller/websocket_controller.go @@ -6,9 +6,14 @@ package controller import ( + "bytes" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" "log" "net/http" "os" @@ -18,12 +23,18 @@ import ( "github.com/gin-gonic/gin" "github.com/go-redis/redis/v8" + "github.com/golang-jwt/jwt/v5" "github.com/gorilla/websocket" "gorm.io/gorm" "xk-websocket/models" "xk-websocket/utils" ) +// JWT相关常量(与Laravel JWTService保持一致) +const ( + AdminJWTSecretKey = "xk_admin_secret_key" // Admin JWT密钥 +) + var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, @@ -40,17 +51,21 @@ var upgrader = websocket.Upgrader{ * - 读写锁 (ClientsMux) 保护连接池安全 * - Redis 客户端 (RedisCli) 用于集群通信 * - 数据库连接 (DB) + * - Token验证相关 (AuthRedisCli, ClientInfoMap) */ type WebSocketController struct { - Clients map[string]*websocket.Conn - ClientsMux sync.RWMutex - WriteMutex sync.Mutex // 添加写锁 - RedisCli *redis.Client - NodeID string - Port string - RedisCtx context.Context - Logger *log.Logger - DB *gorm.DB // 数据库连接 + Clients map[string]*websocket.Conn + ClientsMux sync.RWMutex + WriteMutex sync.Mutex // 添加写锁 + RedisCli *redis.Client + AuthRedisCli *redis.Client // 用于Token验证的Redis客户端 (DB 1) + ClientInfos map[string]*models.ClientInfo // 客户端认证信息映射 + ClientInfoMux sync.RWMutex // 客户端信息锁 + NodeID string + Port string + RedisCtx context.Context + Logger *log.Logger + DB *gorm.DB // 数据库连接 } /** @@ -60,9 +75,10 @@ type WebSocketController struct { */ func NewWebSocketController(db *gorm.DB) *WebSocketController { return &WebSocketController{ - Clients: make(map[string]*websocket.Conn), - RedisCtx: context.Background(), - DB: db, // 初始化数据库连接 + Clients: make(map[string]*websocket.Conn), + ClientInfos: make(map[string]*models.ClientInfo), + RedisCtx: context.Background(), + DB: db, // 初始化数据库连接 } } @@ -147,10 +163,15 @@ func (c *WebSocketController) getLogWriter(logDir, freq string) *utils.DailyFile /** * InitRedisClient * 功能:建立 Redis 连接并验证连通性。 + * 初始化两个Redis客户端: + * - RedisCli: DB 0,用于WebSocket集群通信 + * - AuthRedisCli: DB 1,用于Token验证(与Laravel保持一致) */ func (c *WebSocketController) InitRedisClient() { redisAddr := utils.GetEnv("REDIS_ADDR", "localhost:6379") redisPassword := utils.GetEnv("REDIS_PASSWORD", "") + + // 初始化主Redis客户端 (DB 0) 用于集群通信 c.RedisCli = redis.NewClient(&redis.Options{ Addr: redisAddr, Password: redisPassword, @@ -160,6 +181,19 @@ func (c *WebSocketController) InitRedisClient() { if err := c.checkRedisConnection(); err != nil { log.Fatalf("❌❌❌❌❌❌❌❌ Redis连接失败: %v", err) } + + // 初始化Token验证Redis客户端 (DB 1) 用于Token验证 + c.AuthRedisCli = redis.NewClient(&redis.Options{ + Addr: redisAddr, + Password: redisPassword, + DB: 1, // 与Laravel cache连接保持一致 + }) + + if err := c.checkAuthRedisConnection(); err != nil { + log.Fatalf("❌❌❌❌❌❌❌❌ Token验证Redis连接失败: %v", err) + } + + log.Println("✅ Token验证Redis客户端初始化成功 (DB 1)") } // 检查Redis连接 @@ -168,6 +202,259 @@ func (c *WebSocketController) checkRedisConnection() error { return err } +// 检查Token验证Redis连接 +func (c *WebSocketController) checkAuthRedisConnection() error { + _, err := c.AuthRedisCli.Ping(c.RedisCtx).Result() + return err +} + +/** + * ValidateToken + * 功能:验证Token是否有效 + * 逻辑:从Redis DB 1中查询 xk_user_{token} 是否存在 + * @param token string 待验证的Token + * @return (userInfo string, isValid bool) 用户信息JSON和验证结果 + */ +func (c *WebSocketController) ValidateToken(token string) (string, bool) { + if token == "" { + log.Printf("⚠️ Token验证失败: Token为空") + return "", false + } + + // 构造Redis Key: xk_user_{token} + redisKey := models.TokenKeyPrefix + token + + // 从Redis DB 1中查询 + userInfo, err := c.AuthRedisCli.Get(c.RedisCtx, redisKey).Result() + if err != nil { + if err == redis.Nil { + log.Printf("⚠️ Token验证失败: Token不存在或已过期 | Key=%s", redisKey) + } else { + log.Printf("❌ Token验证错误: %v | Key=%s", err, redisKey) + } + return "", false + } + + log.Printf("✅ Token验证成功 | Key=%s", redisKey) + return userInfo, true +} + +/** + * AdminJWTClaims + * 结构体:Admin JWT Token的Claims结构 + * 与Laravel JWTService的payload结构保持一致 + */ +type AdminJWTClaims struct { + Data struct { + ID int `json:"id"` // 用户ID + } `json:"data"` + jwt.RegisteredClaims +} + +/** + * ValidateAdminToken + * 功能:验证Admin后台的JWT Token是否有效 + * 逻辑: + * 1. JWT解码获取 payload.data.id (user_id) + * 2. 用 xk_login_{user_id} 在 Redis DB 0 查询 + * @param token string 待验证的JWT Token + * @return (userInfo string, isValid bool) 用户信息JSON和验证结果 + */ +func (c *WebSocketController) ValidateAdminToken(token string) (string, bool) { + if token == "" { + log.Printf("⚠️ Admin Token验证失败: Token为空") + return "", false + } + + // 解析JWT Token + claims := &AdminJWTClaims{} + parsedToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { + // 验证签名算法 + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return []byte(AdminJWTSecretKey), nil + }) + + if err != nil { + log.Printf("⚠️ Admin Token解析失败: %v | Token=%s", err, maskToken(token)) + return "", false + } + + if !parsedToken.Valid { + log.Printf("⚠️ Admin Token无效 | Token=%s", maskToken(token)) + return "", false + } + + // 获取用户ID + userID := claims.Data.ID + if userID == 0 { + log.Printf("⚠️ Admin Token验证失败: 无法获取用户ID | Token=%s", maskToken(token)) + return "", false + } + + // 构造Redis Key: xk_login_{user_id} + redisKey := fmt.Sprintf("%s%d", models.AdminTokenKeyPrefix, userID) + + // 从Redis DB 0中查询(使用主Redis客户端) + userInfo, err := c.RedisCli.Get(c.RedisCtx, redisKey).Result() + if err != nil { + if err == redis.Nil { + log.Printf("⚠️ Admin Token验证失败: 用户登录信息不存在或已过期 | Key=%s", redisKey) + } else { + log.Printf("❌ Admin Token验证错误: %v | Key=%s", err, redisKey) + } + return "", false + } + + log.Printf("✅ Admin Token验证成功 | UserID=%d | Key=%s", userID, redisKey) + return userInfo, true +} + +/** + * ValidateTokenByUserType + * 功能:根据用户类型选择对应的Token验证方法 + * @param token string 待验证的Token + * @param userType string 用户类型 (doctor/user) + * @return (userInfo string, isValid bool) 用户信息JSON和验证结果 + */ +func (c *WebSocketController) ValidateTokenByUserType(token string, userType string) (string, bool) { + if userType == models.UserTypeDoctor { + // 后台用户(医生端PC):使用JWT Token验证 + log.Printf("🔐 使用Admin Token验证方式 | UserType=%s", userType) + return c.ValidateAdminToken(token) + } + // 小程序用户:使用原始Token验证 + log.Printf("🔐 使用Mobile Token验证方式 | UserType=%s", userType) + return c.ValidateToken(token) +} + +/** + * SaveClientInfo + * 功能:保存客户端认证信息 + * @param clientID string 客户端ID + * @param info *models.ClientInfo 客户端信息 + */ +func (c *WebSocketController) SaveClientInfo(clientID string, info *models.ClientInfo) { + c.ClientInfoMux.Lock() + defer c.ClientInfoMux.Unlock() + c.ClientInfos[clientID] = info +} + +/** + * GetClientInfo + * 功能:获取客户端认证信息 + * @param clientID string 客户端ID + * @return *models.ClientInfo 客户端信息 + */ +func (c *WebSocketController) GetClientInfo(clientID string) *models.ClientInfo { + c.ClientInfoMux.RLock() + defer c.ClientInfoMux.RUnlock() + return c.ClientInfos[clientID] +} + +/** + * RemoveClientInfo + * 功能:移除客户端认证信息 + * @param clientID string 客户端ID + */ +func (c *WebSocketController) RemoveClientInfo(clientID string) { + c.ClientInfoMux.Lock() + defer c.ClientInfoMux.Unlock() + delete(c.ClientInfos, clientID) +} + +/** + * SendAuthResponse + * 功能:向客户端发送认证响应 + * @param clientID string 客户端ID + * @param status string 认证状态 (success/failed/token_expired) + * @param message string 消息说明 + */ +func (c *WebSocketController) SendAuthResponse(clientID string, status string, message string) { + c.ClientsMux.RLock() + conn, exists := c.Clients[clientID] + c.ClientsMux.RUnlock() + + if !exists { + return + } + + authResp := models.AuthResponse{ + AuthStatus: status, + Message: message, + ClientID: clientID, + } + + c.WriteMutex.Lock() + defer c.WriteMutex.Unlock() + + if err := conn.WriteJSON(authResp); err != nil { + log.Printf("❌ 发送认证响应失败: %v | ClientID=%s", err, clientID) + } +} + +/** + * maskToken + * 功能:对Token进行脱敏处理,用于日志输出 + * @param token string 原始Token + * @return string 脱敏后的Token + */ +func maskToken(token string) string { + if len(token) <= 8 { + return "***" + } + return token[:4] + "****" + token[len(token)-4:] +} + +/** + * startTokenCheckTimer + * 功能:启动定期Token检查定时器 + * 每5分钟检查一次Token是否仍然有效 + * 根据用户类型使用不同的验证方法 + * @param clientID string 客户端ID + */ +func (c *WebSocketController) startTokenCheckTimer(clientID string) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + // 检查客户端是否仍然连接 + c.ClientsMux.RLock() + _, exists := c.Clients[clientID] + c.ClientsMux.RUnlock() + + if !exists { + log.Printf("🔌 客户端已断开,停止Token检查: ClientID=%s", clientID) + return + } + + // 获取客户端信息 + clientInfo := c.GetClientInfo(clientID) + if clientInfo == nil || clientInfo.Token == "" { + log.Printf("⚠️ 客户端信息不存在,停止Token检查: ClientID=%s", clientID) + return + } + + // 根据用户类型选择验证方法 + _, isValid := c.ValidateTokenByUserType(clientInfo.Token, clientInfo.UserType) + if !isValid { + log.Printf("⚠️ Token已过期,通知客户端重新认证: ClientID=%s | UserType=%s", clientID, clientInfo.UserType) + c.SendAuthResponse(clientID, "token_expired", "Token已过期,请重新登录") + + // 更新客户端认证状态 + clientInfo.IsAuth = false + c.SaveClientInfo(clientID, clientInfo) + return + } + + // 更新最后检查时间 + clientInfo.LastCheckTime = time.Now() + c.SaveClientInfo(clientID, clientInfo) + log.Printf("✅ Token定期检查通过: ClientID=%s | UserType=%s", clientID, clientInfo.UserType) + } +} + /** * PrintStartupInfo * 功能:在控制台打印详细的服务启动信息,包括节点ID、端口、Redis状态及支持的消息类型。 @@ -312,11 +599,50 @@ func (c *WebSocketController) handleClientMessage(senderID string, message []byt log.Printf("📦📦📦📦📦📦📦📦 解析JSON消息成功: Type=%s", payload.RequestType) if payload.RequestType == "bind" && payload.SenderUserID != "" { - log.Printf("🔗🔗🔗🔗🔗🔗🔗🔗 处理绑定请求: \n ClientID=%s \n UserID=%s", senderID, payload.SenderUserID) + log.Printf("🔗🔗🔗🔗🔗🔗🔗🔗 处理绑定请求: \n ClientID=%s \n UserID=%s \n UserType=%s \n Token=%s", + senderID, payload.SenderUserID, payload.UserType, maskToken(payload.Token)) + + // 验证Token + if payload.Token == "" { + log.Printf("❌ 绑定失败: Token为空 | ClientID=%s", senderID) + c.SendAuthResponse(senderID, "failed", "Token不能为空") + return + } + + // 根据用户类型选择验证方法 + userInfo, isValid := c.ValidateTokenByUserType(payload.Token, payload.UserType) + if !isValid { + log.Printf("❌ 绑定失败: Token验证失败 | ClientID=%s | UserType=%s", senderID, payload.UserType) + c.SendAuthResponse(senderID, "failed", "Token验证失败,请重新登录") + return + } + + // 绑定用户 if err := c.bindClientToUser(senderID, payload.SenderUserID); err != nil { log.Printf("❌❌❌❌❌❌❌❌ 绑定失败: %v", err) + c.SendAuthResponse(senderID, "failed", "绑定失败") } else { log.Printf("✅ 绑定成功: \n ClientID=%s \n UserID=%s", senderID, payload.SenderUserID) + + // 保存客户端认证信息 + clientInfo := &models.ClientInfo{ + ClientID: senderID, + UserID: payload.SenderUserID, + UserType: payload.UserType, + Token: payload.Token, + IsAuth: true, + AuthTime: time.Now(), + LastCheckTime: time.Now(), + } + c.SaveClientInfo(senderID, clientInfo) + + // 发送认证成功响应 + c.SendAuthResponse(senderID, "success", "认证成功") + + // 启动定期Token检查 + go c.startTokenCheckTimer(senderID) + + log.Printf("🔐 客户端认证信息已保存: \n ClientID=%s \n UserInfo=%s", senderID, userInfo) } return } @@ -332,8 +658,8 @@ func (c *WebSocketController) handleClientMessage(senderID string, message []byt SenderUserID: payload.SenderUserID, ReceiverUserID: payload.ReceiverUserID, MessageType: payload.MessageType, - Content: payload.MessageContent, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: payload.MessageContent, + CreatedAt: time.Now().Format(time.RFC3339), } c.sendMessageToClient(payload.TargetClientID, clientMsg) @@ -350,8 +676,8 @@ func (c *WebSocketController) handleClientMessage(senderID string, message []byt SenderUserID: payload.SenderUserID, ReceiverUserID: payload.ReceiverUserID, MessageType: payload.MessageType, - Content: payload.MessageContent, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: payload.MessageContent, + CreatedAt: time.Now().Format(time.RFC3339), } c.sendMessageToUser(payload.ReceiverUserID, clientMsg) @@ -431,8 +757,8 @@ func (c *WebSocketController) handleCallInvite(senderID, senderUserID string, si SenderUserID: senderUserID, ReceiverUserID: signal.CalleeID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "invite", } @@ -457,8 +783,8 @@ func (c *WebSocketController) handleCallAccept(senderID, senderUserID string, si SenderUserID: senderUserID, ReceiverUserID: signal.CallerID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "accepted", } @@ -483,8 +809,8 @@ func (c *WebSocketController) handleCallReject(senderID, senderUserID string, si SenderUserID: senderUserID, ReceiverUserID: signal.CallerID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "rejected", } @@ -509,8 +835,8 @@ func (c *WebSocketController) handleCallEnd(senderID, senderUserID string, signa SenderUserID: senderUserID, ReceiverUserID: signal.CalleeID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "ended", } @@ -535,8 +861,8 @@ func (c *WebSocketController) handleCallOffer(senderID, senderUserID string, sig SenderUserID: senderUserID, ReceiverUserID: signal.CalleeID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "offer", } @@ -561,8 +887,8 @@ func (c *WebSocketController) handleCallAnswer(senderID, senderUserID string, si SenderUserID: senderUserID, ReceiverUserID: signal.CallerID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "answer", } @@ -587,8 +913,8 @@ func (c *WebSocketController) handleCallCandidate(senderID, senderUserID string, SenderUserID: senderUserID, ReceiverUserID: signal.CalleeID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "candidate", } @@ -613,8 +939,8 @@ func (c *WebSocketController) handleCallHangup(senderID, senderUserID string, si SenderUserID: senderUserID, ReceiverUserID: signal.CalleeID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "hangup", } @@ -639,8 +965,8 @@ func (c *WebSocketController) handleCallDisconnected(senderID, senderUserID stri SenderUserID: senderUserID, ReceiverUserID: signal.CalleeID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "disconnected", } @@ -665,8 +991,8 @@ func (c *WebSocketController) handleCallTerminated(senderID, senderUserID string SenderUserID: senderUserID, ReceiverUserID: signal.CalleeID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "terminated", } @@ -691,8 +1017,8 @@ func (c *WebSocketController) handleCallNoAnswer(senderID, senderUserID string, SenderUserID: senderUserID, ReceiverUserID: signal.CallerID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "no-answer", } @@ -717,8 +1043,8 @@ func (c *WebSocketController) handleCallBusy(senderID, senderUserID string, sign SenderUserID: senderUserID, ReceiverUserID: signal.CallerID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "busy", } @@ -743,8 +1069,8 @@ func (c *WebSocketController) handleCallFailed(senderID, senderUserID string, si SenderUserID: senderUserID, ReceiverUserID: signal.CalleeID, MessageType: signal.CallType, - Content: signal.Data, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: signal.Data, + CreatedAt: time.Now().Format(time.RFC3339), CallID: signal.CallID, CallStatus: "failed", } @@ -789,8 +1115,8 @@ func (c *WebSocketController) SendMessageHandler(ctx *gin.Context) { SenderUserID: payload.SenderUserID, ReceiverUserID: payload.ReceiverUserID, MessageType: payload.MessageType, - Content: payload.MessageContent, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: payload.MessageContent, + CreatedAt: time.Now().Format(time.RFC3339), } var result string @@ -919,8 +1245,8 @@ func (c *WebSocketController) SendToUserHandler(ctx *gin.Context) { ReceiverUserID: payload.ReceiverUserID, Duration: payload.Duration, MessageType: payload.MessageType, - Content: payload.MessageContent, - SendTime: time.Now().Format(time.RFC3339), + MessageContent: payload.MessageContent, + CreatedAt: time.Now().Format(time.RFC3339), } // 如果是通话信令消息,添加通话相关字段 @@ -1061,6 +1387,10 @@ func (c *WebSocketController) removeClient(clientID string) { // 清理用户绑定关系 c.cleanupUserBinding(clientID) + + // 清理客户端认证信息 + c.RemoveClientInfo(clientID) + log.Printf("🧹 已清理客户端认证信息: ClientID=%s", clientID) } } @@ -1154,7 +1484,7 @@ func (c *WebSocketController) sendMessageToClient(clientID string, message model } log.Printf("✅ 消息已发送: \n ClientID=%s \n MsgType=%d \n Content=%s", - clientID, message.MessageType, message.Content) + clientID, message.MessageType, message.MessageContent) return nil } @@ -1279,3 +1609,148 @@ func (c *WebSocketController) SubscribeToRedis() { } }() } + +/** + * OpenAPIAuthMiddleware + * 功能:Open API HMAC 签名验证中间件 + * 用于保护 /open-api 分组下的接口 + */ +func (c *WebSocketController) OpenAPIAuthMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + // 1. 获取请求头中的签名信息 + timestamp := ctx.GetHeader("X-Timestamp") + signature := ctx.GetHeader("X-Signature") + + if timestamp == "" || signature == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "缺少签名信息"}) + ctx.Abort() + return + } + + // 2. 验证时间戳(防重放攻击,允许5分钟误差) + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "无效的时间戳"}) + ctx.Abort() + return + } + + tolerance := int64(utils.GetEnvInt("OPEN_API_TIMESTAMP_TOLERANCE", 300)) + now := time.Now().Unix() + diff := now - ts + if diff < 0 { + diff = -diff + } + if diff > tolerance { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "请求已过期"}) + ctx.Abort() + return + } + + // 3. 读取请求体 + body, err := io.ReadAll(ctx.Request.Body) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"}) + ctx.Abort() + return + } + // 重新设置请求体,供后续处理器使用 + ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + + // 4. 计算签名 + secretKey := utils.GetEnv("OPEN_API_SECRET_KEY", "") + if secretKey == "" { + log.Printf("⚠️ OPEN_API_SECRET_KEY 未配置") + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "服务配置错误"}) + ctx.Abort() + return + } + + // 签名算法: HMAC-SHA256(secret, timestamp + body) + message := timestamp + string(body) + h := hmac.New(sha256.New, []byte(secretKey)) + h.Write([]byte(message)) + expectedSignature := hex.EncodeToString(h.Sum(nil)) + + // 5. 验证签名 + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + log.Printf("❌ Open API 签名验证失败: expected=%s, got=%s", expectedSignature, signature) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "签名验证失败"}) + ctx.Abort() + return + } + + log.Printf("✅ Open API 签名验证通过") + ctx.Next() + } +} + +/** + * APITokenAuthMiddleware + * 功能:API Token 验证中间件 + * 用于客户端调用的接口,验证用户 Token + * Token 从请求头 Authorization 获取 + * 用户类型从请求体 sender_user_id 推断(doctor- 开头为 doctor,否则为 user) + */ +func (c *WebSocketController) APITokenAuthMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + // 1. 获取 Token(从 Header 的 Authorization 字段) + token := ctx.GetHeader("Authorization") + if token == "" { + token = ctx.GetHeader("authorization") // 兼容小写 + } + + // 移除 Bearer 前缀 + if len(token) > 7 && (token[:7] == "Bearer " || token[:7] == "bearer ") { + token = token[7:] + } + + if token == "" { + log.Printf("❌ API Token 验证失败: Token 为空") + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "缺少认证信息"}) + ctx.Abort() + return + } + + // 2. 读取请求体获取 sender_user_id 以推断用户类型 + body, err := io.ReadAll(ctx.Request.Body) + if err != nil { + log.Printf("❌ API Token 验证失败: 读取请求体失败") + ctx.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"}) + ctx.Abort() + return + } + // 重新设置请求体,供后续处理器使用 + ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + + // 3. 从请求体解析 sender_user_id + var payload struct { + SenderUserID string `json:"sender_user_id"` + } + userType := "user" // 默认为 user + if err := json.Unmarshal(body, &payload); err == nil && payload.SenderUserID != "" { + // 根据 sender_user_id 前缀判断用户类型 + if len(payload.SenderUserID) > 7 && payload.SenderUserID[:7] == "doctor-" { + userType = "doctor" + } + } + + log.Printf("🔐 API Token 验证: UserType=%s | SenderUserID=%s", userType, payload.SenderUserID) + + // 4. 验证 Token + userInfo, isValid := c.ValidateTokenByUserType(token, userType) + if !isValid { + log.Printf("❌ API Token 验证失败: Token 无效 | UserType=%s", userType) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Token 验证失败,请重新登录"}) + ctx.Abort() + return + } + + // 5. 将用户信息存入上下文,供后续处理器使用 + ctx.Set("userInfo", userInfo) + ctx.Set("userType", userType) + + log.Printf("✅ API Token 验证通过 | UserType=%s", userType) + ctx.Next() + } +} diff --git a/go.mod b/go.mod index 45b9017..2bc25a9 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 2b2b095..d33a67a 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/models/messages.go b/models/messages.go index 8bce3b1..4d20984 100644 --- a/models/messages.go +++ b/models/messages.go @@ -13,14 +13,16 @@ import "time" */ // 统一消息结构(发送方使用) type SendMessagePayload struct { - RequestType string `json:"request_type"` // 消息类型 - TargetClientID string `json:"target_client_id"` // 目标客户端ID - SenderUserID string `json:"sender_user_id"` // 发送者用户ID - ReceiverUserID string `json:"receiver_user_id"` // 接收者用户ID - MessageType int `json:"message_type"` // 消息类型(0-9) - MessageContent string `json:"message_content"` // 消息内容 - RoomId string `json:"room_id"` // 发送者用户ID - Duration int `json:"duration"` // 时长(音频、视频、音视频通话) + RequestType string `json:"request_type"` // 消息类型 + TargetClientID string `json:"target_client_id"` // 目标客户端ID + SenderUserID string `json:"sender_user_id"` // 发送者用户ID + ReceiverUserID string `json:"receiver_user_id"` // 接收者用户ID + MessageType int `json:"message_type"` // 消息类型(0-13) + MessageContent string `json:"message_content"` // 消息内容 + RoomId string `json:"room_id"` // 房间ID + Duration int `json:"duration"` // 时长(音频、视频、音视频通话) + Token string `json:"token,omitempty"` // 认证Token + UserType string `json:"user_type,omitempty"` // 用户类型: user/doctor // 通话专用字段 CallID string `json:"call_id,omitempty"` // 通话唯一ID CallStatus string `json:"call_status,omitempty"` // 通话状态:invite/accepted/rejected/ended/candidate @@ -36,7 +38,7 @@ type SendToUserPayload struct { Duration int `json:"duration"` // 时长(音频、视频、音视频通话) SenderUserID string `json:"sender_user_id"` // 发送者用户ID ReceiverUserID string `json:"receiver_user_id"` // 接收者用户ID - MessageType int `json:"message_type"` // 消息类型(0-9) + MessageType int `json:"message_type"` // 消息类型(0-13) MessageContent string `json:"message_content"` // 消息内容 // 通话专用字段 CallID string `json:"call_id,omitempty"` // 通话唯一ID @@ -75,9 +77,9 @@ type ClientReceivedMessage struct { ReceiverID string `json:"receiver_id"` // 接收者连接ID SenderUserID string `json:"sender_user_id"` // 发送者用户ID ReceiverUserID string `json:"receiver_user_id"` // 接收者用户ID - MessageType int `json:"message_type"` // 消息类型(0-9) - Content string `json:"content"` // 消息内容 - SendTime string `json:"send_time"` // 发送时间 + MessageType int `json:"message_type"` // 消息类型(0-13) + MessageContent string `json:"message_content"` // 消息内容 + CreatedAt string `json:"created_at"` // 创建时间 Duration int `json:"duration"` // 时长(音频、视频、音视频通话) // 通话专用字段 @@ -101,6 +103,33 @@ type RedisMessage struct { type BindRequest struct { UserID string `json:"user_id"` // 用户ID ClientID string `json:"client_id"` // 客户端ID + Token string `json:"token"` // 认证Token +} + +/** + * AuthResponse + * 结构体:认证响应结构 + * 用途:返回给客户端的认证状态 + */ +type AuthResponse struct { + AuthStatus string `json:"auth_status"` // 认证状态: success/failed/token_expired + Message string `json:"message"` // 消息说明 + ClientID string `json:"clientId,omitempty"` // 客户端ID +} + +/** + * ClientInfo + * 结构体:客户端连接信息(包含认证信息) + * 用途:存储已连接客户端的认证状态和token + */ +type ClientInfo struct { + ClientID string `json:"client_id"` // 客户端ID + UserID string `json:"user_id"` // 用户ID + UserType string `json:"user_type"` // 用户类型: user/doctor + Token string `json:"token"` // 认证Token + IsAuth bool `json:"is_auth"` // 是否已认证 + AuthTime time.Time `json:"auth_time"` // 认证时间 + LastCheckTime time.Time `json:"last_check_time"` // 上次检查时间 } /** @@ -146,21 +175,34 @@ const ( // 消息类型常量 const ( - MessageTypeText = 0 // 文本消息 - MessageTypeImage = 1 // 图片消息 - MessageTypeAudio = 2 // 音频消息 - MessageTypeVideo = 3 // 视频消息 - MessageTypePrescription = 4 // 处方消息 - MessageTypeMedicalRecord = 5 // 病例消息 - MessageTypeVideoCall = 6 // 视频通话 - MessageTypeAudioCall = 7 // 语音通话 - MessageTypeFile = 8 // 文件消息 + MessageTypeText = 0 // 文本消息 + MessageTypeImage = 1 // 图片消息 + MessageTypeAudio = 2 // 音频消息 + MessageTypeVideo = 3 // 视频消息 + MessageTypePrescription = 4 // 处方消息 + MessageTypeMedicalRecord = 5 // 病例消息 + MessageTypeVideoCall = 6 // 视频通话 + MessageTypeAudioCall = 7 // 语音通话 + MessageTypeFile = 8 // 文件消息 + MessageTypeSystem = 9 // 系统消息 + MessageTypeRegister = 10 // 挂号信息 + MessageTypePatientExperience = 11 // 患者就诊经历卡片 + MessageTypeProductCard = 12 // 商品卡片 + MessageTypeEndConsultation = 13 // 结束问诊卡片 ) // Redis键常量 const ( - ClientUserKey = "client_user_mapping" // clientid -> userid映射 - UserClientKey = "user_client_mapping" // userid -> clientid列表 + ClientUserKey = "client_user_mapping" // clientid -> userid映射 + UserClientKey = "user_client_mapping" // userid -> clientid列表 + TokenKeyPrefix = "xiaokang_database_xk_user_" // Mobile token存储前缀 (Redis DB 1) - 包含Laravel全局前缀 + AdminTokenKeyPrefix = "xiaokang_database_xk_login_" // Admin token存储前缀 (Redis DB 0) - 包含Laravel全局前缀 +) + +// 用户类型常量 +const ( + UserTypeDoctor = "doctor" // 后台用户类型(PC管理端) + UserTypeUser = "user" // 小程序用户类型 ) // 响应结构 diff --git a/route/route.go b/route/route.go index 95db3a3..1349592 100644 --- a/route/route.go +++ b/route/route.go @@ -20,17 +20,29 @@ func SetupRoutes(router *gin.Engine, wsCtrl *controller.WebSocketController) { router.Use(gin.Recovery()) router.GET("/ws", wsCtrl.HandleWebSocket) - // API 分组路由 + + // 内部 API(公开接口,不需要验证) apiGroup := router.Group("/api") { apiGroup.POST("/send", wsCtrl.SendMessageHandler) apiGroup.POST("/bind", wsCtrl.BindHandler) - apiGroup.POST("/send-to-user", wsCtrl.SendToUserHandler) apiGroup.GET("/health", wsCtrl.HealthHandler) apiGroup.GET("/check-user-online", wsCtrl.IsUserOnline) - - // 新增的消息路由 apiGroup.GET("/messages", wsCtrl.GetMessagesHandler) apiGroup.GET("/messages/sync", wsCtrl.SyncMessagesHandler) } + + // 客户端 API(需要 Token 验证) + clientApiGroup := router.Group("/api") + clientApiGroup.Use(wsCtrl.APITokenAuthMiddleware()) + { + clientApiGroup.POST("/send-to-user", wsCtrl.SendToUserHandler) // 客户端发送消息 + } + + // Open API(需要 HMAC 签名验证) + openApiGroup := router.Group("/open-api") + openApiGroup.Use(wsCtrl.OpenAPIAuthMiddleware()) + { + openApiGroup.POST("/send-to-user", wsCtrl.SendToUserHandler) + } } diff --git a/utils/env.go b/utils/env.go index a9bb410..0bdc391 100644 --- a/utils/env.go +++ b/utils/env.go @@ -4,6 +4,7 @@ import ( "github.com/joho/godotenv" "log" "os" + "strconv" ) func GetEnv(key, defaultValue string) string { @@ -18,3 +19,16 @@ func GetEnv(key, defaultValue string) string { } return defaultValue } + +// GetEnvInt 获取整数类型的环境变量 +func GetEnvInt(key string, defaultVal int) int { + val := os.Getenv(key) + if val == "" { + return defaultVal + } + intVal, err := strconv.Atoi(val) + if err != nil { + return defaultVal + } + return intVal +}