Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user