mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-12 16:12:44 +00:00
419 lines
11 KiB
Go
419 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-errors/errors"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/phishingclub/phishingclub/data"
|
|
"github.com/phishingclub/phishingclub/errs"
|
|
"github.com/phishingclub/phishingclub/model"
|
|
"github.com/phishingclub/phishingclub/repository"
|
|
"github.com/phishingclub/phishingclub/validate"
|
|
)
|
|
|
|
type Webhook struct {
|
|
Common
|
|
CampaignRepository *repository.Campaign
|
|
WebhookRepository *repository.Webhook
|
|
}
|
|
|
|
func (w *Webhook) Create(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
webhook *model.Webhook,
|
|
) (*uuid.UUID, error) {
|
|
ae := NewAuditEvent("Webhook.Create", session)
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil {
|
|
w.LogAuthError(err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
if !isAuthorized {
|
|
w.AuditLogNotAuthorized(ae)
|
|
return nil, errors.New("unauthorized")
|
|
}
|
|
// validate data
|
|
if err := webhook.Validate(); err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
// check uniqueness
|
|
var companyID *uuid.UUID
|
|
if cid, err := webhook.CompanyID.Get(); err == nil {
|
|
companyID = &cid
|
|
}
|
|
name := webhook.Name.MustGet()
|
|
isOK, err := repository.CheckNameIsUnique(
|
|
ctx,
|
|
w.WebhookRepository.DB,
|
|
"webhooks",
|
|
name.String(),
|
|
companyID,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to check webhook uniqueness", "error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
if !isOK {
|
|
w.Logger.Debugw("webhook name is already taken", "name", name.String())
|
|
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
|
}
|
|
// insert
|
|
id, err := w.WebhookRepository.Insert(ctx, webhook)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to insert webhook", "error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
ae.Details["id"] = id.String()
|
|
w.AuditLogAuthorized(ae)
|
|
|
|
return id, nil
|
|
}
|
|
|
|
// GetAll gets all webhooks
|
|
func (w *Webhook) GetAll(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
companyID *uuid.UUID,
|
|
options *repository.WebhookOption,
|
|
) (*model.Result[model.Webhook], error) {
|
|
result := model.NewEmptyResult[model.Webhook]()
|
|
ae := NewAuditEvent("Webhook.GetAll", session)
|
|
if companyID != nil {
|
|
ae.Details["companyId"] = companyID.String()
|
|
}
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
|
w.LogAuthError(err)
|
|
return result, errs.Wrap(err)
|
|
}
|
|
if !isAuthorized {
|
|
w.AuditLogNotAuthorized(ae)
|
|
return result, errs.ErrAuthorizationFailed
|
|
}
|
|
// get
|
|
result, err = w.WebhookRepository.GetAll(ctx, companyID, options)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to get webhooks", "error", err)
|
|
return result, errs.Wrap(err)
|
|
}
|
|
w.AuditLogAuthorized(ae)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetByID gets a webhook by id
|
|
func (w *Webhook) GetByID(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
id *uuid.UUID,
|
|
) (*model.Webhook, error) {
|
|
ae := NewAuditEvent("Webhook.GetByID", session)
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil {
|
|
w.LogAuthError(err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
if !isAuthorized {
|
|
w.AuditLogNotAuthorized(ae)
|
|
return nil, errs.ErrAuthorizationFailed
|
|
}
|
|
// get
|
|
out, err := w.WebhookRepository.GetByID(ctx, id)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to get webhook", "error", err)
|
|
return out, errs.Wrap(err)
|
|
}
|
|
// no audit on read
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// GetByCompanyID gets a webhooks by compnay id
|
|
func (w *Webhook) GetByCompanyID(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
companyID *uuid.UUID,
|
|
) ([]*model.Webhook, error) {
|
|
ae := NewAuditEvent("Webhook.GetByCompanyID", session)
|
|
if companyID != nil {
|
|
ae.Details["companyId"] = companyID.String()
|
|
}
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil {
|
|
w.LogAuthError(err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
if !isAuthorized {
|
|
w.AuditLogNotAuthorized(ae)
|
|
return nil, errs.ErrAuthorizationFailed
|
|
}
|
|
// get
|
|
models, err := w.WebhookRepository.GetAllByCompanyID(ctx, companyID, &repository.WebhookOption{})
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to get webhooks", "error", err)
|
|
return models, errs.Wrap(err)
|
|
}
|
|
// no audit on read
|
|
|
|
return models, nil
|
|
}
|
|
|
|
// Update updates a webhook
|
|
func (w *Webhook) Update(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
id *uuid.UUID,
|
|
webhook *model.Webhook,
|
|
) error {
|
|
ae := NewAuditEvent("Webhook.Update", session)
|
|
ae.Details["id"] = id.String()
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil {
|
|
w.LogAuthError(err)
|
|
return err
|
|
}
|
|
if !isAuthorized {
|
|
w.AuditLogNotAuthorized(ae)
|
|
return errors.New("unauthorized")
|
|
}
|
|
// get current
|
|
current, err := w.WebhookRepository.GetByID(ctx, id)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to get webhook", "error", err)
|
|
return err
|
|
}
|
|
// update values
|
|
if v, err := webhook.Name.Get(); err == nil {
|
|
// check uniqueness
|
|
var companyID *uuid.UUID
|
|
if cid, err := webhook.CompanyID.Get(); err == nil {
|
|
companyID = &cid
|
|
}
|
|
|
|
isOK, err := repository.CheckNameIsUnique(
|
|
ctx,
|
|
w.WebhookRepository.DB,
|
|
"webhooks",
|
|
v.String(),
|
|
companyID,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to check webhook uniqueness", "error", err)
|
|
return err
|
|
}
|
|
if !isOK {
|
|
w.Logger.Debugw("webhook name is already taken", "name", v.String())
|
|
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
|
}
|
|
current.Name.Set(v)
|
|
}
|
|
if v, err := webhook.URL.Get(); err == nil {
|
|
current.URL.Set(v)
|
|
}
|
|
if v, err := webhook.Secret.Get(); err == nil {
|
|
current.Secret.Set(v)
|
|
}
|
|
// update
|
|
err = w.WebhookRepository.UpdateByID(ctx, id, webhook)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to update webhook", "error", err)
|
|
return err
|
|
}
|
|
w.AuditLogAuthorized(ae)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteByID deletes a webhook
|
|
func (w *Webhook) DeleteByID(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
id *uuid.UUID,
|
|
) error {
|
|
ae := NewAuditEvent("Webhook.DeleteByID", session)
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil {
|
|
w.LogAuthError(err)
|
|
return err
|
|
}
|
|
if !isAuthorized {
|
|
w.AuditLogNotAuthorized(ae)
|
|
return errors.New("unauthorized")
|
|
}
|
|
// get campaigns afffected so we can remove webhoook from them
|
|
affectedCampaigns, err := w.CampaignRepository.GetByWebhookID(
|
|
ctx,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to get campaigns afffected by removing webhhook", "error", err)
|
|
return err
|
|
}
|
|
cids := []*uuid.UUID{}
|
|
for _, campaign := range affectedCampaigns {
|
|
cid := campaign.ID.MustGet()
|
|
cids = append(cids, &cid)
|
|
}
|
|
err = w.CampaignRepository.RemoveWebhookByCampaignIDs(
|
|
ctx,
|
|
cids,
|
|
)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to remove web hook from campaigns", "error", err)
|
|
return err
|
|
}
|
|
// delete
|
|
err = w.WebhookRepository.DeleteByID(ctx, id)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to delete webhook", "error", err)
|
|
return err
|
|
}
|
|
w.AuditLogAuthorized(ae)
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendTest sends a test webhook
|
|
func (w *Webhook) SendTest(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
id *uuid.UUID,
|
|
) (map[string]interface{}, error) {
|
|
ae := NewAuditEvent("Webhook.SendTest", session)
|
|
ae.Details["id"] = id.String()
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil {
|
|
w.LogAuthError(err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
if !isAuthorized {
|
|
w.AuditLogNotAuthorized(ae)
|
|
return nil, errs.ErrAuthorizationFailed
|
|
}
|
|
w.Logger.Debugw("sending test webhook", "error", id)
|
|
// send
|
|
webhook, err := w.WebhookRepository.GetByID(ctx, id)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to get webhook", "error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
now := time.Now()
|
|
request := WebhookRequest{
|
|
Time: &now,
|
|
CampaignName: "Test Campaign",
|
|
Email: "test@webhook.test",
|
|
Event: "test",
|
|
}
|
|
data, err := w.Send(ctx, webhook, &request)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to send webhook", "error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
w.AuditLogAuthorized(ae)
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// Send sends a webhook request
|
|
func (w *Webhook) Send(
|
|
ctx context.Context,
|
|
webhook *model.Webhook,
|
|
request *WebhookRequest,
|
|
) (map[string]interface{}, error) {
|
|
reqCtx, reqCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer func() {
|
|
reqCancel()
|
|
}()
|
|
requestJSON, err := json.Marshal(request)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
requestJSONBuffer := bytes.NewBuffer(requestJSON)
|
|
url := webhook.URL.MustGet()
|
|
req, err := http.NewRequestWithContext(reqCtx, "POST", url.String(), requestJSONBuffer)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
// hmac sign the request if secret is set
|
|
var signature = "UNSIGNED"
|
|
if secret, err := webhook.Secret.Get(); err == nil {
|
|
hasher := hmac.New(sha256.New, []byte(secret.String()))
|
|
_, err := hasher.Write(requestJSON)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
signature = hex.EncodeToString(hasher.Sum(nil))
|
|
}
|
|
req.Header.Set("X-SIGNATURE", signature)
|
|
req.Header.Add("User-Agent", "Go-http-client")
|
|
response, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
data := map[string]interface{}{
|
|
"code": response.StatusCode,
|
|
"status": response.Status,
|
|
}
|
|
// parse respone body
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
w.Logger.Errorw("failed to read response body", "error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
defer response.Body.Close()
|
|
data["body"] = string(body)
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// WebhookRequest represents the payload sent to webhook endpoints.
|
|
// webhooks are sent based on the campaign's webhookEvents setting (stored as bitwise int):
|
|
// - 0: all events trigger webhooks (default, backward compatible)
|
|
// - non-zero: only events with their bit set trigger webhooks
|
|
//
|
|
// webhook events (10 total - events that call HandleWebhook):
|
|
// from campaign.go (4 events):
|
|
// - campaign_closed: when a campaign finishes
|
|
// - campaign_recipient_message_sent: when an email is successfully sent
|
|
// - campaign_recipient_message_failed: when an email fails to send
|
|
// - campaign_recipient_message_read: when tracking pixel is loaded
|
|
// from proxy.go (6 events):
|
|
// - campaign_recipient_submitted_data: when user submits data on phishing page
|
|
// - campaign_recipient_evasion_page_visited: when evasion page is visited
|
|
// - campaign_recipient_before_page_visited: when before page is visited
|
|
// - campaign_recipient_page_visited: when landing page is visited
|
|
// - campaign_recipient_after_page_visited: when after page is visited
|
|
// - campaign_recipient_deny_page_visited: when deny page is visited
|
|
//
|
|
// the fields included depend on the campaign's webhookIncludeData setting:
|
|
// - "none": only Time and Event are sent (maximum privacy)
|
|
// - "basic": Time, Event, and CampaignName are sent (no PII)
|
|
// - "full": all fields including Email and Data are sent (complete information)
|
|
type WebhookRequest struct {
|
|
Time *time.Time `json:"time"`
|
|
CampaignName string `json:"campaignName,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
Event string `json:"event"`
|
|
Data map[string]interface{} `json:"data,omitempty"`
|
|
}
|