Add script to remove registered authentication methods for Entra ID users in bulk
This commit is contained in:
@@ -0,0 +1,541 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Removes a chosen type of registered authentication method (phone, Microsoft
|
||||||
|
Authenticator, FIDO2, etc.) from Entra ID users in bulk.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Connects to Microsoft Graph, lets the caller pick which authentication method
|
||||||
|
type to remove (interactively from a menu, or via -MethodType for unattended
|
||||||
|
runs), then removes every registered entry of that type for each in-scope user.
|
||||||
|
|
||||||
|
Safety: for MFA-capable method types, if the selected type is a user's only
|
||||||
|
remaining MFA-capable method, removing it would leave them unable to complete MFA
|
||||||
|
at all. This script never does that - such users are automatically skipped (never
|
||||||
|
modified) and reported separately so an admin can register them another method
|
||||||
|
first. Temporary Access Pass does not count as a substitute method for this check,
|
||||||
|
since it is a one-time/short-lived bootstrap credential, not an ongoing MFA
|
||||||
|
method. Email is not an MFA method at all (SSPR only), so removing it is never
|
||||||
|
blocked by this check.
|
||||||
|
|
||||||
|
.PARAMETER TenantId
|
||||||
|
Optional tenant ID or verified domain name to sign in to. If omitted,
|
||||||
|
Connect-MgGraph uses the interactive account picker / default tenant.
|
||||||
|
|
||||||
|
.PARAMETER MethodType
|
||||||
|
Which authentication method type to remove: Phone, MicrosoftAuthenticator,
|
||||||
|
Fido2, WindowsHelloForBusiness, SoftwareOath, TemporaryAccessPass,
|
||||||
|
PlatformCredential, or Email. Omit to choose from an interactive menu instead.
|
||||||
|
|
||||||
|
.PARAMETER UserPrincipalName
|
||||||
|
Optional. Limit the run to these specific users instead of the whole tenant.
|
||||||
|
|
||||||
|
.PARAMETER IncludeGuests
|
||||||
|
Include guest (B2B) accounts. By default only enabled Member users are processed.
|
||||||
|
|
||||||
|
.PARAMETER OutputFolder
|
||||||
|
Folder to write the results CSV to. Defaults to the current working directory.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Remove-UserAuthenticationMethod.ps1 -WhatIf
|
||||||
|
Signs in, prompts for which method type to remove, lists every enabled Member
|
||||||
|
user in the tenant, and shows what would be removed (and what would be skipped
|
||||||
|
to avoid leaving a user with no MFA method) without changing anything.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Remove-UserAuthenticationMethod.ps1 -MethodType Phone -TenantId 'contoso.onmicrosoft.com'
|
||||||
|
Signs in to the specified tenant and, after a single confirmation prompt, removes
|
||||||
|
every eligible user's phone authentication method(s).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Remove-UserAuthenticationMethod.ps1 -MethodType Phone -UserPrincipalName 'alice@contoso.com','bob@contoso.com' -Confirm:$false
|
||||||
|
Removes phone authentication methods for just the two named users, without even
|
||||||
|
the single upfront confirmation prompt - use only once a prior -WhatIf run against
|
||||||
|
the same scope has already been reviewed.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Author: Petr Stepan
|
||||||
|
Created: 2026-07-29
|
||||||
|
Version: 1.1.0
|
||||||
|
Changelog:
|
||||||
|
1.0.0 - Initial version (phone/SMS-Voice authentication methods only)
|
||||||
|
1.1.0 - Generalized to any removable authentication method type, selected
|
||||||
|
interactively from a menu or via the new -MethodType parameter
|
||||||
|
#>
|
||||||
|
|
||||||
|
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Users
|
||||||
|
|
||||||
|
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = 'Tenant ID or verified domain name to connect to.')]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]
|
||||||
|
$TenantId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = 'Authentication method type to remove. Omit to choose interactively from a menu.')]
|
||||||
|
[ValidateSet('Phone', 'MicrosoftAuthenticator', 'Fido2', 'WindowsHelloForBusiness', 'SoftwareOath', 'TemporaryAccessPass', 'PlatformCredential', 'Email')]
|
||||||
|
[string]
|
||||||
|
$MethodType,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = 'Limit the run to these specific users instead of the whole tenant.')]
|
||||||
|
[string[]]
|
||||||
|
$UserPrincipalName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]
|
||||||
|
$IncludeGuests,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false, HelpMessage = 'Folder to write the results CSV to.')]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]
|
||||||
|
$OutputFolder = (Get-Location).Path
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Safety preamble -------------------------------------------------------
|
||||||
|
# Fail fast on uninitialized variables and typos in property/member names.
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
# Treat all non-terminating errors as terminating so failures can't slip by silently.
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Graph scopes needed: read users, and read/remove their authentication methods.
|
||||||
|
$script:RequiredGraphScopes = @(
|
||||||
|
'User.Read.All'
|
||||||
|
'UserAuthenticationMethod.ReadWrite.All'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Every authentication method type this script knows how to remove, and how to
|
||||||
|
# remove it: the Graph @odata.type value(s) that identify an entry of this type,
|
||||||
|
# the dedicated Remove-Mg cmdlet for it (each method type has its own - there is no
|
||||||
|
# single generic "remove any method" cmdlet), and that cmdlet's ID parameter name.
|
||||||
|
# IsMfaCapable marks whether an entry of this type counts towards the "does this
|
||||||
|
# user have another MFA method left" safety check below - Temporary Access Pass
|
||||||
|
# (one-time/short-lived bootstrap credential) and Email (SSPR only, not MFA) do not.
|
||||||
|
$script:MethodTypeCatalog = @(
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = 'Phone'
|
||||||
|
DisplayName = 'Phone (SMS / Voice call)'
|
||||||
|
OdataTypes = @('#microsoft.graph.phoneAuthenticationMethod')
|
||||||
|
RemoveCmdlet = 'Remove-MgUserAuthenticationPhoneMethod'
|
||||||
|
IdParameter = 'PhoneAuthenticationMethodId'
|
||||||
|
IsMfaCapable = $true
|
||||||
|
}
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = 'MicrosoftAuthenticator'
|
||||||
|
DisplayName = 'Microsoft Authenticator (push / passwordless)'
|
||||||
|
# Both the classic push method and the passwordless/passkey-in-Authenticator
|
||||||
|
# variant are registrations of the same underlying device and are removed
|
||||||
|
# through the same cmdlet - there is no separate "passwordless" delete API.
|
||||||
|
OdataTypes = @(
|
||||||
|
'#microsoft.graph.microsoftAuthenticatorAuthenticationMethod'
|
||||||
|
'#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod'
|
||||||
|
)
|
||||||
|
RemoveCmdlet = 'Remove-MgUserAuthenticationMicrosoftAuthenticatorMethod'
|
||||||
|
IdParameter = 'MicrosoftAuthenticatorAuthenticationMethodId'
|
||||||
|
IsMfaCapable = $true
|
||||||
|
}
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = 'Fido2'
|
||||||
|
DisplayName = 'FIDO2 / Passkey'
|
||||||
|
OdataTypes = @('#microsoft.graph.fido2AuthenticationMethod')
|
||||||
|
RemoveCmdlet = 'Remove-MgUserAuthenticationFido2Method'
|
||||||
|
IdParameter = 'Fido2AuthenticationMethodId'
|
||||||
|
IsMfaCapable = $true
|
||||||
|
}
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = 'WindowsHelloForBusiness'
|
||||||
|
DisplayName = 'Windows Hello for Business'
|
||||||
|
OdataTypes = @('#microsoft.graph.windowsHelloForBusinessAuthenticationMethod')
|
||||||
|
RemoveCmdlet = 'Remove-MgUserAuthenticationWindowsHelloForBusinessMethod'
|
||||||
|
IdParameter = 'WindowsHelloForBusinessAuthenticationMethodId'
|
||||||
|
IsMfaCapable = $true
|
||||||
|
}
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = 'SoftwareOath'
|
||||||
|
DisplayName = 'OATH software token'
|
||||||
|
OdataTypes = @('#microsoft.graph.softwareOathAuthenticationMethod')
|
||||||
|
RemoveCmdlet = 'Remove-MgUserAuthenticationSoftwareOathMethod'
|
||||||
|
IdParameter = 'SoftwareOathAuthenticationMethodId'
|
||||||
|
IsMfaCapable = $true
|
||||||
|
}
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = 'TemporaryAccessPass'
|
||||||
|
DisplayName = 'Temporary Access Pass'
|
||||||
|
OdataTypes = @('#microsoft.graph.temporaryAccessPassAuthenticationMethod')
|
||||||
|
RemoveCmdlet = 'Remove-MgUserAuthenticationTemporaryAccessPassMethod'
|
||||||
|
IdParameter = 'TemporaryAccessPassAuthenticationMethodId'
|
||||||
|
IsMfaCapable = $false
|
||||||
|
}
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = 'PlatformCredential'
|
||||||
|
DisplayName = 'Platform credential (e.g. Mac)'
|
||||||
|
OdataTypes = @('#microsoft.graph.platformCredentialAuthenticationMethod')
|
||||||
|
RemoveCmdlet = 'Remove-MgUserAuthenticationPlatformCredentialMethod'
|
||||||
|
IdParameter = 'PlatformCredentialAuthenticationMethodId'
|
||||||
|
IsMfaCapable = $true
|
||||||
|
}
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = 'Email'
|
||||||
|
DisplayName = 'Email (SSPR only)'
|
||||||
|
OdataTypes = @('#microsoft.graph.emailAuthenticationMethod')
|
||||||
|
RemoveCmdlet = 'Remove-MgUserAuthenticationEmailMethod'
|
||||||
|
IdParameter = 'EmailAuthenticationMethodId'
|
||||||
|
IsMfaCapable = $false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flat list of every @odata.type that counts as an ongoing MFA-capable method,
|
||||||
|
# derived from the catalog above so the two can never drift out of sync.
|
||||||
|
$script:MfaCapableOdataTypes = @($script:MethodTypeCatalog | Where-Object { $_.IsMfaCapable } | ForEach-Object { $_.OdataTypes })
|
||||||
|
|
||||||
|
# --- Functions ---------------------------------------------------------
|
||||||
|
|
||||||
|
function Connect-TenantGraph {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Ensures an authenticated Microsoft Graph session with the required scopes.
|
||||||
|
.DESCRIPTION
|
||||||
|
Read-only from the script's perspective (it only inspects/creates an auth
|
||||||
|
session), so it does not need ShouldProcess. Reuses an existing connection if
|
||||||
|
it already grants every required scope, to avoid re-prompting unnecessarily.
|
||||||
|
.PARAMETER TenantId
|
||||||
|
Optional tenant ID or verified domain name to connect to.
|
||||||
|
.EXAMPLE
|
||||||
|
Connect-TenantGraph -TenantId 'contoso.onmicrosoft.com'
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]
|
||||||
|
$TenantId
|
||||||
|
)
|
||||||
|
|
||||||
|
$context = Get-MgContext
|
||||||
|
$hasAllScopes = $context -and (-not (Compare-Object -ReferenceObject $script:RequiredGraphScopes -DifferenceObject $context.Scopes | Where-Object { $_.SideIndicator -eq '<=' }))
|
||||||
|
|
||||||
|
if (-not $hasAllScopes) {
|
||||||
|
$connectParams = @{
|
||||||
|
Scopes = $script:RequiredGraphScopes
|
||||||
|
NoWelcome = $true
|
||||||
|
}
|
||||||
|
if ($TenantId) {
|
||||||
|
$connectParams['TenantId'] = $TenantId
|
||||||
|
}
|
||||||
|
Connect-MgGraph @connectParams
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose 'Reusing existing Microsoft Graph connection with sufficient scopes.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select-AuthenticationMethodType {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Resolves which authentication method type this run should remove.
|
||||||
|
.DESCRIPTION
|
||||||
|
Interactive/read-only - does not modify anything, so it does not need
|
||||||
|
ShouldProcess. Returns the matching catalog entry for -MethodType if one was
|
||||||
|
given; otherwise prints a numbered menu and prompts until a valid choice is
|
||||||
|
made.
|
||||||
|
.PARAMETER MethodType
|
||||||
|
Optional method type name already chosen by the caller (skips the prompt).
|
||||||
|
.EXAMPLE
|
||||||
|
Select-AuthenticationMethodType -MethodType 'Phone'
|
||||||
|
.EXAMPLE
|
||||||
|
Select-AuthenticationMethodType
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]
|
||||||
|
$MethodType
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($MethodType) {
|
||||||
|
$selected = $script:MethodTypeCatalog | Where-Object { $_.Name -eq $MethodType }
|
||||||
|
if (-not $selected) {
|
||||||
|
throw "Unknown method type '$MethodType'."
|
||||||
|
}
|
||||||
|
return $selected
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nWhich authentication method type should be removed?" -ForegroundColor Cyan
|
||||||
|
for ($index = 0; $index -lt $script:MethodTypeCatalog.Count; $index++) {
|
||||||
|
Write-Host (" {0}. {1}" -f ($index + 1), $script:MethodTypeCatalog[$index].DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
$chosenIndex = -1
|
||||||
|
do {
|
||||||
|
$choice = Read-Host "`nEnter a number (1-$($script:MethodTypeCatalog.Count))"
|
||||||
|
$parsedNumber = 0
|
||||||
|
$isValidNumber = [int]::TryParse($choice, [ref]$parsedNumber)
|
||||||
|
if ($isValidNumber -and $parsedNumber -ge 1 -and $parsedNumber -le $script:MethodTypeCatalog.Count) {
|
||||||
|
$chosenIndex = $parsedNumber - 1
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Warning 'Invalid choice - please enter a number from the list.'
|
||||||
|
}
|
||||||
|
} while ($chosenIndex -lt 0)
|
||||||
|
|
||||||
|
return $script:MethodTypeCatalog[$chosenIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-TenantUserInventory {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Returns the users this run should process.
|
||||||
|
.DESCRIPTION
|
||||||
|
Read-only; does not modify anything, so it does not need ShouldProcess. Returns
|
||||||
|
either the specific users named via -UserPrincipalName, or every enabled Member
|
||||||
|
user in the tenant (guests included only when -IncludeGuests is set).
|
||||||
|
.PARAMETER UserPrincipalName
|
||||||
|
Optional. Specific users to return instead of the whole tenant.
|
||||||
|
.PARAMETER IncludeGuests
|
||||||
|
Include guest (B2B) accounts when returning the whole tenant.
|
||||||
|
.EXAMPLE
|
||||||
|
Get-TenantUserInventory
|
||||||
|
.EXAMPLE
|
||||||
|
Get-TenantUserInventory -UserPrincipalName 'alice@contoso.com'
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string[]]
|
||||||
|
$UserPrincipalName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]
|
||||||
|
$IncludeGuests
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($UserPrincipalName) {
|
||||||
|
$users = [System.Collections.Generic.List[object]]::new()
|
||||||
|
foreach ($upn in $UserPrincipalName) {
|
||||||
|
try {
|
||||||
|
$users.Add((Get-MgUser -UserId $upn -Property Id, DisplayName, UserPrincipalName -ErrorAction Stop))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not find user '$upn': $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return @($users)
|
||||||
|
}
|
||||||
|
|
||||||
|
$userFilter = "accountEnabled eq true"
|
||||||
|
if (-not $IncludeGuests) {
|
||||||
|
$userFilter += " and userType eq 'Member'"
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return @(Get-MgUser -Filter $userFilter -ConsistencyLevel eventual -CountVariable usersFound `
|
||||||
|
-Property Id, DisplayName, UserPrincipalName -All -PageSize 500 -Sort DisplayName -ErrorAction Stop)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Failed to retrieve users from Entra ID: $_"
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-UserAuthenticationMethodEntry {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Removes every registered authentication method of the selected type for one
|
||||||
|
user, unless doing so would leave them with no MFA-capable method at all.
|
||||||
|
.DESCRIPTION
|
||||||
|
Each matching entry is removed independently, gated behind ShouldProcess, so
|
||||||
|
-WhatIf reports exactly what would be removed and a failure removing one entry
|
||||||
|
does not prevent the others from being attempted.
|
||||||
|
.PARAMETER UserId
|
||||||
|
The Entra ID object ID of the user.
|
||||||
|
.PARAMETER UserPrincipalName
|
||||||
|
Used only for readable ShouldProcess prompts/log messages.
|
||||||
|
.PARAMETER MethodTypeInfo
|
||||||
|
The catalog entry (an item from $script:MethodTypeCatalog) describing which
|
||||||
|
method type to remove and which Graph cmdlet/parameter to use.
|
||||||
|
.EXAMPLE
|
||||||
|
Remove-UserAuthenticationMethodEntry -UserId $id -UserPrincipalName 'alice@contoso.com' -MethodTypeInfo $methodTypeInfo
|
||||||
|
#>
|
||||||
|
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]
|
||||||
|
$UserId,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string]
|
||||||
|
$UserPrincipalName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidateNotNull()]
|
||||||
|
[PSCustomObject]
|
||||||
|
$MethodTypeInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
[array]$allMethods = @(Get-MgUserAuthenticationMethod -UserId $UserId -ErrorAction Stop)
|
||||||
|
[array]$matchingMethods = @($allMethods | Where-Object { $_.AdditionalProperties['@odata.type'] -in $MethodTypeInfo.OdataTypes })
|
||||||
|
|
||||||
|
if ($matchingMethods.Count -eq 0) {
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Status = 'NoMatchingMethod'
|
||||||
|
RemovedCount = 0
|
||||||
|
Errors = [System.Collections.Generic.List[string]]::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($MethodTypeInfo.IsMfaCapable) {
|
||||||
|
[array]$remainingMfaMethods = @($allMethods | Where-Object {
|
||||||
|
$_.AdditionalProperties['@odata.type'] -in $script:MfaCapableOdataTypes -and
|
||||||
|
$_.AdditionalProperties['@odata.type'] -notin $MethodTypeInfo.OdataTypes
|
||||||
|
})
|
||||||
|
if ($remainingMfaMethods.Count -eq 0) {
|
||||||
|
# Removing this would leave the user with zero MFA-capable methods -
|
||||||
|
# never do that; flag for manual remediation instead.
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Status = 'Skipped'
|
||||||
|
RemovedCount = 0
|
||||||
|
Errors = [System.Collections.Generic.List[string]]::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$removedCount = 0
|
||||||
|
|
||||||
|
foreach ($method in $matchingMethods) {
|
||||||
|
$target = "$UserPrincipalName ($($MethodTypeInfo.DisplayName))"
|
||||||
|
if ($PSCmdlet.ShouldProcess($target, "Remove $($MethodTypeInfo.DisplayName) authentication method")) {
|
||||||
|
try {
|
||||||
|
$removeParams = @{
|
||||||
|
UserId = $UserId
|
||||||
|
ErrorAction = 'Stop'
|
||||||
|
}
|
||||||
|
$removeParams[$MethodTypeInfo.IdParameter] = $method.Id
|
||||||
|
& $MethodTypeInfo.RemoveCmdlet @removeParams
|
||||||
|
$removedCount++
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$errors.Add("Failed to remove entry: $_")
|
||||||
|
Write-Warning "Failed to remove $($MethodTypeInfo.DisplayName) method for '$UserPrincipalName': $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = if ($errors.Count -gt 0) { 'Failed' } else { 'Removed' }
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Status = $status
|
||||||
|
RemovedCount = $removedCount
|
||||||
|
Errors = $errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main --------------------------------------------------------------
|
||||||
|
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||||
|
$hasFailures = $false
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Verbose 'Connecting to Microsoft Graph.'
|
||||||
|
Connect-TenantGraph -TenantId $TenantId
|
||||||
|
|
||||||
|
$methodTypeInfo = Select-AuthenticationMethodType -MethodType $MethodType
|
||||||
|
Write-Host "Selected method type: $($methodTypeInfo.DisplayName)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# One upfront confirmation for the whole run, instead of a separate interactive
|
||||||
|
# prompt per user. Skipped entirely under -WhatIf (nothing to confirm for a
|
||||||
|
# preview) and honored if the caller explicitly passed -Confirm:$false. Every
|
||||||
|
# individual removal below then runs with -Confirm:$false, since the decision to
|
||||||
|
# proceed has already been made here.
|
||||||
|
if (-not $WhatIfPreference) {
|
||||||
|
$confirmExplicitlySuppressed = $PSBoundParameters.ContainsKey('Confirm') -and -not $PSBoundParameters['Confirm']
|
||||||
|
$mfaWarning = if ($methodTypeInfo.IsMfaCapable) { 'Users for whom this is their only remaining MFA-capable method are skipped automatically. ' } else { '' }
|
||||||
|
if (-not $confirmExplicitlySuppressed -and -not $PSCmdlet.ShouldContinue(
|
||||||
|
"This will permanently remove '$($methodTypeInfo.DisplayName)' authentication methods from every eligible user in scope. $mfaWarning" + 'This cannot be undone by this script - continue?',
|
||||||
|
'Confirm authentication method removal')) {
|
||||||
|
Write-Warning 'Aborted by user - no authentication methods were removed.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose 'Retrieving user accounts.'
|
||||||
|
$users = @(Get-TenantUserInventory -UserPrincipalName $UserPrincipalName -IncludeGuests:$IncludeGuests)
|
||||||
|
Write-Host "Found $($users.Count) user account(s) to process." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
[int]$i = 0
|
||||||
|
foreach ($user in $users) {
|
||||||
|
$i++
|
||||||
|
Write-Progress -Activity "Removing $($methodTypeInfo.DisplayName) authentication methods" -Status "$($user.DisplayName) ($i of $($users.Count))" -PercentComplete (($i / $users.Count) * 100)
|
||||||
|
|
||||||
|
$resultRow = [PSCustomObject]@{
|
||||||
|
DisplayName = $user.DisplayName
|
||||||
|
UserPrincipalName = $user.UserPrincipalName
|
||||||
|
Status = 'Unknown'
|
||||||
|
RemovedCount = 0
|
||||||
|
ErrorDetail = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Forward -WhatIf explicitly rather than relying on $WhatIfPreference to
|
||||||
|
# cascade implicitly - keeps the dry-run behavior obvious and independent
|
||||||
|
# of scope-inheritance quirks. -Confirm:$false because the single upfront
|
||||||
|
# confirmation above already covers this whole run.
|
||||||
|
$removalResult = Remove-UserAuthenticationMethodEntry -UserId $user.Id -UserPrincipalName $user.UserPrincipalName `
|
||||||
|
-MethodTypeInfo $methodTypeInfo -WhatIf:$WhatIfPreference -Confirm:$false
|
||||||
|
$resultRow.Status = $removalResult.Status
|
||||||
|
$resultRow.RemovedCount = $removalResult.RemovedCount
|
||||||
|
if ($removalResult.Errors.Count -gt 0) {
|
||||||
|
$resultRow.ErrorDetail = $removalResult.Errors -join '; '
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Do not let one user's failure abort the whole tenant run.
|
||||||
|
$resultRow.Status = 'Failed'
|
||||||
|
$resultRow.ErrorDetail = "$_"
|
||||||
|
Write-Warning "Skipping '$($user.UserPrincipalName)' after failure: $_"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resultRow.Status -eq 'Failed') {
|
||||||
|
$hasFailures = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$results.Add($resultRow)
|
||||||
|
}
|
||||||
|
Write-Progress -Activity "Removing $($methodTypeInfo.DisplayName) authentication methods" -Completed
|
||||||
|
|
||||||
|
if (-not (Test-Path -Path $OutputFolder)) {
|
||||||
|
New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||||
|
$csvPath = Join-Path $OutputFolder "RemoveUserAuthMethod_$($methodTypeInfo.Name)_$timestamp.csv"
|
||||||
|
|
||||||
|
# Only surface users the script acted on (or would act on) or flagged for manual
|
||||||
|
# review - users with no matching method registered at all are deliberately
|
||||||
|
# omitted to keep the report focused on what actually needs attention.
|
||||||
|
$reportableResults = @($results | Where-Object { $_.Status -ne 'NoMatchingMethod' })
|
||||||
|
$reportableResults | Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8
|
||||||
|
|
||||||
|
$removedUserCount = @($results | Where-Object { $_.Status -eq 'Removed' }).Count
|
||||||
|
$skippedUserCount = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count
|
||||||
|
$failedUserCount = @($results | Where-Object { $_.Status -eq 'Failed' }).Count
|
||||||
|
$totalRemovedMethods = ($results | Measure-Object -Property RemovedCount -Sum).Sum
|
||||||
|
if (-not $totalRemovedMethods) { $totalRemovedMethods = 0 }
|
||||||
|
|
||||||
|
Write-Host "`n===== SUMMARY =====" -ForegroundColor Magenta
|
||||||
|
Write-Host "Method type: $($methodTypeInfo.DisplayName)"
|
||||||
|
Write-Host "Users processed: $($results.Count)"
|
||||||
|
Write-Host "Users with method(s) removed: $removedUserCount" -ForegroundColor Green
|
||||||
|
Write-Host "Methods removed in total: $totalRemovedMethods" -ForegroundColor Green
|
||||||
|
Write-Host "Users skipped (only remaining MFA method): $skippedUserCount" -ForegroundColor Yellow
|
||||||
|
Write-Host "Users failed: $failedUserCount" -ForegroundColor Red
|
||||||
|
Write-Host "Results CSV: $csvPath"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error "Script failed: $_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# No persistent sessions to release - Connect-MgGraph tokens are cached by the SDK.
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasFailures) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
exit 0
|
||||||
Reference in New Issue
Block a user