Use AAD Connect to disable accounts with expired on-premises passwords

This week, I received an email from a colleague asking if there was a way to work around the default behavior described in /en-us/azure/active-directory/connect/active-directory-aadconnectsync-implement-password-synchronization:

Password expiration policy

If a user is in the scope of password synchronization, the cloud account password is set to Never Expire.

You can continue to sign in to your cloud services by using a synchronized password that is expired in your on-premises environment. Your cloud password is updated the next time you change the password in the on-premises environment.

So, when you are only using Password Hash synchronization, the "expired password" detail isn't synced to AAD, and users can continue to sign in.

Here's a workaround that I've come up with to help in some scenarios.  It's not perfect, by any means, and may seem a bit of a kludge, but it should help minimize exposure for accounts with expired passwords.  It requires 4 steps:

  1. Synchronize the msDS-User-Account-Control-Computed computed attribute to a static AD attribute (I call it the UserAccountControlValue attribute) via scheduled task.  The attribute I selected by default is msDS-CloudExtensionAttribute1, but you can choose any unused attribute.
  2. Update the AD connector to synchronize the attribute you have selected (if it's not already in the default attribute set).
  3. Create an AAD Connect sync rule to check for the presence of the value 8388608 in the AD attribute specified for UserAccountControlValue attribute).  If it's there, the set accountEnabled to FALSE and set the 'info' attribute to DisabledByExpiredPasswordSyncRule.
  4. Create an AAD Connect sync rules to check the value of UserAccountControlValue attribute as well as the value of 'info.'  If the UserAccountControlValue is set to something besides 8388608 (password expired) and the info attribute is set to DisabledByExpiredPasswordSyncRule, then set accountEnabled to TRUE and set info to NULL.  That way, we're only enabling accounts that have previously been disabled by the sync rule (and not enabling other accounts that may have been purposely disabled).

Before you begin, disable the AAD Connect Sync Scheduler.

Synchronize msDS-User-Account-Control-Computed

Back in the olden days, the userAccountControl attribute had a number of flags, and you could use those flags to determine the state of an account.  You can learn more about the property flags here (https://msdn.microsoft.com/en-us/library/ms680832(v=vs.85).aspx) and here (https://msdn.microsoft.com/en-us/library/aa772300(v=vs.85).aspx).  In the current releases of Windows, however, this attribute is actually computed real-time when the user properties are retrieved.  This is great to get the up-to-the-minute detail for account properties, but it's not so great for state-based applications like FIM/MIM/AAD Connect since they import attributes into a database table and then perform their comparisons and operations on these static values.

To my understanding, this is fundamentally why we are unable to sync the "expired" state of an account.

Enter the workaround.

In this workaround, I created a script that retrieves all of these values for users on a regular basis, stores the value in memory, and then checks it against the previously saved value.  If they match, nothing happens.  If they're different, the new value gets saved back out to the specified static attribute.  Messy?  Yes.  Kludgy? Definitely.  Works?  Three yes's for me.  But, once that's done, the AAD Connector delta import step can pick it up and then perform processing on it.

 <#
.SYNOPSIS
Save the userAccountControl flag value to an extension attribute.

.PARAMETER UserAccountControlValue
Choose which attribute to store the computed userAccountControl value.  By
default, the script will use msDS-CloudExtensionAttribute1.

.PARAMETER Logfile
Specify the path to the log file. By default, the script will create and append
to a log file in $env:TEMP.

#>
Param (
    $UserAccountControlValue = 'msDS-CloudExtensionAttribute1',
    $Logfile = "$env:TEMP\SyncPasswordExpiryLog.csv"
    )

If (!(Test-Path $Logfile))
{
    $LogData = """" + "Identity" + """" + "," + """" + "Attribute" + """" + "," + """" + "OldValue" + """" + "," + """" + "NewValue" + """"
    Add-Content -Path $Logfile -Value $LogData
}

function LogWrite($Identity, $Attribute, $OldValue, $NewValue)
{
    $LogData = """" + $Identity + """" + "," + """" + $Attribute + """" + "," + """" + $OldValue + """" + "," + """" + $NewValue + """"
    Add-Content -Path $Logfile -Value $LogData
}

Write-Host "UserAccountControlValue attribute is $($UserAccountControlValue)"
$cmd = "[array]`$Users = Get-ADUser -Filter * -prop objectGuid, msDS-User-Account-Control-Computed,$($UserAccountControlValue)"

Invoke-Expression $cmd

# Loop through all users
foreach ($obj in $users)
{
    # Set Attribute values
    $Existing = $obj.$($UserAccountControlValue)
    $New = $obj.'msDS-User-Account-Control-Computed'
    $User = $obj.DistinguishedName
    If (!($Existing -eq $New))
    {
        LogWrite -Identity $User -Attribute $UserAccountControlValue -OldValue $Existing -NewValue $New
        $UpdateUserCmd = "Set-ADUser `"$($User)`" -Replace @{ `"$($UserAccountControlValue)`" = `"$($New)`" }"
        Invoke-Expression $UpdateUserCmd
    }
}

Run the script to copy the value from msDS-User-Account-Control-Computed to the attribute you're going to use for $UserAccountControlValue.

Update the AD Connector to capture data from the $UserAccountControlValue attribute

If you use the default attribute for the script, msDS-CloudExtensionAttribute1, you'll need to configure the AD connector to import that data from AD.  If you use one of the normal extension attributes (1-15), you won't need to do this step, as those are selected by default.

  1. Launch the Synchronization Service.
  2. On the Connectors tab, select the AD connector for your forest, right-click, and select Properties.
  3. Under Connector Designer, select Select Attributes.
  4. Under Select Attributes, click the Show All checkbox.
  5. Find the attribute you are using to store the UserAccountControl static value (msDS-CloudExtensionAttribute1 if you're using the default script settings) and select it.
  6. Click OK.
  7. Run a Full Import on the AD Connector (if your environment is large, this will take a while).

Create AAD Connect Sync Rule to disable expired accounts

The function of this rule is to perform two set operations on objects where msDS-cloudExtenstionAttribute1 (or whatever attribute you use to hold the UserAccountControl static data) is set to 8388608.  The rule will set accountEnabled to FALSE (which will prevent login to AAD) and the Info attribute to DisabledByExpiredPasswordSyncRule.  We'll be using the value in that attribute in the next rule as well to determine which accounts can get re-enabled by a rule.

  1. Launch the Synchronization Rules Editor.
  2. Under Direction, select Inbound, and then click Add new rule.
  3. Fill out the synchronization rule general information and click Next:
    1. Name: In from AD - Disable Accounts in Cloud with Expired Passwords
    2. Description: Disable Accounts in Cloud with Expired Passwords
    3. Connected System: [ choose your AD forest ]
    4. Connected System Object Type: user
    5. Metaverse Object Type: person
    6. Link Type: Join
    7. Precedence: [ choose a low, unused precedence, such as 80 ]
    8. Tag: [ blank ]
    9. Enable Password Sync: [ clear ]
    10. Disabled: [clear]
  4. On the Scoping Filter page, click Add Clause, and fill out the scoping details:
    1. Under Attribute, select msDS-cloudExtensionAttribute1 (or whatever AD attribute you are storing the UserAccountControl static value in)
    2. Under Operator, select EQUAL
    3. In the Value box, type 8388608
  5. Click Next.
  6. On the Join rules page, click Next.
  7. On the Transformations page, click Add Transformation, and add the following two transformations:
    1. Flow Type: Constant; Target Attribute: accountEnabled; source: FALSE
    2. Flow Type: Constant; Target Attribute: info; source DisabledByExpiredPasswordSyncRule
  8. Click Save.

Optional: If you don't disable the on-premises accounts that have expired passwords and want to scope this to only 'active' accounts, you can add an additional scoping filter for userAccountControl EQUALS 512.

Create AAD Connect Sync Rule to re-enable account after password has been reset

After a user has updated the on-premises password and the UserAccountControl static value sync has been performed, you'll probably want to re-enable users that had been disabled.  To do that, we're going to create one more rule.  This rule will be scoped to objects that have both the "info" attribute set to DisabledByExpiredPasswordSyncRule (so we know we're only enabling objects that the other sync rule disabled due to expired passwords) and that have the UserAccountControl static value attribute (msDS-CloudExtensionAttribute1, by default) set to a value other that 8388608.  If objects meet those two criteria, then we'll set accountEnabled to TRUE and clear the info attribute.

  1. Launch the Synchronization Rules Editor.
  2. Under Direction, select Inbound, and then click Add new rule.
  3. Fill out the synchronization rule general information and click Next:
    1. Name: In from AD - Re-Enable Accounts after Password Reset
    2. Description: Re-Enable Accounts after Password Reset
    3. Connected System: [ choose your AD forest ]
    4. Connected System Object Type: user
    5. Metaverse Object Type: person
    6. Link Type: Join
    7. Precedence: [ choose a low, unused precedence, such as 79 ]
    8. Tag: [ blank ]
    9. Enable Password Sync: [ clear ]
    10. Disabled: [clear]
  4. On the Scoping Filter page, click Add Clause, and fill out the scoping details:
    1. Under Attribute, select msDS-cloudExtensionAttribute1 (or whatever AD attribute you are storing the UserAccountControl static value in)
    2. Under Operator, select NOTEQUAL
    3. In the Value box, type 8388608
  5. Click Add Clause to add another clause to the scoping filter, and fill out the scoping details:
    1. Under Attribute, select info
    2. Under Operator, select EQUAL
    3. In the Value box, type DisabledByExpiredPasswordSyncRule
  6. Click Next.
  7. On the Join rules page, click Next.
  8. On the Transformations page, click Add Transformation, and add the following two transformations:
    1. Flow Type: Constant; Target Attribute: accountEnabled; source: TRUE
    2. Flow Type: Constant; Target Attribute: info; source NULL
  9. Click Save.

Optional: If you don't disable the on-premises accounts that have expired passwords and want to scope this to only 'active' accounts, you can add an additional scoping filter for userAccountControl EQUALS 512.

Process the users

Once you have the rules set, you can process the users.  I'd recommend tracking down a user with an expired password FIRST, and then just processing them:

  1. Run the UserAccountControl sync script.
  2. Verify that the user who should have an expired password has a value of 8388608 in the msDS-CloudExtensionAttribute1 attribute.
  3. Run a Delta Import on the AD Connector.
  4. On the AD Connector, right-click, select Search Connector Space, select RDN from the drop-down,  type CN= <username> and click Search.
  5. Select the User from the results box, and then click Preview.
  6. Ensure the Full Synchronization radio button is selected, then click Generate Preview.
  7. Expand Connector Updates.
  8. Expand the node CN={GUID} and click on Export Attribute Flow.
  9. Verify that accountEnabled has a Final Value of FALSE.

Repeat the preview steps for a user who does NOT have an expired password to verify that the accountEnabled flag is still set to TRUE.

If it all looks good, then you can select a few users, perform the manual synchronization (step 6 above), and export the values to Office 365.  If the testing works successfully, you should be able to re-enable the AAD Connect synchronization schedule.