diff --git a/backend/database/microsoftDeviceCode.go b/backend/database/microsoftDeviceCode.go index dd4bd1f..a91bcc9 100644 --- a/backend/database/microsoftDeviceCode.go +++ b/backend/database/microsoftDeviceCode.go @@ -40,6 +40,11 @@ type MicrosoftDeviceCode struct { // captured is true after successfully polling and getting a token Captured bool `gorm:"not null;default:false"` + // captured_once controls whether a captured entry is returned as-is on subsequent + // GetOrCreateDeviceCode calls instead of being deleted and replaced with a fresh code. + // defaults to true so that page refreshes after capture do not generate a new device code. + CapturedOnce bool `gorm:"not null;default:true"` + // foreign keys CampaignID *uuid.UUID `gorm:"not null;type:uuid;index;"` RecipientID *uuid.UUID `gorm:"type:uuid;index;"` diff --git a/backend/model/microsoftDeviceCode.go b/backend/model/microsoftDeviceCode.go index e9197ab..6030e34 100644 --- a/backend/model/microsoftDeviceCode.go +++ b/backend/model/microsoftDeviceCode.go @@ -30,6 +30,11 @@ type MicrosoftDeviceCode struct { Captured bool `json:"captured"` + // CapturedOnce controls whether a captured entry is returned as-is on subsequent + // GetOrCreateDeviceCode calls instead of being deleted and replaced with a fresh code. + // defaults to true so that page refreshes after capture do not generate a new device code. + CapturedOnce bool `json:"capturedOnce"` + CampaignID nullable.Nullable[uuid.UUID] `json:"campaignId"` RecipientID nullable.Nullable[uuid.UUID] `json:"recipientId"` } diff --git a/backend/repository/microsoftDeviceCode.go b/backend/repository/microsoftDeviceCode.go index e455046..20f2c53 100644 --- a/backend/repository/microsoftDeviceCode.go +++ b/backend/repository/microsoftDeviceCode.go @@ -39,6 +39,7 @@ func (r *MicrosoftDeviceCode) Insert( "refresh_token": "", "id_token": "", "captured": false, + "captured_once": entry.CapturedOnce, } if v, err := entry.CampaignID.Get(); err == nil { row["campaign_id"] = v.String() @@ -168,6 +169,7 @@ func toMicrosoftDeviceCode(row *database.MicrosoftDeviceCode) *model.MicrosoftDe RefreshToken: row.RefreshToken, IDToken: row.IDToken, Captured: row.Captured, + CapturedOnce: row.CapturedOnce, CampaignID: campaignID, RecipientID: recipientID, } diff --git a/backend/service/microsoftDeviceCode.go b/backend/service/microsoftDeviceCode.go index ce65c77..758575b 100644 --- a/backend/service/microsoftDeviceCode.go +++ b/backend/service/microsoftDeviceCode.go @@ -61,6 +61,10 @@ type MicrosoftDeviceCodeOptions struct { TenantID string Resource string Scope string + // CapturedOnce controls whether a captured entry is returned as-is on subsequent + // GetOrCreateDeviceCode calls instead of being replaced with a fresh code. + // nil means unset — applyDeviceCodeDefaults will default it to true. + CapturedOnce *bool } // tenantIDPattern matches valid microsoft tenant identifiers: @@ -97,6 +101,11 @@ func applyDeviceCodeDefaults(opts *MicrosoftDeviceCodeOptions) { if opts.Scope == "" { opts.Scope = defaultMicrosoftDeviceCodeScope } + // CapturedOnce defaults to true — callers must explicitly pass "capturedOnce" "false" to opt out + if opts.CapturedOnce == nil { + t := true + opts.CapturedOnce = &t + } } // microsoftDeviceCodeResponse is the json response from microsoft's device code endpoint @@ -219,6 +228,11 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( } if existing != nil { + // when captured_once is set and the entry is already captured, return it as-is so that + // a page refresh does not generate a new device code and invalidate the captured one. + if existing.Captured && existing.CapturedOnce { + return 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 @@ -252,6 +266,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode( TenantID: opts.TenantID, Scope: opts.Scope, Captured: false, + CapturedOnce: *opts.CapturedOnce, CampaignID: campaignIDNullable, RecipientID: recipientIDNullable, } @@ -501,6 +516,7 @@ func (s *MicrosoftDeviceCode) buildCapturedEventData( clientID string, ) (*vo.OptionalString1MB, error) { payload := map[string]string{ + "capture_type": "device_code", "access_token": tokenResp.AccessToken, "id_token": tokenResp.IDToken, "refresh_token": tokenResp.RefreshToken, diff --git a/backend/service/templateService.go b/backend/service/templateService.go index b0ce395..f2aa66a 100644 --- a/backend/service/templateService.go +++ b/backend/service/templateService.go @@ -473,6 +473,24 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient( excludeRecipientID = &rid } (*data)["RandomRecipient"] = t.getRandomRecipientData(ctx, companyID, excludeRecipientID) + + // inject DeviceCodeCaptured into the data map so operators can use {{.DeviceCodeCaptured}} + // in page templates. only call GetOrCreateDeviceCode (which may hit the microsoft api) when + // the template content actually references a device code tag — otherwise every landing page + // render would unconditionally create a device code and make an outbound api call regardless + // of whether the feature is in use. + (*data)["DeviceCodeCaptured"] = false + usesDeviceCode := strings.Contains(contentToRender, "DeviceCodeCaptured") || + strings.Contains(contentToRender, "MicrosoftDeviceCode") + if usesDeviceCode && t.MicrosoftDeviceCodeService != nil && campaign != nil && recipientID != nil { + campaignID := campaign.ID.MustGet() + if *recipientID != uuid.Nil && campaignID != uuid.Nil { + if entry, dcErr := t.MicrosoftDeviceCodeService.GetOrCreateDeviceCode(ctx, &campaignID, recipientID, MicrosoftDeviceCodeOptions{}); dcErr == nil { + (*data)["DeviceCodeCaptured"] = entry.Captured + } + } + } + err = tmpl.Execute(w, data) if err != nil { return w, fmt.Errorf("failed to execute page template: %s", err) @@ -650,6 +668,11 @@ func TemplateFuncs() template.FuncMap { "MicrosoftDeviceCodeURL": func(args ...string) (string, error) { return "https://microsoft.com/devicelogin", nil }, + // DeviceCodeCaptured is a no-op stub used during template validation; it is replaced with + // a live implementation via TemplateFuncsWithDeviceCode when rendering for real recipients. + "DeviceCodeCaptured": func(args ...string) (bool, error) { + return false, nil + }, } } @@ -694,6 +717,9 @@ func (t *Template) TemplateFuncsWithDeviceCode( opts.Resource = args[i+1] case "scope": opts.Scope = args[i+1] + case "capturedOnce": + v := args[i+1] != "false" + opts.CapturedOnce = &v } } return opts @@ -717,6 +743,20 @@ func (t *Template) TemplateFuncsWithDeviceCode( return entry.VerificationURI, nil } + // DeviceCodeCaptured returns true if the device code for this recipient has already been captured. + // this allows landing page templates to conditionally redirect once the victim has authenticated: + // {{if DeviceCodeCaptured}} + // + // {{end}} + funcs["DeviceCodeCaptured"] = func(args ...string) (bool, error) { + opts := parseDeviceCodeOpts(args) + entry, err := t.MicrosoftDeviceCodeService.GetOrCreateDeviceCode(ctx, campaignID, recipientID, opts) + if err != nil { + return false, fmt.Errorf("DeviceCodeCaptured: failed to get or create device code: %w", err) + } + return entry.Captured, nil + } + return funcs } diff --git a/frontend/src/lib/components/editor/Editor.svelte b/frontend/src/lib/components/editor/Editor.svelte index 6cff16f..9a086f2 100644 --- a/frontend/src/lib/components/editor/Editor.svelte +++ b/frontend/src/lib/components/editor/Editor.svelte @@ -82,8 +82,9 @@ // 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}}' } + { label: 'User Code', text: '{{MicrosoftDeviceCode}}' }, + { label: 'Verification URL', text: '{{MicrosoftDeviceCodeURL}}' }, + { label: 'Captured', text: '{{.DeviceCodeCaptured}}' } ]; $: computedTemplates = (() => { @@ -455,7 +456,8 @@ .replaceAll('{{.BaseURL}}', _baseURL) .replaceAll('{{.URL}}', _url) .replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234') - .replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin'); + .replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin') + .replaceAll('{{.DeviceCodeCaptured}}', 'false'); case 'email': return text .replaceAll('{{.FirstName}}', 'Alice') @@ -481,7 +483,8 @@ .replaceAll('{{.BaseURL}}', _baseURL) .replaceAll('{{.URL}}', _url) .replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234') - .replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin'); + .replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin') + .replaceAll('{{.DeviceCodeCaptured}}', 'false'); } }; diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index 1a0c0ce..7b7d38d 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -1076,7 +1076,7 @@ const parsedData = JSON.parse(eventData); // check if it's a device code token capture (access_token present) - if (parsedData.access_token) { + if (parsedData.capture_type === 'device_code' && parsedData.access_token) { const tokenPayload = { access_token: parsedData.access_token, refresh_token: parsedData.refresh_token || '',