Introduction
A few weeks back I did a post about how to use Proactive Remediation’s to trigger a Toast for the end user notifying them that their password is about to expire (Feel free to read it here). I posted about it on Reddit & Twitter and got great responses for which I am really grateful, thanks to everyone who read and commented. The people who read it highlighted a potential issue with the Detection script regarding authentication against the Enterprise Application. The Detection script contains the authentication details of the Enterprise Application in clear-text which would be readable as the scripts that run on the device is stored in the registry and this could definitely be seen as a security flaw. Although I definitely see this as a risk, I don’t necessarily see this is as a “big” risk for a couple of reasons added together:
- For this to be exploitable one would need to get a hold of a device and be able to log onto it
- The only permissions we granted in the Enterprise Application was to read user profiles
But someone pointed out that the guys over at https://msendpointmgr.com addressed a similar issue a while in back in this post and that we could probably utilize their solution to more securely authenticate. Since they came up with such an elegant solution I was really eager to try it out.
Solution
Note: The solution going forward is based on the solution provided in the previous post
Instead of putting the authentication details in the Detection script we are going to create an an Azure Function that in turn will do the MS Graph Authentication and Query and just the respond with the details about when password was last set. Along with this we will make sure that the call to the Azure Function comes from an Azure AD joined device in our tenant. This should make our Authentication & MS Graph calls more secure and not provide any plain text authentication details in the script. The URL for the Azure Function will however be exposed, but we can control what information may be returned from the function as well as previously mention the device doing the HTTP call to the azure function will need to be a device joined to the correct tenant or the function will simply return a forbidden status.
To visualize this I have created a very lackluster Visio Sketch and provided a screenshot of said sketch:
What we need
- An Enterprise Application (We’re going to modify the one we have already)
- We need some new that permissions will be used to verify that the device is joined to our tenant
- A Detection Script (We’re going to modify the one we have already)
- We’re going to remove the Authentication details from the Detection script and replace them with an HTTP call
- An Azure Function
- This is what will complete the MSGraph query and return the information to the detection script
Enterprise Application
We’re gonna start of with modifying the Enterprise Application. We’re not going to change a lot since the application already does what we need, but seeing as we’re going to verify that the device is joined to our Azure tenant we need for the function to be able to fetch the devices currently in our tenant so it can do a comparison.
- Open Azure AD
- Navigate to App registrations
- Select the Application you Created previously (Note that there are multiple tabs and that if you can’t find your application select “All Applications” and search for it)
- Navigate to the API permissions tab
- Select Add a permission
- Chose Microsoft Graph
- Chose Application permissions
- Search Device.Read.All
- Mark the box for Device.Read.All under Device
- Add permissions
- Review that the correct permissions have been granted then Select Grant admin consent for “Tenant”
Detection Script
We already have a detection script so we’re just gonna add some modifications to it. Mainly what we’re doing is removing the Authentication details for the Enterprise Application and the MSGraph query from the script, we’re also adding the HTTP call and response for the Azure Function and the gathering of the current device AzureADDeviceID and TenantID to send along with the HTTP call. Thanks again to the msendpointmgr guys & Nikolaj for the Get-AzureADDeviceID function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
############################## ## Parameters ############################## ############################## ## Variables ############################## $FunctionURL = "Enter Azure Function URL" $PasswordExpirationDays = 90 $WindirTemp = Join-Path $Env:Windir -Childpath "Temp" $UserTemp = $Env:Temp $UserContext = [Security.Principal.WindowsIdentity]::GetCurrent() Switch ($UserContext) { { $PSItem.Name -Match "System" } { Write-Output "Running as System" ; $Temp = $UserTemp } { $PSItem.Name -NotMatch "System" } { Write-Output "Not running System" ; $Temp = $WindirTemp } Default { Write-Output "Could not translate Usercontext" } } $logfilename = "PasswordNotificationDS" $logfile = Join-Path $Temp -Childpath "$logfilename.log" $LogfileSizeMax = 100 ############################## ## Functions ############################## function Get-AzureADDeviceID { <# .SYNOPSIS Get the Azure AD device ID from the local device. .DESCRIPTION Get the Azure AD device ID from the local device. .NOTES Author: Nickolaj Andersen Contact: @NickolajA Created: 2021-05-26 Updated: 2021-05-26 Version history: 1.0.0 - (2021-05-26) Function created #> Process { # Define Cloud Domain Join information registry path $AzureADJoinInfoRegistryKeyPath = "HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo" # Retrieve the child key name that is the thumbprint of the machine certificate containing the device identifier guid $AzureADJoinInfoThumbprint = Get-ChildItem -Path $AzureADJoinInfoRegistryKeyPath | Select-Object -ExpandProperty "PSChildName" if ($AzureADJoinInfoThumbprint -ne $null) { # Retrieve the machine certificate based on thumbprint from registry key $AzureADJoinCertificate = Get-ChildItem -Path "Cert:\LocalMachine\My" -Recurse | Where-Object { $PSItem.Thumbprint -eq $AzureADJoinInfoThumbprint } if ($AzureADJoinCertificate -ne $null) { # Determine the device identifier from the subject name $AzureADDeviceID = ($AzureADJoinCertificate | Select-Object -ExpandProperty "Subject") -replace "CN=", "" # Handle return value return $AzureADDeviceID } } } } #endfunction ############################## ## Scriptstart ############################## If ($logfilename) { If (((Get-Item -ErrorAction SilentlyContinue $logfile).length / 1MB) -gt $LogfileSizeMax) { Remove-Item $logfile -Force } Start-Transcript $logfile -Append | Out-Null Get-Date } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Try { $LoggedSID = Get-WmiObject -Class win32_computersystem | Select-Object -ExpandProperty Username | ForEach-Object { ([System.Security.Principal.NTAccount]$_).Translate([System.Security.Principal.SecurityIdentifier]).Value } } Catch { Write-Error -Message "Failed to gather SID for current user" -ErrorAction Stop } Try { $CurrentAzureADUser = (Get-ItemProperty -ErrorAction SilentlyContinue -Path "HKLM:\SOFTWARE\Microsoft\IdentityStore\Cache\$LoggedSID\IdentityCache\$LoggedSID" -Name UserName).UserName } Catch { Write-Error -Message "Failed to gather CurrentAzureADUser" -ErrorAction Stop } If (!($CurrentAzureADUser)) { Write-Output "Failed to gather CurrentAzureADUser, Exiting" ; Exit 0 } $UserName = $CurrentAzureADUser $AADTenantID = (Get-ChildItem -Path "HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo\" | Select-Object PSChildName).PSChildName $AADDeviceID = Get-AzureADDeviceID $Data = [PSCustomObject]@{ AzureADTenantID = $AADTenantID AzureADDeviceID = $AADDeviceID UserName = $UserName } $JSONData = $Data | ConvertTo-Json -Depth 9 $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" $headers.Add("Content-Type", "application/json") try { $Response = Invoke-RestMethod $FunctionURL -Method 'POST' -Headers $headers -Body $JSONData $Output = "OK " + $Response } catch { $Response = "Error Code: $($_.Exception.Response.StatusCode.value__)" $ResponseException = $_.Exception.Message $Output = $Response + " $ResponseException" } Write-Output $Output $User = $Response [datetime]$lastpasswordChange = $User.lastPasswordChangeDateTime -replace "T", " " -replace "Z","" $PasswordExpirationDate = ($lastpasswordChange).AddDays($PasswordExpirationDays) $StartDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") $TimeSpan = New-Timespan -Start $StartDate -End $PasswordExpirationDate If (($TimeSpan.Days -le 10) -and ($TimeSpan.Days -ge -5)) { Write-Output "Password Expires after $($TimeSpan.Days) days" Exit 1 } If ($logfilename) { Stop-Transcript | Out-Null } Exit 0 |
Azure Function
So onto the really cool part, now we’re going to the create the Azure function that will handle the call, the query and the response.
The Function App consists of basically 2 parts when you create it. Firstly its the Application it Runs on and secondly the Functions within the plan. So we’re gonna start off by creating the Function App and then Function that will run the code. Also this will add Azure consumption and cost, but fortunately for us with the Consumption plan we get a bunch of free executions monthly and if your not in a very big environment this shouldn’t add any extra cost. Feel free to check out this pricing option for Azure Functions.
Creating the Function App
- Start off by searching for Function App in Azure
- Select Create
- Chose a fitting Subscription, Resource Group, and Region
- Select a fitting Name for your Function App, these names need to be unique, I chose “IntunePasswordNotificationTenantName” but it doesn’t matter
- Select Code, Powershell Core, Version 7.0
- Select Windows for Operating System and Consumption for Plan type
- Go through the rest of the tabs at your own preference and then Create
When the Function App is finished creating we can now head into it and start creating our Function
- Select the blade Functions
- Select Create
- You know get a few option of how to publish your code and you can chose your own preference, for this case I chose Develop in Portal
- Chose HTTP trigger as Template
- Select a fitting Name for your Function I chose “IntunePasswordNotification” but it doesn’t matter
- Select Function as Authorization level
- Create
Now to enter the code in our Function
- Select the Function you created
- Select the Code + Test blade
- Here we need to enter the code that will run when we call on the function
To give just a little bit more insight on what is actually going on here for those who might be wondering. The function we’re creating is going to receive a “Payload” (a JSON body) from our detection script. This payload will be the AzureAD Device ID of the device that calls it, along with the Tenant ID and the Username of the logged on user. So the first thing in our script is going to be a parameter that is going to be the the input for the rest of the script, containing said payload.
The script will after this authenticate to the MS Graph and Azure AD using the Enterprise Application details you will input in the Script. After this it will verify that the device exists in the Azure AD and if it does it will do an MSGraph query to check when the Username provided in the Payload last set his password and return this info to the device.
Input the Enterprise Application details in the Function code then paste the code into the Function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
############################## ## Parameters ############################## using namespace System.Net # Input bindings are passed in via param block. param($Request) [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 ############################## ## Variables ############################## $TenantID = "Enter Tenant ID" $ApplicationID = "Enter Application ID" $AppSecret = 'Enter Secret Value' ############################## ## Functions ############################## Function Get-MSGraphAuthToken { [cmdletbinding()] Param( [parameter(Mandatory = $true)] [pscredential]$Credential, [parameter(Mandatory = $true)] [string]$tenantID ) #Get token $AuthUri = "https://login.microsoftonline.com/$TenantID/oauth2/token" $Resource = 'graph.microsoft.com' $AuthBody = "grant_type=client_credentials&client_id=$($credential.UserName)&client_secret=$($credential.GetNetworkCredential().Password)&resource=https%3A%2F%2F$Resource%2F" $Response = Invoke-RestMethod -Method Post -Uri $AuthUri -Body $AuthBody If ($Response.access_token) { return $Response.access_token } Else { Throw "Authentication failed" } } Function Invoke-MSGraphQuery { [CmdletBinding(DefaultParametersetname = "Default")] Param( [Parameter(Mandatory = $true, ParameterSetName = 'Default')] [Parameter(Mandatory = $true, ParameterSetName = 'Refresh')] [string]$URI, [Parameter(Mandatory = $false, ParameterSetName = 'Default')] [Parameter(Mandatory = $false, ParameterSetName = 'Refresh')] [string]$Body, [Parameter(Mandatory = $true, ParameterSetName = 'Default')] [Parameter(Mandatory = $true, ParameterSetName = 'Refresh')] [string]$token, [Parameter(Mandatory = $false, ParameterSetName = 'Default')] [Parameter(Mandatory = $false, ParameterSetName = 'Refresh')] [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string]$method = "GET", [Parameter(Mandatory = $false, ParameterSetName = 'Default')] [Parameter(Mandatory = $false, ParameterSetName = 'Refresh')] [switch]$recursive, [Parameter(Mandatory = $true, ParameterSetName = 'Refresh')] [switch]$tokenrefresh, [Parameter(Mandatory = $true, ParameterSetName = 'Refresh')] [pscredential]$credential, [Parameter(Mandatory = $true, ParameterSetName = 'Refresh')] [string]$tenantID ) $authHeader = @{ 'Accept' = 'application/json' 'Content-Type' = 'application/json' 'Authorization' = "Bearer $Token" } [array]$returnvalue = $() Try { If ($body) { $Response = Invoke-RestMethod -Uri $URI -Headers $authHeader -Body $Body -Method $method -ErrorAction Stop } Else { $Response = Invoke-RestMethod -Uri $URI -Headers $authHeader -Method $method -ErrorAction Stop } } Catch { If (($Error[0].ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue).error.Message -eq 'Access token has expired.' -and $tokenrefresh) { $token = Get-MSGraphAuthToken -credential $credential -tenantID $TenantID $authHeader = @{ 'Content-Type' = 'application/json' 'Authorization' = $Token } $returnvalue = $() If ($body) { $Response = Invoke-RestMethod -Uri $URI -Headers $authHeader -Body $Body -Method $method -ErrorAction Stop } Else { $Response = Invoke-RestMethod -Uri $uri -Headers $authHeader -Method $method } } Else { Throw $_ } } $returnvalue += $Response If (-not $recursive -and $Response.'@odata.nextLink') { Write-Warning "Query contains more data, use recursive to get all!" Start-Sleep 1 } ElseIf ($recursive -and $Response.'@odata.nextLink') { If ($PSCmdlet.ParameterSetName -eq 'default') { If ($body) { $returnvalue += Invoke-MSGraphQuery -URI $Response.'@odata.nextLink' -token $token -body $body -method $method -recursive -ErrorAction SilentlyContinue } Else { $returnvalue += Invoke-MSGraphQuery -URI $Response.'@odata.nextLink' -token $token -method $method -recursive -ErrorAction SilentlyContinue } } Else { If ($body) { $returnvalue += Invoke-MSGraphQuery -URI $Response.'@odata.nextLink' -token $token -body $body -method $method -recursive -tokenrefresh -credential $credential -tenantID $TenantID -ErrorAction SilentlyContinue } Else { $returnvalue += Invoke-MSGraphQuery -URI $Response.'@odata.nextLink' -token $token -method $method -recursive -tokenrefresh -credential $credential -tenantID $TenantID -ErrorAction SilentlyContinue } } } Return $returnvalue } ############################## ## Scriptstart ############################## # Write to the Azure Functions log stream. Write-Host "PowerShell HTTP trigger function processed a request." # Setting inital Status Code: $StatusCode = [HttpStatusCode]::OK Write-Output "Request from Device: $($Request.Headers.from)" $InboundDeviceID= $Request.Body.AzureADDeviceID $InboundTenantID = $Request.Body.AzureADTenantID # Write to the Azure Functions log stream. Write-Information "Inbound DeviceID $($InboundDeviceID)" Write-Information "Inbound TenantID $($InboundTenantID)" Write-Information "Environment TenantID $TenantID" # Verify request comes from correct tenant if ($TenantID -eq $InboundTenantID) { Write-Information "Request is comming from correct tenant" #Authenticate to Enterprise Application $Credential = New-Object System.Management.Automation.PSCredential($ApplicationID, (ConvertTo-SecureString $AppSecret -AsPlainText -Force)) $Token = Get-MSGraphAuthToken -credential $Credential -TenantID $TenantID # Query graph for device verification $resourceURL = "https://graph.microsoft.com/v1.0/devices?`$filter=deviceId eq '$($InboundDeviceID)'" $DeviceIDResponse = Invoke-MSGraphQuery -method GET -URI $resourceURL -token $token $DeviceIDResponse = $DeviceIDResponse.Value # Assign to variables for matching $DeviceID = $DeviceIDResponse.deviceId $DeviceEnabled = $DeviceIDResponse.accountEnabled Write-Information "DeviceID $DeviceID" Write-Information "DeviceEnabled: $DeviceEnabled" # Verify request comes from a valid device if ($DeviceID -eq $InboundDeviceID) { Write-Information "Request is coming from a valid device in Azure AD" if ($DeviceEnabled -eq "True") { Write-Information "Requesting device is not disabled in Azure AD" } else { Write-Warning "Device is not enabled - Forbidden" $StatusCode = [HttpStatusCode]::Forbidden } } else { Write-Warning "Device not in my Tenant - Forbidden" $StatusCode = [HttpStatusCode]::Forbidden } } else { Write-Warning "Tenant not allowed - Forbidden" $StatusCode = [HttpStatusCode]::Forbidden } $userName = $Request.Body.userName Write-Output "userName from Input: $userName" $resourceURL = "https://graph.microsoft.com/v1.0/users/$UserName`?`$select=userprincipalname,lastPasswordChangeDateTime" $User = Invoke-MSGraphQuery -method GET -URI $resourceURL -token $token $Data = [PSCustomObject]@{ userprincipalname = $User.userprincipalname lastPasswordChangeDateTime = $User.lastPasswordChangeDateTime } $JSONData = $Data | ConvertTo-Json -Depth 9 $body = $JSONData # Associate values to output bindings by calling 'Push-OutputBinding'. Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{ StatusCode = $StatusCode Body = $body }) |
After we’re finished uploading and saving the code we need to get the URL and paste it into our detection script
- From the Code + Test blade select Get function URL
- Copy the URL
- Paste it into the Detection Script on Row 9
- Upload the Detection Script into Intune
Done!
Note: when in the Function App you can open the Log slider at the bottom to view and verify what is going on when the HTTP call is coming in from the device
Try it out, tinker with it how you like and if you have any way to improve please feel free to comment
Further
Surely there are still ways to improve upon this and would love if someone would come with new ideas. One of the things I see as a clear improvement still stands from the last post and it would be to restring the AAD application to only being able to fetching the required information instead of the whole profile. I would be ever so grateful for feedback, comments or ideas how to improve upon this further
Thanks for reading
Hi
Nice solution friend
All time in function app is throwing the following error:
[Error] EXCEPTION: Response status code does not indicate success: 403 (Forbidden).Exception :Type : Microsoft.PowerShell.Commands.HttpResponseExceptionResponse : StatusCode: 403, ReasonPhrase: ‘Forbidden’, Version: 1.1, Content: System.Net.Http.HttpConnectionResponseContent, Headers:{Cache-Control: no-cacheTransfer-Encoding: chunkedStrict-Transport-Security: max-age=31536000request-id: 902bb297-cd86-4724-8587-9a154e0d58d6client-request-id: 902bb297-cd86-4724-8587-9a154e0d58d6x-ms-ags-diagnostic: {“ServerInfo”:{“DataCenter”:”France Central”,”Slice”:”E”,”Ring”:”5″,”ScaleUnit”:”001″,”RoleInstance”:”PA2PEPF00000F3C”}}x-ms-resource-unit: 1Date: Tue, 31 May 2022 17:49:15 GMTContent-Type: application/json}TargetSite :Name : ThrowTerminatingErrorDeclaringType : System.Management.Automation.MshCommandRuntime, System.Management.Automation, Version=7.0.8.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35MemberType : MethodModule : System.Management.Automation.dllStackTrace :at System.Management.Automation.MshCommandRuntime.ThrowTerminatingError(ErrorRecord errorRecord)Message : Response status code does not indicate success: 403 (Forbidden).Source : System.Management.AutomationHResult : -2146233088TargetObject : Method: GET, RequestUri: ‘https://graph.microsoft.com/v1.0/users/xxxxx@xxxxxxx.onmicrosoft.com?$select=userprincipalname,lastPasswordChangeDateTime’, Version: 1.1, Content: System.Net.Http.StringContent, Headers:{Authorization: Bearer eyJ0eXAiOiJKV1QiLCJub25jZSI6InBPRWZPLUFVRHNEUWVacTU3ZW5Rd29sZGVHQ1Zsc3d2Mk5xaENjemJ5dFUiLCJhbGciOiJSUzI1NiIsIng1dCI6ImpTMVhvMU9XRGpfNTJ2YndHTmd2UU8yVnpNYyIsImtpZCI6ImpTMVhvMU9XRGpfNTJ2YndHTmd2UU8yVnpNYyJ9.eyJhdWQiOiJodHRwczovL2dyYXBoLm1pY3Jvc29mdC5jb20vIiwiaXNzIjoiaHR0cHM6Ly9zdHMud2luZG93cy5uZXQvODM2ZWQwNjUtMjdhYy00ZjUyLTg0YjUtOThmOThmNzQ0MTZkLyIsImlhdCI6MTY1NDAxOTA1NiwibmJmIjoxNjU0MDE5MDU2LCJleHAiOjE2NTQwMjI5NTYsImFpbyI6IkUyWmdZSGl6NzdQSWxSMmFTMjlOWTFaeTFwamdBUUE9IiwiYXBwX2Rpc3BsYXluYW1lIjoiUGFzc3dvcmQgUmVtaW5kZXIiLCJhcHBpZCI6IjlkMDEzNDc5LWE2NzEtNDZlZC04ZTA1LTA0MDkwZmZmYTFmOCIsImFwcGlkYWNyIjoiMSIsImlkcCI6Imh0dHBzOi8vc3RzLndpbmRvd3MubmV0LzgzNmVkMDY1LTI3YWMtNGY1Mi04NGI1LTk4Zjk4Zjc0NDE2ZC8iLCJpZHR5cCI6ImFwcCIsIm9pZCI6IjE2YjJhMGZhLWI5NjgtNGI2Ny04YjFhLTdkOTgxNzNjNWU0MCIsInJoIjoiMC5BVGtBWmRCdWc2d25Vay1FdFpqNWozUkJiUU1BQUFBQUFBQUF3QUFBQUFBQUFBQTVBQUEuIiwicm9sZXMiOlsiRGV2aWNlLlJlYWQuQWxsIl0sInN1YiI6IjE2YjJhMGZhLWI5NjgtNGI2Ny04YjFhLTdkOTgxNzNjNWU0MCIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJFVSIsInRpZCI6IjgzNmVkMDY1LTI3YWMtNGY1Mi04NGI1LTk4Zjk4Zjc0NDE2ZCIsInV0aSI6IlVLNFllOXljYWtTNFZJNTlnVHRPQUEiLCJ2ZXIiOiIxLjAiLCJ3aWRzIjpbIjA5OTdhMWQwLTBkMWQtNGFjYi1iNDA4LWQ1Y2E3MzEyMWU5MCJdLCJ4bXNfdGNkdCI6MTU3MzU1MTIzOH0.MwVEuLxDtVMFVXHxSVrrrpYLA9-C765st8sjvv3OdYJEjFgS0tyiUr2JY37N1b4-RDkh8THZLnzksipe6zoSCZ28u8jnLkKCFL_u216LRGDiEU5p2–kCfvvmYi2o8-PiABr8ncqLL3pjm0jacKoXJlZwg-yr9fBixj7R30yZXOe_RyoqWkBncWkbVGefTDvwcZFCPLf6t3CqjXOmveBPmtfNIZShaIwAmoYx8LNGajQMproDJb2LLtHebQx6xPb8snQJqzhhIIfTmJv-MkF99WSiYzaL2wo9l226_V_VbQI82fGh-v3kUMzcJsZLrhl24OYkxuFI-5rJuYpWIAg0gAccept: application/jsonUser-Agent: Mozilla/5.0User-Agent: (Windows NT 10.0; Microsoft Windows 10.0.14393; en-US)User-Agent: PowerShell/7.0.8Content-Type: application/jsonContent-Length: 0}CategoryInfo : InvalidOperation: (Method: GET, Reques…ontent-Length: 0}:HttpRequestMessage) [Invoke-RestMethod], HttpResponseExceptionFullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommandErrorDetails : {“error”:{“code”:”Authorization_RequestDenied”,”message”:”Insufficient privileges to complete the operation.”,”innerError”:{“date”:”2022-05-31T17:49:16″,”request-id”:”902bb297-cd86-4724-8587-9a154e0d58d6″,”client-request-id”:”902bb297-cd86-4724-8587-9a154e0d58d6″}}}InvocationInfo :MyCommand : Invoke-RestMethodScriptLineNumber : 91OffsetInLine : 25HistoryId : 1ScriptName : D:\home\site\wwwroot\PasswordReminder\run.ps1Line : $Response = Invoke-RestMethod -Uri $URI -Headers $authHeader -Method $method -ErrorAction StopPositionMessage : At D:\home\site\wwwroot\PasswordReminder\run.ps1:91 char:25+ … $Response = Invoke-RestMethod -Uri $URI -Headers $authHeader -Method …+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~PSScriptRoot : D:\home\site\wwwroot\PasswordReminderPSCommandPath : D:\home\site\wwwroot\PasswordReminder\run.ps1InvocationName : Invoke-RestMethodCommandOrigin : InternalScriptStackTrace : at Invoke-MSGraphQuery, D:\home\site\wwwroot\PasswordReminder\run.ps1: line 91at , D:\home\site\wwwroot\PasswordReminder\run.ps1: line 206
2022-05-31T17:49:18.732 [Error] Executed ‘Functions.PasswordReminder’ (Failed, Id=7e2cd62e-7cc2-4345-9457-62dfe9474119, Duration=5113ms)Result: FailureException: {“error”:{“code”:”Authorization_RequestDenied”,”message”:”Insufficient privileges to complete the operation.”,”innerError”:{“date”:”2022-05-31T17:49:16″,”request-id”:”902bb297-cd86-4724-8587-9a154e0d58d6″,”client-request-id”:”902bb297-cd86-4724-8587-9a154e0d58d6″}}}Stack: at System.Management.Automation.Runspaces.PipelineBase.Invoke(IEnumerable input)at System.Management.Automation.Runspaces.Pipeline.Invoke()at System.Management.Automation.PowerShell.Worker.ConstructPipelineAndDoWork(Runspace rs, Boolean performSyncInvoke)at System.Management.Automation.PowerShell.Worker.CreateRunspaceIfNeededAndDoWork(Runspace rsToUse, Boolean isSync)at System.Management.Automation.PowerShell.CoreInvokeHelper[TInput,TOutput](PSDataCollection
1 input, PSDataCollection
1 output, PSInvocationSettings settings)at System.Management.Automation.PowerShell.CoreInvoke[TInput,TOutput](PSDataCollection1 input, PSDataCollection
1 output, PSInvocationSettings settings)at System.Management.Automation.PowerShell.CoreInvoke[TOutput](IEnumerable input, PSDataCollection1 output, PSInvocationSettings settings)at System.Management.Automation.PowerShell.Invoke[T](IEnumerable input, IList
1 output, PSInvocationSettings settings)at System.Management.Automation.PowerShell.Invoke[T]()at Microsoft.Azure.Functions.PowerShellWorker.PowerShell.PowerShellExtensions.InvokeAndClearCommands[T](PowerShell pwsh) in /mnt/vss/_work/1/s/src/PowerShell/PowerShellExtensions.cs:line 45at Microsoft.Azure.Functions.PowerShellWorker.PowerShell.PowerShellManager.InvokeNonOrchestrationFunction(DurableController durableController, IDictionary outputBindings) in /mnt/vss/_work/1/s/src/PowerShell/PowerShellManager.cs:line 301at Microsoft.Azure.Functions.PowerShellWorker.PowerShell.PowerShellManager.InvokeFunction(AzFunctionInfo functionInfo, Hashtable triggerMetadata, TraceContext traceContext, RetryContext retryContext, IList`1 inputData, FunctionInvocationPerformanceStopwatch stopwatch) in /mnt/vss/_work/1/s/src/PowerShell/PowerShellManager.cs:line 230at Microsoft.Azure.Functions.PowerShellWorker.RequestProcessor.InvokeFunction(AzFunctionInfo functionInfo, PowerShellManager psManager, FunctionInvocationPerformanceStopwatch stopwatch, InvocationRequest invocationRequest) in /mnt/vss/_work/1/s/src/RequestProcessor.cs:line 335at Microsoft.Azure.Functions.PowerShellWorker.RequestProcessor.ProcessInvocationRequestImpl(StreamingMessage request, AzFunctionInfo functionInfo, PowerShellManager psManager, FunctionInvocationPerformanceStopwatch stopwatch) in /mnt/vss/_work/1/s/src/RequestProcessor.cs:line 307Hi Jospeh!
Sorry for the late reply, actually this message indicates not enough permissions on the enterprise application. Can you redo the step for enterprise application?
Be sure to grant the applications, if it doesn’t work out still feel free to message me on Reddit and we can chat about it to find the solution?
https://www.reddit.com/user/IntRangeNoShut
We are using a script that queries the on prem AD (directly or via the VPN that is open), as we are using PTA for authentication) and based on that we generate a toast message to change the PW.
Query script used:
#=============================================================================================================================
#
# Script Name: Detect_Expired_User_Password.ps1
# Description: Detect almosted password by
# Notes: Configure $expiringdays to select the number of days before a user should be warned.
# Author: Frank van Rijt
#=============================================================================================================================
# Define Variables
$results = @()
$expiringDays = 14
$DomainName=”OnPrem Active Directory Domain”
try {
Add-Type -AssemblyName System.DirectoryServices.AccountManagement
$PrincipalContext = [System.DirectoryServices.AccountManagement.PrincipalContext]::new([System.DirectoryServices.AccountManagement.ContextType]::Domain,$DomainName)
$SamAccountName = ([System.DirectoryServices.AccountManagement.Principal]::FindByIdentity($PrincipalContext,[System.DirectoryServices.AccountManagement.IdentityType]::SamAccountName,[Environment]::UserName)).SamAccountName
$PrincipalContext.Dispose()
}
catch {
$errMsg = “Password Age Check warning, cannot query Active Directory for user. No connection to DGN?”
Write-Error $errMsg
exit 0
}
if (($SamAccountName) -AND ($DomainName)) {
try {
$Root = [ADSI] “LDAP://$($DomainName)”
$Searcher = New-Object System.DirectoryServices.DirectorySearcher($Root, “(SamAccountName=$($SamAccountName))”)
$Searcher.PropertiesToLoad.Add(“msDS-UserPasswordExpiryTimeComputed”) | Out-Null
$Result = $Searcher.FindOne();
$ExpiryDate = [DateTime]::FromFileTime([Int64]::Parse((($Result.Properties[“msDS-UserPasswordExpiryTimeComputed”])[0]).ToString()))
}
catch {
Write-Error “Failed to retrieve password expiration date from Active Directory.”
exit 0
}
if ($ExpiryDate) {
$LocalCulture = Get-Culture
$RegionDateFormat = [System.Globalization.CultureInfo]::GetCultureInfo($LocalCulture.LCID).DateTimeFormat.LongDatePattern
$ExpiryDate = Get-Date $ExpiryDate -f “$RegionDateFormat”
$Today = Get-Date -f “$RegionDateFormat”
$DateDiff = New-TimeSpan -Start $Today -End $ExpiryDate
if ($DateDiff.Days -le $expiringDays -AND $DateDiff.Days -ge 0) {
Write-Output “Password expiring within 14 days: ” $($DateDiff.Days)
exit 1
}
else {
Write-Error $($DateDiff.Days)
exit 0
}
}
elseif (-NOT($ExpiryDate)) {
Write-Error “No Password expiration date found”
exit 0
}
}
elseif (-NOT($SamAccountName) -OR ($DomainName)) {
Write-Error “No user or domain found, nothing script can handle”
exit 0
}
Hi!
Very cool,
I guess this is not viable if there is no line of sight to AD but if you are using a Always on VPN this could be cool!
Thanks for Sharing
Hi,
I think there should be a difference between App Registration and Enterprise App. Both are valid choices in Azure but with different options. Especially the needed secrets are only in App Registrations.
Could you please clarify. I would like to use your script, as we don’t store mail-addresses in AAD and the users don’t get a notification.
Kind regards
Hi Rene! Sorry for the late response. So in your case you want to do an App Registration. Basically an Enterprise application is a application from a third party that requires permissions in your Environment, and an app registration is a app you register and control. Hope that helps, let me know if I can help you out any further.
Kind regards
Hi,
After following the guides i deployed it to 5 Machines, however all of them has Detection Status showing “With Issues” and “Remediation Status showing “Recurred”
Further error logs for Pre-Remediation is below
==================================================================================================
Cannot convert value “” to type “System.DateTime”. Error: “String was not recognized as a valid DateTime.” At C:\WINDOWS\IMECache\HealthScripts\1c7bfec0-b593-40a3-8953-b608019a14ec_2\detect.ps1:123 char:1 + [datetime]$lastpasswordChange = $User.lastPasswordChangeDateTime -rep … + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : MetadataError: (:) [], ArgumentTransformationMetadataException + FullyQualifiedErrorId : RuntimeException You cannot call a method on a null-valued expression. At C:\WINDOWS\IMECache\HealthScripts\1c7bfec0-b593-40a3-8953-b608019a14ec_2\detect.ps1:125 char:1 + $PasswordExpirationDate = ($lastpasswordChange).AddDays($PasswordExpi … + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : InvalidOperation: (:) [], RuntimeException + FullyQualifiedErrorId : InvokeMethodOnNull New-TimeSpan : Cannot bind parameter ‘End’ to the target. Exception setting “End”: “Cannot convert null to type “System.DateTime”.” At C:\WINDOWS\IMECache\HealthScripts\1c7bfec0-b593-40a3-8953-b608019a14ec_2\detect.ps1:129 char:49 + … imeSpan = New-Timespan -Start $StartDate -End $PasswordExpirationDate + ~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : WriteError: (:) [New-TimeSpan], ParameterBindingException + FullyQualifiedErrorId : ParameterBindingFailed,Microsoft.PowerShell.Commands.NewTimeSpanCommand
Post Remediation is below
====================================================================================================
Cannot convert value “” to type “System.DateTime”. Error: “String was not recognized as a valid DateTime.” At C:\WINDOWS\IMECache\HealthScripts\1c7bfec0-b593-40a3-8953-b608019a14ec_2\detect.ps1:123 char:1 + [datetime]$lastpasswordChange = $User.lastPasswordChangeDateTime -rep … + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : MetadataError: (:) [], ArgumentTransformationMetadataException + FullyQualifiedErrorId : RuntimeException You cannot call a method on a null-valued expression. At C:\WINDOWS\IMECache\HealthScripts\1c7bfec0-b593-40a3-8953-b608019a14ec_2\detect.ps1:125 char:1 + $PasswordExpirationDate = ($lastpasswordChange).AddDays($PasswordExpi … + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : InvalidOperation: (:) [], RuntimeException + FullyQualifiedErrorId : InvokeMethodOnNull New-TimeSpan : Cannot bind parameter ‘End’ to the target. Exception setting “End”: “Cannot convert null to type “System.DateTime”.” At C:\WINDOWS\IMECache\HealthScripts\1c7bfec0-b593-40a3-8953-b608019a14ec_2\detect.ps1:129 char:49 + … imeSpan = New-Timespan -Start $StartDate -End $PasswordExpirationDate + ~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : WriteError: (:) [New-TimeSpan], ParameterBindingException + FullyQualifiedErrorId : ParameterBindingFailed,Microsoft.PowerShell.Commands.NewTimeSpanCommand
Hi Jackson,
did you find a solution for this? I’m facing the same issue
Have you investigated using a Managed Identity instead of an app registration?
It seems like it should be doable to me.
Excellent post! Trying to get it working and getting this issue:
ERROR: Response status code does not indicate success: 401 (Unauthorized). Exception : Type : Microsoft.PowerShell.Commands.HttpResponseException Response : StatusCode: 401, ReasonPhrase: ‘Unauthorized’, Version: 1.1, Content: System.Net.Http.HttpConnectionResponseContent, Headers:
It continues to not liking line 38 (or 36 in your code):
InvocationName : Invoke-RestMethod CommandOrigin : Internal ScriptStackTrace : at Get-MSGraphAuthToken, C:\home\site\wwwroot\IntunePasswordNotification\run.ps1: line 38 at ,
Tried to change the line to use “https://$Resource/” but it didn’t help:
$AuthBody = “grant_type=client_credentials&client_id=$($credential.UserName)&client_secret=$($credential.GetNetworkCredential().Password)&resource=https%3A%2F%2F$Resource%2F”
Can you check if the MSGraph connection to the function needs to be updated? I am still learning Graph and not sure what I could do to get it working.
Thanks!
Great article!
We have some users who have had their names changed due to marriage etc. These users had their UPN and email addresses changed, and they could sign in easily using the updated UPN keeping their current profile. I found that the information being retrieved by this script (like all the others) returns the original details.
I have found that using the registry keys HKCU:\SOFTWARE\Microsoft\IdentityCRL\TokenBroker\DefaultAccount\accountid gives you the user ObjectID and TenantID allowing you to use that in the Graph call.
Have you experienced this before?