251 lines
8.8 KiB
Go
251 lines
8.8 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/gochat/gochat/internal/auth"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// WSClaims represents authenticated WebSocket connection claims.
|
|
// Extends auth.Claims with PubsubToken for contact-based auth
|
|
// (mirrors Chatwoot's RoomChannel where contacts connect via pubsub_token).
|
|
type WSClaims struct {
|
|
UserID uint `json:"user_id"`
|
|
AccountID uint `json:"account_id"`
|
|
Role string `json:"role"`
|
|
Provider string `json:"provider"`
|
|
PubsubToken string `json:"pubsub_token,omitempty"` // contact auth token (Chatwoot RoomChannel)
|
|
IsContact bool `json:"is_contact"` // true when authenticated via pubsub_token
|
|
ContactID uint `json:"contact_id,omitempty"` // resolved contact ID for contact auth
|
|
InboxID uint `json:"inbox_id,omitempty"` // resolved inbox ID for contact auth
|
|
}
|
|
|
|
// WSAuthenticator handles WebSocket authentication and authorization.
|
|
// Reference: Chatwoot ActionCable RoomChannel — authenticates both
|
|
// agent users (via JWT) and contacts (via pubsub_token).
|
|
type WSAuthenticator struct {
|
|
jwtService *auth.JWTService
|
|
contactInboxRepo *repository.ContactInboxRepo
|
|
}
|
|
|
|
// NewWSAuthenticator creates a new WebSocket authenticator.
|
|
func NewWSAuthenticator(jwtService *auth.JWTService, contactInboxRepo *repository.ContactInboxRepo) *WSAuthenticator {
|
|
return &WSAuthenticator{
|
|
jwtService: jwtService,
|
|
contactInboxRepo: contactInboxRepo,
|
|
}
|
|
}
|
|
|
|
// Authenticate validates WebSocket upgrade request parameters and returns WSClaims.
|
|
// It supports two authentication paths (mirrors Chatwoot's RoomChannel):
|
|
//
|
|
// 1. Agent/User auth (primary): JWT token from 'token' query param or Authorization header.
|
|
// Validates via jwtService.ValidateAccessToken, populates WSClaims from auth.Claims.
|
|
//
|
|
// 2. Contact auth (secondary): pubsub_token + user_id query params.
|
|
// Looks up ContactInbox by pubsub_token, verifies the contact belongs to the account,
|
|
// populates WSClaims with contact identity.
|
|
//
|
|
// Returns WSClaims on success, or an error suitable for HTTP 401 rejection.
|
|
func (a *WSAuthenticator) Authenticate(c *gin.Context) (*WSClaims, error) {
|
|
// --- Path 1: Agent/User authentication via JWT ---
|
|
token := extractWSToken(c)
|
|
if token != "" {
|
|
claims, err := a.jwtService.ValidateAccessToken(token)
|
|
if err != nil {
|
|
logger.L().Debugf("ws auth: JWT validation failed: %v", err)
|
|
return nil, fmt.Errorf("invalid JWT token: %w", err)
|
|
}
|
|
|
|
wsClaims := &WSClaims{
|
|
UserID: claims.UserID,
|
|
AccountID: claims.AccountID,
|
|
Role: claims.Role,
|
|
Provider: claims.Provider,
|
|
IsContact: false,
|
|
}
|
|
|
|
// Also extract pubsub_token and user_id if present (for dual auth context)
|
|
pubsubToken := c.Query("pubsub_token")
|
|
if pubsubToken != "" {
|
|
wsClaims.PubsubToken = pubsubToken
|
|
}
|
|
|
|
logger.L().Infof("ws auth: agent authenticated (user_id=%d, account_id=%d, role=%s)",
|
|
wsClaims.UserID, wsClaims.AccountID, wsClaims.Role)
|
|
return wsClaims, nil
|
|
}
|
|
|
|
// --- Path 2: Contact authentication via pubsub_token ---
|
|
pubsubToken := c.Query("pubsub_token")
|
|
if pubsubToken == "" {
|
|
return nil, errors.New("authentication required: provide 'token' (JWT) or 'pubsub_token' + 'user_id' params")
|
|
}
|
|
|
|
// Contact auth requires user_id param (Chatwoot RoomChannel: contact_id from params)
|
|
userIDStr := c.Query("user_id")
|
|
if userIDStr == "" {
|
|
return nil, errors.New("contact auth requires 'user_id' parameter alongside 'pubsub_token'")
|
|
}
|
|
|
|
contactID, err := strconv.ParseUint(userIDStr, 10, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid user_id parameter: %w", err)
|
|
}
|
|
|
|
// Lookup ContactInbox by pubsub_token
|
|
contactInbox, err := a.findContactInboxByPubsubToken(c.Request.Context(), pubsubToken)
|
|
if err != nil {
|
|
logger.L().Debugf("ws auth: contact inbox lookup failed for pubsub_token: %v", err)
|
|
return nil, fmt.Errorf("invalid pubsub_token: %w", err)
|
|
}
|
|
|
|
// Verify the contact ID matches
|
|
if contactInbox.ContactID != uint(contactID) {
|
|
logger.L().Debugf("ws auth: contact mismatch (expected=%d, found=%d)", uint(contactID), contactInbox.ContactID)
|
|
return nil, errors.New("pubsub_token does not belong to the specified contact")
|
|
}
|
|
|
|
wsClaims := &WSClaims{
|
|
UserID: uint(contactID), // for contacts, UserID maps to contact_id (Chatwoot convention)
|
|
AccountID: contactInbox.Contact.AccountID,
|
|
Role: "contact",
|
|
Provider: "pubsub_token",
|
|
PubsubToken: pubsubToken,
|
|
IsContact: true,
|
|
ContactID: contactInbox.ContactID,
|
|
InboxID: contactInbox.InboxID,
|
|
}
|
|
|
|
logger.L().Infof("ws auth: contact authenticated (contact_id=%d, account_id=%d, inbox_id=%d)",
|
|
wsClaims.ContactID, wsClaims.AccountID, wsClaims.InboxID)
|
|
return wsClaims, nil
|
|
}
|
|
|
|
// Authorize verifies the authenticated user/contact has access to the requested account.
|
|
// For agent auth: verifies the account_id param matches the JWT claims' AccountID.
|
|
// For contact auth: verifies the contact's account matches the requested account_id.
|
|
//
|
|
// Call this after Authenticate succeeds, before upgrading the WebSocket connection.
|
|
// Returns nil on success, or an error suitable for HTTP 403 rejection.
|
|
func (a *WSAuthenticator) Authorize(claims *WSClaims, c *gin.Context) error {
|
|
// Extract requested account_id from query params
|
|
accountIDStr := c.Query("account_id")
|
|
if accountIDStr == "" {
|
|
// If no explicit account_id requested, use the one from claims
|
|
return nil
|
|
}
|
|
|
|
requestedAccountID, err := strconv.ParseUint(accountIDStr, 10, 32)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid account_id parameter: %w", err)
|
|
}
|
|
|
|
if claims.AccountID != uint(requestedAccountID) {
|
|
logger.L().Debugf("ws auth: account mismatch (claims=%d, requested=%d)",
|
|
claims.AccountID, uint(requestedAccountID))
|
|
return fmt.Errorf("user does not have access to account %d", requestedAccountID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AuthenticateAndServeWS is a combined authentication + upgrade handler.
|
|
// It authenticates the request, authorizes access, then upgrades to WebSocket.
|
|
// Rejects with HTTP 401 if authentication fails, 403 if authorization fails.
|
|
//
|
|
// Usage: register as a Gin handler for the WebSocket upgrade route.
|
|
// router.GET("/ws", wsAuth.AuthenticateAndServeWS(hub, upgrader, onConnect))
|
|
func (a *WSAuthenticator) AuthenticateAndServeWS(c *gin.Context) {
|
|
// Step 1: Authenticate
|
|
claims, err := a.Authenticate(c)
|
|
if err != nil {
|
|
logger.L().Errorf("ws: authentication failed: %v", err)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Step 2: Authorize
|
|
if err := a.Authorize(claims, c); err != nil {
|
|
logger.L().Errorf("ws: authorization failed: %v", err)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Authentication and authorization succeeded — set claims in context
|
|
// for downstream handler (e.g. the actual upgrade handler) to use.
|
|
c.Set("ws_claims", claims)
|
|
c.Set("ws_authenticated", true)
|
|
c.Next()
|
|
}
|
|
|
|
// findContactInboxByPubsubToken looks up a ContactInbox by its PubsubToken field.
|
|
// Preloads the associated Contact to resolve the AccountID for authorization.
|
|
func (a *WSAuthenticator) findContactInboxByPubsubToken(ctx context.Context, pubsubToken string) (*model.ContactInbox, error) {
|
|
return a.contactInboxRepo.FindByPubsubToken(ctx, pubsubToken)
|
|
}
|
|
|
|
// extractWSToken pulls the JWT token from websocket upgrade request.
|
|
// Order of precedence:
|
|
// 1. 'token' query parameter (browser WebSocket API can't set custom headers)
|
|
// 2. Authorization header (Bearer token, for non-browser clients)
|
|
// 3. Sec-WebSocket-Protocol header (some ActionCable-compatible clients)
|
|
func extractWSToken(c *gin.Context) string {
|
|
// Primary: 'token' query param
|
|
token := c.Query("token")
|
|
if token != "" {
|
|
return token
|
|
}
|
|
|
|
// Fallback: Authorization header
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader != "" {
|
|
parts := strings.SplitN(authHeader, " ", 2)
|
|
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
|
return parts[1]
|
|
}
|
|
}
|
|
|
|
// Fallback: Sec-WebSocket-Protocol header (ActionCable convention)
|
|
proto := c.GetHeader("Sec-WebSocket-Protocol")
|
|
if proto != "" {
|
|
token = strings.TrimSpace(proto)
|
|
if token != "" {
|
|
return token
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ParseWSQueryParams extracts all WebSocket-relevant query parameters
|
|
// from the upgrade request URL. Useful for subscription authorization
|
|
// after initial authentication.
|
|
// Reference: Chatwoot RoomChannel subscribe params — account_id, conversation_id, etc.
|
|
func ParseWSQueryParams(query url.Values) map[string]string {
|
|
params := make(map[string]string)
|
|
for _, key := range []string{
|
|
"account_id",
|
|
"conversation_id",
|
|
"inbox_id",
|
|
"pubsub_token",
|
|
"user_id",
|
|
"token",
|
|
} {
|
|
if v := query.Get(key); v != "" {
|
|
params[key] = v
|
|
}
|
|
}
|
|
return params
|
|
} |