Skip to content

Commit 5b42363

Browse files
committed
feat(nudge-mfa): add group targeting and auth method selection
Expand the NudgeMFA standard to support: - Multiple authentication methods (Authenticator, Passkey) - Group-level include/exclude targeting with group name resolution - Snooze enforcement limits (max 3 snoozes) Adds Set-CIPPRegistrationCampaign helper function as a single writer for the campaign, shared by the new ExecRegistrationCampaign HTTP endpoint and the enhanced NudgeMFA standard. The standard now supports both portal-configured and template-specified group targeting, with full compliance comparison.
1 parent 610ec61 commit 5b42363

4 files changed

Lines changed: 364 additions & 36 deletions

File tree

Config/standards.json

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,21 +1371,38 @@
13711371
"cat": "Entra (AAD) Standards",
13721372
"tag": ["SMB1001 (2.5)"],
13731373
"appliesToTest": ["SMB1001_2_5", "ZTNA21889"],
1374-
"helpText": "Sets the state of the registration campaign for the tenant",
1375-
"docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.",
1374+
"helpText": "Sets the state of the registration campaign for the tenant, including the targeted authentication method, snooze settings and include/exclude groups. Leave include/exclude blank to keep the groups currently configured in the tenant, or use 'AllUsers' to target all users.",
1375+
"docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the targeted authentication method (Microsoft Authenticator or a Passkey) during sign-in. Supports limiting the number of snoozes, and including or excluding specific groups (by display name).",
13761376
"executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.",
13771377
"addedComponent": [
13781378
{
13791379
"type": "autoComplete",
13801380
"multiple": false,
13811381
"creatable": false,
1382-
"label": "Select value",
1382+
"label": "Registration campaign state",
13831383
"name": "standards.NudgeMFA.state",
13841384
"options": [
13851385
{ "label": "Enabled", "value": "enabled" },
13861386
{ "label": "Disabled", "value": "disabled" }
13871387
]
13881388
},
1389+
{
1390+
"type": "autoComplete",
1391+
"multiple": false,
1392+
"creatable": false,
1393+
"required": false,
1394+
"label": "Authentication method to nudge users to register (default is Microsoft Authenticator)",
1395+
"name": "standards.NudgeMFA.targetedAuthenticationMethod",
1396+
"options": [
1397+
{ "label": "Microsoft Authenticator", "value": "microsoftAuthenticator" },
1398+
{ "label": "Passkey (FIDO2)", "value": "fido2" }
1399+
],
1400+
"condition": {
1401+
"field": "standards.NudgeMFA.state",
1402+
"compareType": "valueEq",
1403+
"compareValue": "enabled"
1404+
}
1405+
},
13891406
{
13901407
"type": "number",
13911408
"name": "standards.NudgeMFA.snoozeDurationInDays",
@@ -1395,6 +1412,39 @@
13951412
"min": { "value": 0, "message": "Minimum value is 0" },
13961413
"max": { "value": 14, "message": "Maximum value is 14" }
13971414
}
1415+
},
1416+
{
1417+
"type": "switch",
1418+
"name": "standards.NudgeMFA.enforceRegistrationAfterAllowedSnoozes",
1419+
"label": "Limited number of snoozes (require registration after 3 snoozes)",
1420+
"defaultValue": true,
1421+
"condition": {
1422+
"field": "standards.NudgeMFA.state",
1423+
"compareType": "valueEq",
1424+
"compareValue": "enabled"
1425+
}
1426+
},
1427+
{
1428+
"type": "textField",
1429+
"name": "standards.NudgeMFA.includeTargets",
1430+
"label": "Include groups (comma separated group names, 'AllUsers' for everyone, blank = keep current targets)",
1431+
"required": false,
1432+
"condition": {
1433+
"field": "standards.NudgeMFA.state",
1434+
"compareType": "valueEq",
1435+
"compareValue": "enabled"
1436+
}
1437+
},
1438+
{
1439+
"type": "textField",
1440+
"name": "standards.NudgeMFA.excludeTargets",
1441+
"label": "Exclude groups (comma separated group names, blank = keep current exclusions)",
1442+
"required": false,
1443+
"condition": {
1444+
"field": "standards.NudgeMFA.state",
1445+
"compareType": "valueEq",
1446+
"compareValue": "enabled"
1447+
}
13981448
}
13991449
],
14001450
"label": "Sets the state for the request to setup Authenticator",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
function Set-CIPPRegistrationCampaign {
2+
<#
3+
.SYNOPSIS
4+
Updates the authentication methods registration campaign (nudge) for a tenant.
5+
.DESCRIPTION
6+
Single writer for the registration campaign, shared by the ExecRegistrationCampaign
7+
endpoint and the NudgeMFA standard. Any parameter left as $null keeps the value
8+
currently configured in the tenant, so callers can update settings independently.
9+
.PARAMETER IncludeTargets
10+
Array of @{ id; targetType } targets. $null keeps the current include targets. The
11+
targeted authentication method is applied to every include target, and a campaign
12+
always ends up with at least one include target (falls back to all_users).
13+
.PARAMETER ExcludeTargets
14+
Array of @{ id; targetType } targets. $null keeps the current exclusions, an empty
15+
array clears them.
16+
#>
17+
[CmdletBinding(SupportsShouldProcess = $true)]
18+
param(
19+
[Parameter(Mandatory = $true)]$Tenant,
20+
$State,
21+
$TargetedAuthenticationMethod,
22+
$SnoozeDurationInDays,
23+
$EnforceRegistrationAfterAllowedSnoozes,
24+
$IncludeTargets,
25+
$ExcludeTargets,
26+
$APIName = 'Set Registration Campaign',
27+
$Headers
28+
)
29+
30+
try {
31+
$CurrentPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -tenantid $Tenant
32+
$CurrentCampaign = $CurrentPolicy.registrationEnforcement.authenticationMethodsRegistrationCampaign
33+
} catch {
34+
$ErrorMessage = Get-CippException -Exception $_
35+
Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
36+
throw "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)"
37+
}
38+
39+
$DesiredState = $State ?? $CurrentCampaign.state
40+
$DesiredMethod = $TargetedAuthenticationMethod ?? (@($CurrentCampaign.includeTargets).targetedAuthenticationMethod | Select-Object -First 1) ?? 'microsoftAuthenticator'
41+
$DesiredSnooze = if ($null -ne $SnoozeDurationInDays) { [int]$SnoozeDurationInDays } else { [int]($CurrentCampaign.snoozeDurationInDays ?? 1) }
42+
$DesiredEnforce = if ($null -ne $EnforceRegistrationAfterAllowedSnoozes) { [bool]$EnforceRegistrationAfterAllowedSnoozes } else { [bool]$CurrentCampaign.enforceRegistrationAfterAllowedSnoozes }
43+
44+
if ($DesiredState -notin @('default', 'enabled', 'disabled')) {
45+
throw "State must be one of 'default', 'enabled' or 'disabled'"
46+
}
47+
if ($DesiredMethod -notin @('microsoftAuthenticator', 'fido2')) {
48+
throw "TargetedAuthenticationMethod must be 'microsoftAuthenticator' or 'fido2'"
49+
}
50+
if ($DesiredSnooze -lt 0 -or $DesiredSnooze -gt 14) {
51+
throw 'SnoozeDurationInDays must be between 0 and 14'
52+
}
53+
54+
$DesiredIncludeTargets = if ($null -ne $IncludeTargets) {
55+
@($IncludeTargets | ForEach-Object { @{ id = "$($_.id)"; targetType = "$($_.targetType)"; targetedAuthenticationMethod = $DesiredMethod } })
56+
} else {
57+
@($CurrentCampaign.includeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType; targetedAuthenticationMethod = $DesiredMethod } })
58+
}
59+
if ($DesiredIncludeTargets.Count -eq 0) {
60+
$DesiredIncludeTargets = @(@{ id = 'all_users'; targetType = 'group'; targetedAuthenticationMethod = $DesiredMethod })
61+
}
62+
63+
$DesiredExcludeTargets = if ($null -ne $ExcludeTargets) {
64+
@($ExcludeTargets | ForEach-Object { @{ id = "$($_.id)"; targetType = "$($_.targetType)" } })
65+
} else {
66+
@($CurrentCampaign.excludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } })
67+
}
68+
69+
$Body = @{
70+
registrationEnforcement = @{
71+
authenticationMethodsRegistrationCampaign = @{
72+
state = $DesiredState
73+
snoozeDurationInDays = $DesiredSnooze
74+
enforceRegistrationAfterAllowedSnoozes = $DesiredEnforce
75+
includeTargets = @($DesiredIncludeTargets)
76+
excludeTargets = @($DesiredExcludeTargets)
77+
}
78+
}
79+
} | ConvertTo-Json -Depth 10 -Compress
80+
81+
try {
82+
$Result = "Set the registration campaign state to $DesiredState targeting $DesiredMethod with a snooze duration of $DesiredSnooze day(s), $($DesiredIncludeTargets.Count) include target(s) and $($DesiredExcludeTargets.Count) exclude target(s)"
83+
if ($PSCmdlet.ShouldProcess('Registration campaign', "Set state to $DesiredState")) {
84+
$null = New-GraphPostRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -Type PATCH -Body $Body -ContentType 'application/json' -AsApp $false
85+
Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message $Result -sev Info
86+
}
87+
return $Result
88+
} catch {
89+
$ErrorMessage = Get-CippException -Exception $_
90+
Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
91+
throw "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)"
92+
}
93+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
function Invoke-ExecRegistrationCampaign {
2+
<#
3+
.FUNCTIONALITY
4+
Entrypoint
5+
.ROLE
6+
Tenant.Administration.ReadWrite
7+
#>
8+
[CmdletBinding()]
9+
param($Request, $TriggerMetadata)
10+
11+
$APIName = $Request.Params.CIPPEndpoint
12+
$Headers = $Request.Headers
13+
$TenantFilter = $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter
14+
15+
if (-not $TenantFilter) {
16+
return [HttpResponseContext]@{
17+
StatusCode = [HttpStatusCode]::BadRequest
18+
Body = @{ Results = 'Error: tenantFilter is required' }
19+
}
20+
}
21+
22+
if ($null -ne $Request.Body.snoozeDurationInDays -and ([int]$Request.Body.snoozeDurationInDays -lt 0 -or [int]$Request.Body.snoozeDurationInDays -gt 14)) {
23+
return [HttpResponseContext]@{
24+
StatusCode = [HttpStatusCode]::BadRequest
25+
Body = @{ Results = 'Error: snoozeDurationInDays must be between 0 and 14' }
26+
}
27+
}
28+
29+
try {
30+
# Build include/exclude target lists; $null means "keep what is currently configured"
31+
$IncludeSpecified = ($null -ne $Request.Body.includeAllUsers) -or ($null -ne $Request.Body.includeGroups) -or ($null -ne $Request.Body.includeUsers)
32+
$IncludeTargets = if ($IncludeSpecified) {
33+
if ([bool]$Request.Body.includeAllUsers) {
34+
@(@{ id = 'all_users'; targetType = 'group' })
35+
} else {
36+
$Targets = [System.Collections.Generic.List[hashtable]]::new()
37+
foreach ($GroupId in @($Request.Body.includeGroups)) {
38+
if ($GroupId) { $Targets.Add(@{ id = "$GroupId"; targetType = 'group' }) }
39+
}
40+
foreach ($UserId in @($Request.Body.includeUsers)) {
41+
if ($UserId) { $Targets.Add(@{ id = "$UserId"; targetType = 'user' }) }
42+
}
43+
@($Targets)
44+
}
45+
} else { $null }
46+
47+
$ExcludeSpecified = ($null -ne $Request.Body.excludeGroups) -or ($null -ne $Request.Body.excludeUsers)
48+
$ExcludeTargets = if ($ExcludeSpecified) {
49+
$Targets = [System.Collections.Generic.List[hashtable]]::new()
50+
foreach ($GroupId in @($Request.Body.excludeGroups)) {
51+
if ($GroupId) { $Targets.Add(@{ id = "$GroupId"; targetType = 'group' }) }
52+
}
53+
foreach ($UserId in @($Request.Body.excludeUsers)) {
54+
if ($UserId) { $Targets.Add(@{ id = "$UserId"; targetType = 'user' }) }
55+
}
56+
@($Targets)
57+
} else { $null }
58+
59+
$CampaignParams = @{
60+
Tenant = $TenantFilter
61+
State = $Request.Body.state.value ?? $Request.Body.state
62+
TargetedAuthenticationMethod = $Request.Body.targetedAuthenticationMethod.value ?? $Request.Body.targetedAuthenticationMethod
63+
SnoozeDurationInDays = $Request.Body.snoozeDurationInDays
64+
EnforceRegistrationAfterAllowedSnoozes = $Request.Body.enforceRegistrationAfterAllowedSnoozes
65+
IncludeTargets = $IncludeTargets
66+
ExcludeTargets = $ExcludeTargets
67+
APIName = $APIName
68+
Headers = $Headers
69+
}
70+
$Result = Set-CIPPRegistrationCampaign @CampaignParams
71+
} catch {
72+
$Result = $_.Exception.Message
73+
$StatusCode = [HttpStatusCode]::InternalServerError
74+
}
75+
76+
return [HttpResponseContext]@{
77+
StatusCode = $StatusCode ?? [HttpStatusCode]::OK
78+
Body = @{ Results = $Result }
79+
}
80+
}

0 commit comments

Comments
 (0)