From f05014f412677c20b14f2efd112a3fa8d6757ea7 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 20 Jul 2025 17:44:36 +0530 Subject: [PATCH] refactor: implement widget authentication middleware with standard HTTP headers - Add widgetAuth middleware to handle JWT and inbox validation consistently - Move authentication logic from request body to standard HTTP headers: * JWT: Authorization: Bearer * Inbox ID: X-Libredesk-Inbox-ID: - Refactor all widget handlers to use middleware context instead of duplicate auth code - Frontend now sends auth headers via HTTP interceptor for all widget requests --- cmd/chat.go | 267 +++++++++----------------- cmd/handlers.go | 12 +- cmd/widget_middleware.go | 153 +++++++++++++++ frontend/apps/widget/src/api/index.js | 47 +++-- 4 files changed, 281 insertions(+), 198 deletions(-) create mode 100644 cmd/widget_middleware.go diff --git a/cmd/chat.go b/cmd/chat.go index 3972ab47..8e17e68e 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -22,11 +22,6 @@ import ( "github.com/zerodha/fastglue" ) -type reqCommon struct { - JWT string `json:"jwt"` - InboxID int `json:"inbox_id"` -} - // Define JWT claims structure type Claims struct { UserID int `json:"user_id,omitempty"` @@ -39,24 +34,11 @@ type Claims struct { jwt.RegisteredClaims } -// Chat widget initialization request -type chatInitReq struct { - reqCommon - VisitorName string `json:"visitor_name,omitempty"` - VisitorEmail string `json:"visitor_email,omitempty"` - Message string `json:"message,omitempty"` -} - type conversationResp struct { Conversation cmodels.ChatConversation `json:"conversation"` Messages []cmodels.ChatMessage `json:"messages"` } -type chatMessageReq struct { - Message string `json:"message"` - reqCommon -} - type chatSettingsResponse struct { livechat.Config BusinessHours []bhmodels.BusinessHours `json:"business_hours,omitempty"` @@ -175,7 +157,11 @@ func handleGetChatSettings(r *fastglue.Request) error { func handleChatInit(r *fastglue.Request) error { var ( app = r.Context.(*App) - req = chatInitReq{} + req = struct { + VisitorName string `json:"visitor_name,omitempty"` + VisitorEmail string `json:"visitor_email,omitempty"` + Message string `json:"message,omitempty"` + }{} ) if err := r.Decode(&req, "json"); err != nil { @@ -187,17 +173,20 @@ func handleChatInit(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.message}"), nil, envelope.InputError) } - // Make sure the inbox is enabled and of the correct type. - inbox, err := app.inbox.GetDBRecord(req.InboxID) + // Get authenticated data from context (set by middleware) + // Middleware always validates inbox, so we can safely use non-optional getters + claims := getWidgetClaimsOptional(r) + + inboxID, err := getWidgetInboxID(r) if err != nil { - app.lo.Error("error fetching inbox", "inbox_id", req.InboxID, "error", err) - return sendErrorEnvelope(r, err) + app.lo.Error("error getting inbox ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } - if !inbox.Enabled { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) - } - if inbox.Channel != livechat.ChannelLiveChat { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + + inbox, err := getWidgetInbox(r) + if err != nil { + app.lo.Error("error getting inbox from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } var ( @@ -205,6 +194,7 @@ func handleChatInit(r *fastglue.Request) error { conversationUUID string isVisitor bool config livechat.Config + newJWT string ) // Parse inbox config @@ -213,18 +203,16 @@ func handleChatInit(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } - // Handle authenticated user. - if req.JWT != "" { - var claims Claims - var err error - - claims, err = verifyStandardJWT(req.JWT, inbox.Secret.String) + // Handle authenticated user vs visitor + if claims != nil { + // Use authenticated contact ID from middleware + contactID, err = getWidgetContactID(r) if err != nil { - app.lo.Error("invalid inbox JWT", "jwt", req.JWT, "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.messages.sessionExpired"), nil, envelope.UnauthorizedError) + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) } - // Handle existing contacts they all external user id. + // Handle existing contacts with external user id - check if we need to create user if claims.ExternalUserID != "" { // Find or create user based on external_user_id. user, err := app.user.GetByExternalID(claims.ExternalUserID) @@ -262,7 +250,6 @@ func handleChatInit(r *fastglue.Request) error { contactID = user.ID isVisitor = false } else { - contactID = claims.UserID isVisitor = claims.IsVisitor } } else { @@ -279,7 +266,7 @@ func handleChatInit(r *fastglue.Request) error { } contactID = visitor.ID secretToUse := []byte(inbox.Secret.String) - req.JWT, err = generateUserJWTWithSecret(contactID, isVisitor, time.Now().Add(87600*time.Hour), secretToUse) // 10 years + newJWT, err = generateUserJWTWithSecret(contactID, isVisitor, time.Now().Add(87600*time.Hour), secretToUse) // 10 years if err != nil { app.lo.Error("error generating visitor JWT", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.session}"), nil, envelope.GeneralError) @@ -297,7 +284,7 @@ func handleChatInit(r *fastglue.Request) error { } if userConfig.PreventMultipleConversations { - conversations, err := app.conversation.GetContactChatConversations(contactID, req.InboxID) + conversations, err := app.conversation.GetContactChatConversations(contactID, inboxID) if err != nil { userType := "visitor" if !isVisitor { @@ -316,12 +303,12 @@ func handleChatInit(r *fastglue.Request) error { } } - app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", req.InboxID, "is_visitor", isVisitor) + app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", inboxID, "is_visitor", isVisitor) // Create conversation. _, conversationUUID, err = app.conversation.CreateConversation( contactID, - req.InboxID, + inboxID, "", time.Now(), "", @@ -370,13 +357,20 @@ func handleChatInit(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - return r.SendEnvelope(map[string]any{ + // For visitors, return the new JWT. For authenticated users, no JWT is needed in response. + response := map[string]any{ "conversation": resp.Conversation, "messages": resp.Messages, "business_hours_id": resp.BusinessHoursID, "working_hours_utc_offset": resp.WorkingHoursUTCOffset, - "jwt": req.JWT, - }) + } + + // Only add JWT for visitor creation + if newJWT != "" { + response["jwt"] = newJWT + } + + return r.SendEnvelope(response) } // handleChatUpdateLastSeen updates contact last seen timestamp for a conversation @@ -384,16 +378,17 @@ func handleChatUpdateLastSeen(r *fastglue.Request) error { var ( app = r.Context.(*App) conversationUUID = r.RequestCtx.UserValue("uuid").(string) - req = reqCommon{} ) if conversationUUID == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.conversation}"), nil, envelope.InputError) } - if err := r.Decode(&req, "json"); err != nil { - app.lo.Error("error unmarshalling chat update last seen request", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + // Get authenticated data from middleware context + contactID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) } conversation, err := app.conversation.GetConversation(0, conversationUUID) @@ -402,26 +397,6 @@ func handleChatUpdateLastSeen(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - inbox, err := app.inbox.GetDBRecord(conversation.InboxID) - if err != nil { - app.lo.Error("error fetching inbox", "inbox_id", conversation.InboxID, "error", err) - return sendErrorEnvelope(r, err) - } - - // Verify JWT. - claims, err := verifyStandardJWT(req.JWT, inbox.Secret.String) - if err != nil { - app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - - // Resolve user ID from JWT claims (handles both user_id and external_user_id) - contactID, err := resolveUserIDFromClaims(app, claims) - if err != nil { - app.lo.Error("error resolving user ID from JWT claims", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - // Make sure the conversation belongs to the contact. if conversation.ContactID != contactID { app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID) @@ -442,47 +417,26 @@ func handleChatGetConversation(r *fastglue.Request) error { var ( app = r.Context.(*App) conversationUUID = r.RequestCtx.UserValue("uuid").(string) - chatReq = chatInitReq{} ) if conversationUUID == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "conversation_id is required", nil, envelope.InputError) } - // Decode chat request if present - if err := r.Decode(&chatReq, "json"); err != nil { - app.lo.Error("error unmarshalling chat request", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + // Get authenticated data from middleware context + contactID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) } - // Fetch conversation first to get inbox info + // Fetch conversation conversation, err := app.conversation.GetConversation(0, conversationUUID) if err != nil { app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err) return sendErrorEnvelope(r, err) } - // Get inbox to retrieve secret for JWT verification - inbox, err := app.inbox.GetDBRecord(conversation.InboxID) - if err != nil { - app.lo.Error("error fetching inbox", "inbox_id", conversation.InboxID, "error", err) - return sendErrorEnvelope(r, err) - } - - // Verify JWT. - claims, err := verifyStandardJWT(chatReq.JWT, inbox.Secret.String) - if err != nil { - app.lo.Error("invalid JWT", "jwt", chatReq.JWT, "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - - // Resolve user ID from JWT claims (handles both user_id and external_user_id) - contactID, err := resolveUserIDFromClaims(app, claims) - if err != nil { - app.lo.Error("error resolving user ID from JWT claims", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - // Make sure the conversation belongs to the contact. if conversation.ContactID != contactID { app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID) @@ -502,37 +456,23 @@ func handleChatGetConversation(r *fastglue.Request) error { func handleGetConversations(r *fastglue.Request) error { var ( app = r.Context.(*App) - req = reqCommon{} ) - if err := r.Decode(&req, "json"); err != nil { - app.lo.Error("error unmarshalling chat conversations request", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + // Get authenticated data from middleware context + contactID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) } - // Get inbox to retrieve secret for JWT verification - inbox, err := app.inbox.GetDBRecord(req.InboxID) + inboxID, err := getWidgetInboxID(r) if err != nil { - app.lo.Error("error fetching inbox", "inbox_id", req.InboxID, "error", err) - return sendErrorEnvelope(r, err) - } - - // Verify JWT. - claims, err := verifyStandardJWT(req.JWT, inbox.Secret.String) - if err != nil { - app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - - // Resolve user ID from JWT claims (handles both user_id and external_user_id) - contactID, err := resolveUserIDFromClaims(app, claims) - if err != nil { - app.lo.Error("error resolving user ID from JWT claims", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) + app.lo.Error("error getting inbox ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } // Fetch conversations for the contact and convert to ChatConversation format. - chatConversations, err := app.conversation.GetContactChatConversations(contactID, req.InboxID) + chatConversations, err := app.conversation.GetContactChatConversations(contactID, inboxID) if err != nil { app.lo.Error("error fetching conversations for contact", "contact_id", contactID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError) @@ -546,9 +486,10 @@ func handleChatSendMessage(r *fastglue.Request) error { var ( app = r.Context.(*App) conversationUUID = r.RequestCtx.UserValue("uuid").(string) - req = chatMessageReq{} - senderType = cmodels.SenderTypeContact - senderID = 0 + req = struct { + Message string `json:"message"` + }{} + senderType = cmodels.SenderTypeContact ) if err := r.Decode(&req, "json"); err != nil { @@ -560,33 +501,26 @@ func handleChatSendMessage(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.message}"), nil, envelope.InputError) } - // Fetch conversation to ensure it exists and get inbox info. + // Get authenticated data from middleware context + senderID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + inbox, err := getWidgetInbox(r) + if err != nil { + app.lo.Error("error getting inbox from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + // Fetch conversation to ensure it exists conversation, err := app.conversation.GetConversation(0, conversationUUID) if err != nil { app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err) return sendErrorEnvelope(r, err) } - // Get inbox to retrieve secret for JWT verification - inbox, err := app.inbox.GetDBRecord(conversation.InboxID) - if err != nil { - app.lo.Error("error fetching inbox", "inbox_id", conversation.InboxID, "error", err) - return sendErrorEnvelope(r, err) - } - - claims, err := verifyStandardJWT(req.JWT, inbox.Secret.String) - if err != nil { - app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - - // Resolve user ID from JWT claims (handles both user_id and external_user_id) - senderID, err = resolveUserIDFromClaims(app, claims) - if err != nil { - app.lo.Error("error resolving user ID from JWT claims", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - // Fetch sender. sender, err := app.user.Get(senderID, "", "") if err != nil { @@ -655,8 +589,7 @@ func handleChatSendMessage(r *fastglue.Request) error { // handleWidgetMediaUpload handles media uploads for the widget. func handleWidgetMediaUpload(r *fastglue.Request) error { var ( - app = r.Context.(*App) - senderID = 0 + app = r.Context.(*App) ) form, err := r.RequestCtx.MultipartForm() @@ -665,12 +598,18 @@ func handleWidgetMediaUpload(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError) } - // Get JWT token from form data - jwtValues, jwtOk := form.Value["jwt"] - if !jwtOk || len(jwtValues) == 0 || jwtValues[0] == "" { - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) + // Get authenticated data from middleware context + senderID, err := getWidgetContactID(r) + if err != nil { + app.lo.Error("error getting contact ID from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + + inbox, err := getWidgetInbox(r) + if err != nil { + app.lo.Error("error getting inbox from middleware context", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } - jwtToken := jwtValues[0] // Get conversation UUID from form data conversationValues, convOk := form.Value["conversation_uuid"] @@ -686,32 +625,6 @@ func handleWidgetMediaUpload(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Get inbox to retrieve secret for JWT verification - inbox, err := app.inbox.GetDBRecord(conversation.InboxID) - if err != nil { - app.lo.Error("error fetching inbox", "inbox_id", conversation.InboxID, "error", err) - return sendErrorEnvelope(r, err) - } - - // Make sure the inbox is enabled. - if !inbox.Enabled { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) - } - - // Verify JWT and get user information - claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String) - if err != nil { - app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - - // Resolve user ID from JWT claims (handles both user_id and external_user_id) - senderID, err = resolveUserIDFromClaims(app, claims) - if err != nil { - app.lo.Error("error resolving user ID from JWT claims", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) - } - if conversation.ContactID != senderID { app.lo.Error("access denied: user attempted to access conversation owned by different contact", "conversation_uuid", conversationUUID, "requesting_contact_id", senderID, "conversation_owner_id", conversation.ContactID) return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) @@ -1060,4 +973,4 @@ func generateUserJWTWithSecret(userID int, isVisitor bool, expirationTime time.T return "", err } return tokenString, nil -} \ No newline at end of file +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 04fd1e3a..6f88dc01 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -227,12 +227,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Widget APIs. g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings) g.GET("/api/v1/widget/chat/settings", handleGetChatSettings) - g.POST("/api/v1/widget/chat/conversations/init", handleChatInit) - g.POST("/api/v1/widget/chat/conversations", handleGetConversations) - g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", handleChatUpdateLastSeen) - g.POST("/api/v1/widget/chat/conversations/{uuid}", handleChatGetConversation) - g.POST("/api/v1/widget/chat/conversations/{uuid}/message", handleChatSendMessage) - g.POST("/api/v1/widget/media/upload", handleWidgetMediaUpload) + g.POST("/api/v1/widget/chat/conversations/init", widgetAuth(handleChatInit)) + g.POST("/api/v1/widget/chat/conversations", widgetAuth(handleGetConversations)) + g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", widgetAuth(handleChatUpdateLastSeen)) + g.POST("/api/v1/widget/chat/conversations/{uuid}", widgetAuth(handleChatGetConversation)) + g.POST("/api/v1/widget/chat/conversations/{uuid}/message", widgetAuth(handleChatSendMessage)) + g.POST("/api/v1/widget/media/upload", widgetAuth(handleWidgetMediaUpload)) // Frontend pages. g.GET("/", notAuthPage(serveIndexPage)) diff --git a/cmd/widget_middleware.go b/cmd/widget_middleware.go new file mode 100644 index 00000000..60b01b39 --- /dev/null +++ b/cmd/widget_middleware.go @@ -0,0 +1,153 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + imodels "github.com/abhinavxd/libredesk/internal/inbox/models" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +const ( + // Context keys for storing authenticated widget data + ctxWidgetClaims = "widget_claims" + ctxWidgetInboxID = "widget_inbox_id" + ctxWidgetContactID = "widget_contact_id" + ctxWidgetInbox = "widget_inbox" + + // Header sent in every widget request to identify the inbox + hdrWidgetInboxID = "X-Libredesk-Inbox-ID" +) + +// widgetAuth middleware authenticates widget requests using JWT and inbox validation. +// It always validates the inbox from X-Libredesk-Inbox-ID header, and conditionally validates JWT. +// For /conversations/init without JWT, it allows visitor creation while still validating inbox. +func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error { + return func(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + ) + + // Always extract and validate inbox_id from custom header + inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID)) + if inboxIDHeader == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + inboxID, err := strconv.Atoi(inboxIDHeader) + if err != nil || inboxID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Always fetch and validate inbox + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err) + return sendErrorEnvelope(r, err) + } + + if !inbox.Enabled { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Check if inbox is the correct type for widget requests + if inbox.Channel != livechat.ChannelLiveChat { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Always store inbox data in context + r.RequestCtx.SetUserValue(ctxWidgetInboxID, inboxID) + r.RequestCtx.SetUserValue(ctxWidgetInbox, inbox) + + // Extract JWT from Authorization header (Bearer token) + authHeader := string(r.RequestCtx.Request.Header.Peek("Authorization")) + + // For init endpoint, allow requests without JWT (visitor creation) + if authHeader == "" && strings.Contains(string(r.RequestCtx.Path()), "/conversations/init") { + return next(r) + } + + // For all other requests, require JWT + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) + } + jwtToken := strings.TrimPrefix(authHeader, "Bearer ") + + // Verify JWT using inbox secret + claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String) + if err != nil { + app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) + } + + // Resolve user/contact ID from JWT claims + contactID, err := resolveUserIDFromClaims(app, claims) + if err != nil { + app.lo.Error("error resolving user ID from JWT claims", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) + } + + // Store authenticated data in request context for downstream handlers + r.RequestCtx.SetUserValue(ctxWidgetClaims, claims) + r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID) + + return next(r) + } +} + +// Helper functions to extract authenticated data from request context + +// getWidgetInboxID extracts inbox ID from request context +func getWidgetInboxID(r *fastglue.Request) (int, error) { + val := r.RequestCtx.UserValue(ctxWidgetInboxID) + if val == nil { + return 0, fmt.Errorf("widget middleware not applied: missing inbox ID in context") + } + inboxID, ok := val.(int) + if !ok { + return 0, fmt.Errorf("invalid inbox ID type in context") + } + return inboxID, nil +} + +// getWidgetContactID extracts contact ID from request context +func getWidgetContactID(r *fastglue.Request) (int, error) { + val := r.RequestCtx.UserValue(ctxWidgetContactID) + if val == nil { + return 0, fmt.Errorf("widget middleware not applied: missing contact ID in context") + } + contactID, ok := val.(int) + if !ok { + return 0, fmt.Errorf("invalid contact ID type in context") + } + return contactID, nil +} + +// getWidgetInbox extracts inbox model from request context +func getWidgetInbox(r *fastglue.Request) (imodels.Inbox, error) { + val := r.RequestCtx.UserValue(ctxWidgetInbox) + if val == nil { + return imodels.Inbox{}, fmt.Errorf("widget middleware not applied: missing inbox in context") + } + inbox, ok := val.(imodels.Inbox) + if !ok { + return imodels.Inbox{}, fmt.Errorf("invalid inbox type in context") + } + return inbox, nil +} + +// getWidgetClaimsOptional extracts JWT claims from request context, returns nil if not set +func getWidgetClaimsOptional(r *fastglue.Request) *Claims { + val := r.RequestCtx.UserValue(ctxWidgetClaims) + if val == nil { + return nil + } + if claims, ok := val.(Claims); ok { + return &claims + } + return nil +} diff --git a/frontend/apps/widget/src/api/index.js b/frontend/apps/widget/src/api/index.js index ed72b48a..e46693a4 100644 --- a/frontend/apps/widget/src/api/index.js +++ b/frontend/apps/widget/src/api/index.js @@ -11,19 +11,25 @@ const http = axios.create({ responseType: 'json' }) -// Set content type if not specified and add libredesk_session to POST/PUT requests +// Set content type and authentication headers http.interceptors.request.use((request) => { if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) { request.headers['Content-Type'] = 'application/json' } - // Add libredesk_session to POST/PUT request data - if (request.method === 'post' || request.method === 'put') { + // Add authentication headers for widget API endpoints + if (request.url && request.url.includes('/api/v1/widget/')) { const libredeskSession = localStorage.getItem('libredesk_session') - request.data = { - ...request.data, - inbox_id: getInboxIDFromQuery(), - jwt: libredeskSession + const inboxId = getInboxIDFromQuery() + + // Add JWT to Authorization header + if (libredeskSession) { + request.headers['Authorization'] = `Bearer ${libredeskSession}` + } + + // Add inbox ID to custom header + if (inboxId) { + request.headers['X-Libredesk-Inbox-ID'] = inboxId.toString() } } @@ -41,22 +47,33 @@ const sendChatMessage = (uuid, data) => http.post(`/api/v1/widget/chat/conversat const closeChatConversation = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/close`) const uploadMedia = (conversationUUID, files) => { const formData = new FormData() - - // Add JWT token - const libredeskSession = localStorage.getItem('libredesk_session') - formData.append('jwt', libredeskSession) + + // Only add conversation UUID to form data now formData.append('conversation_uuid', conversationUUID) - formData.append('inbox_id', getInboxIDFromQuery()) // Add files for (let i = 0; i < files.length; i++) { formData.append('files', files[i]) } + // Get authentication data for headers + const libredeskSession = localStorage.getItem('libredesk_session') + const inboxId = getInboxIDFromQuery() + + const headers = { + 'Content-Type': 'multipart/form-data' + } + + // Add authentication headers + if (libredeskSession) { + headers['Authorization'] = `Bearer ${libredeskSession}` + } + if (inboxId) { + headers['X-Libredesk-Inbox-ID'] = inboxId.toString() + } + return axios.post('/api/v1/widget/media/upload', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - }, + headers, timeout: 30000 }) }