Active directory password expiration notification

From Svendsen Tech Powershell Wiki
Jump to: navigation, search

Here is a ready-made, customizable PowerShell script for password expiration notification, warning users via e-mail when their Windows Active Directory user passwords are about to expire. You decide how many days before the passwords expire the users should be warned and what information they receive.


General Information About Password-Expiration-Notifier.ps1

The design logic behind the PowerShell script assumes you want to send one "first and last" warning mail. The idea is that you want to warn your users, for instance, 30 and 3 days before their passwords expire (or maybe 21 and 5). If you only want to send one warning, just use either the first_warning.txt file or the last_warning.txt file, and simply set the other password value to 500000 (in the configuration file, see below) or something else that won't ever be triggered.

The difference between the first and last warning is that there's an exclamation point after the subject of the last warning mail, for the sake of emphasis. Also, it will be logged as either "first warning mail sent" or "last warning mail sent".

You can write a custom message for the first and the last warning, and they can be different (or the same). Plain text files are used. Use the configuration file to configure which values the script uses for SMTP server, port, how many days before the passwords expire to warn, the "from" e-mail address, whether it should be run with authentication or not - and if SSL should be used. I've only added the option of using the logged on user's credentials or sending unauthenticated. I figure this will normally be run as a scheduled task on some server, not interactively. Use runas.exe or something similar if need be.

How To Use It

Typically, you would add it as a scheduled task on a server and run it daily. Avoid running it just before, or at, midnight, in case the AD/LDAP query spans two days, which potentially could lead to some users not being warned. I'm not sure if the implementation of the query prevents this, but why risk it.

I recommend setting it to run hidden and of course "whether the user is logged on or not". Depending on user object permissions in Active Directory, it might also be necessary to run it with elevated privileges (at least I experienced this on a 2008 R2 domain controller). Granting the "Read all properties" right in AD, or sufficient rights, to the user running the script is a good idea. This can be done from AD Users and Computers, by clicking View -> Advanced Features (make sure there's a check mark next to it), then opening properties on the container(s)/user(s), selecting the Security tab and setting permissions as you see fit.

You might get a message about the user needing the "Log on as a batch job" privilege. This privilege can be set in the Group Policy Editor. To set it for a single server, run gpedit.msc and navigate to Computer Configuration -> Windows Settings -> Security Settings -> Local Policies -> User Rights Assignment. Here you can add the relevant user to the "Log on as a batch job" policy.

Remember that you should specify the PowerShell executable as the file to run in Task Scheduler, and the script as the parameter. I recommend using the correct path to the desired PowerShell executable and the parameter -File "X:\path\to\Password-Expiration-Notifier.ps1". Click here for an overview of the PowerShell executable file system locations on 32- and 64-bit Windows.

Set the "Start in" path to the path all the files are in, in Task Scheduler.

If you need authentication, set the useDefaultCredentials value to true in the configuration file, and enter the username and password for an account in Task Scheduler. If you run it interactively, the current user's credentials are used.

Targeting Specific OUs

Ok, I should have supported this somehow. Workaround: Look for this line in the script:

$private:users = Get-QADUser -SizeLimit 0 | Select-Object SamAccountName, GivenName, Sn, $config.emailAttribute, PasswordExpires

To make it only target users in a specific OU, change it to, for instance (notice the -SearchRoot parameter):

$private:users = Get-QADUser -SearchRoot -SizeLimit 0 |
    Select-Object SamAccountName, GivenName, Sn, $config.emailAttribute, PasswordExpires

Using a Non-default Attribute

If you are using an attribute that's not included by default, you can use either the parameter "-IncludeAllProperties" to include all properties (will be slower) or "-IncludedProperties yourProp" to specifically include your (missing) property. Thanks to Steve Kutsenkow for pointing this out.

$private:users = Get-QADUser -SearchRoot -SizeLimit 0 -IncludeAllProperties | 
    Select-Object SamAccountName, GivenName, Sn, $config.emailAttribute, PasswordExpires


This script uses the Get-QADUser cmdlet from Quest ActiveRoles. Quest ActiveRoles requires .NET 3.5 SP1 or later. It will only work with PowerShell version 2 (and presumably later versions).

Configuration File

I decided to implement it using a simple, custom configuration file format similar to an INI file. It's not a real INI file though; it's a custom format. Basically it's in the format key=value. It seemed easier than a ton of parameters to the script specified on the command line.

Example pwdnot.conf:
  • smtp: Outgoing SMTP server to be used.
  • smtpPort: Outgoing SMTP server port to be used.
  • firstWarningDays: How many days before the password expires to send the content of first_warning.txt
  • lastWarningDays: How many days before the password expires to send the content of last_warning.txt
  • from: The "from" email address that will appear to have sent the warning email.
  • useDefaultCredentials: Either "true" or "false". If set to "true", the current user's credentials will be used for authenticating against the SMTP server.
  • enableSSL: Either "true" or "false". Enables SSL when set to true.

There's an optional value called emailAttribute. If you want to use a field different from the default Email LDAP/AD attribute field, you can specify for instance "PrimarySMTPAddress" like this:


If you use a value in the config file that isn't expected, it will be ignored, so to "comment out" emailAttribute, you could use anything, such as "xxxemailAttribute=Pager", or whatever.

Log Files And Other Files

At the top of the script there are some hard-coded file names:

$errorLog      = 'error.log'
$mailSentLog   = 'mail_sent.log'
$mailErrorLog  = 'mail_error.log'
$firstWarnFile = 'first_warning.txt'
$lastWarnFile  = 'last_warning.txt'
$configFile    = 'pwdnot.conf'
  • error.log: Contains all types of warnings and errors, except mail errors.
  • mail_sent.log: Contains a record of all successfully sent email.
  • mail_error.log: Contains all errors related to emailing users.
  • first_warning.txt: You need to create this and fill it with the first warning message.
  • last_warning.txt: You need to create this and fill it with the last warning message.
  • pwdnot.conf: Configuration file for Password-Expiration-Notifier.ps1. You need to edit/create this to suit your environment!


Below is a screenshot of a sample run where I've used the same value for the first and last warning, so the user receives both emails - and one user who doesn't have an email attribute.


And a screenshot of what the mail looks like in Thunderbird:



Source Code

The actual script source code. I wrote this at an earlier stage of learning PowerShell, so I no longer agree on all the style issues, and the logic is pretty convoluted, so it could probably do with a rewrite, but it does work as it is.

Svendsen Tech Password Expiration Notifier.

Emails users a custom notification message the specified number of days before
their passwords expire.
Edit pwdnot.conf to suit your environment. Edit last_warning.txt and
first_warning.txt to contain the first and last warning mail. To only
use one, just set the config file value for either the first or the last
"days" to something that won't ever be triggered, like 500000.

Please refer to the online Svendsen Tech PowerShell Wiki for further documentation:

Author: Joakim Svendsen, Svendsen Tech

# Hard-coded file names.
$errorLog      = 'error.log'
$mailSentLog   = 'mail_sent.log'
$mailErrorLog  = 'mail_error.log'
$firstWarnFile = 'first_warning.txt'
$lastWarnFile  = 'last_warning.txt'
$configFile    = 'pwdnot.conf'

# Utility function that turns unquoted, space-separated strings into an array.
# It means you can write "ql foo bar baz" rather than "@('foo', 'bar', 'baz')".
function ql { $args }

# Function to parse the configuration file.
function Parse-Config {
    $config = @{}
    if (-not (Test-Path -PathType Leaf $configFile)) {
        Write-Host "${date}: Fatal error: File $configFile not found. Processing aborted."
        "${date}: Fatal error: File $configFile not found. Processing aborted." | Out-File -Append $errorLog
        exit 1
    Get-Content $configFile | 
        Where-Object { $_ -match '\S' } | # Skip blank (whitespace only) lines.
        Foreach-Object { $key, $value = $_ -split '\s*=\s*'; $config.$key = $value }
    $requiredValues = ql smtp smtpPort from firstWarningDays lastWarningDays useDefaultCredentials enableSSL
    # Initialize this to false and exit after the loop if a required value is missing.
    [bool] $missingRequiredValue = $false
    foreach ($requiredValue in $requiredValues) {
        if (-not $config.ContainsKey($requiredValue)) {
            Write-Host "${date}: Error: Missing '$requiredValue'. Processing will be aborted."
            "${date}: Error: Missing '$requiredValue'. Processing will be aborted." | Out-File -Append $errorLog
            $missingRequiredValue = $true
    # Set the default e-mail address LDAP attribute/field unless specified (default: 'Email')
    if (-not $config.ContainsKey('emailAttribute')) {
        $config.'emailAttribute' = 'Email'
    # Exit the program if a required value is missing in the configuration file.
    if ($missingRequiredValue) { exit 2 }

# Function to actually send the mail
function Send-Warn-Mail {
          [string] $private:type,
          [string] $private:to,
          [string] $private:message,
          [string] $private:username
    if ($private:type -ieq 'first') {
        $private:subject = 'Your Password Will Expire In ' + $config.firstWarningDays + ' Days'
    elseif ($private:type -ieq 'last') {
        $private:subject = 'Your Password Will Expire In ' + $config.lastWarningDays + ' Days!'
    else {
        Write-Host "${date}: Wrong type (not first/last) received in Send-Warn-Mail for $private:to. Skipping."
        "${date}: Wrong type (not first/last). Error sending to $private:to" | Out-File -append $mailErrorLog
    $private:SmtpClient = New-Object System.Net.Mail.SmtpClient($config.smtp, $config.smtpPort)
    # If useDefaultCredentials is set to true, change the property on the SMTP client object.
    # It is false by default.
    if ($config.useDefaultCredentials -imatch 'true') {
        $private:SmtpClient.UseDefaultCredentials = $true
    # If enableSSL is set to "true" in the config file, enable SSL.
    if ($config.enableSSL -imatch 'true') {
        $private:SmtpClient.enableSSL = $true
    # Send the mail
    $private:SmtpClient.Send($config.from, $private:to, $private:subject, $private:message)
    if ( $? ) {
        "${date}: Sent $private:type warning mail to $private:to (Username: $private:username)" | Out-File -Append $mailSentLog
    else {
        "${date}: Error sending $private:type warning mail to $private:to (Username: $private:username): " + $error[0].ToString() | Out-File -Append $mailErrorLog
    # "Clear" the object/variable. Seems necessary.
    $private:SmtpClient = $null

# Simple function to get the number of days until the passwords expire.
function Get-Pwd-Days {
    param([datetime] $private:pwdExpires)
    ($private:pwdExpires - $(Get-Date)).Days

# Function to iterate the first and last user sets and call the mail sending function.
Function Iterate-Users {
    Param([Parameter(Mandatory=$true)][string] $private:type,
          [Parameter(Mandatory=$true)] $private:iterateUsers)
    foreach ($user in $private:iterateUsers) {
        if (-not $user.($config.emailAttribute)) {
            Write-Host "${date}: Error: No value for email address (LDAP: $($config.emailAttribute)) for '" $user.SamAccountName `
              "'. Name:" $user.GivenName $user.Sn
            "${date}: Error: No email address (LDAP: $($config.emailAttribute)) for '" + $user.SamAccountName + `
              "'. Name: " + $user.GivenName + ' ' + $user.Sn | Out-File -Append $mailErrorLog
        $private:mailGreeting = 'Dear ' + $user.GivenName +' '+ $user.Sn + ' (username: ' + $user.SamAccountName.ToLower() + ").`n`n"
        $private:to = ($user.($config.emailAttribute)).ToLower()
        if ($private:type -ieq 'first') {
            $private:message = "$private:mailGreeting`n" + ($firstWarnText -join "`n")
        else {
            $private:message = "$private:mailGreeting`n" + ($lastWarnText -join "`n")
        Send-Warn-Mail $private:type $private:to $private:message $user.SamAccountName.ToLower()

######### END OF FUNCTIONS ##########

# Get current year, month and day for later use.
$date = Get-Date -uformat %Y-%m-%d

# Parse the configuration file (very little sanity checking).
$config = Parse-Config $configFile

# Set this to true if a file isn't found, and exit after the foreach
[bool] $private:notFound = $false

foreach ($private:file in @($firstWarnFile, $lastWarnFile)) {
    if ( ! (Test-Path $private:file) ) {
        Write-Host ${date}": Fatal error: Did not find $private:file."
        "${date}: Fatal error: Did not find $private:file." | Out-File -Append $errorLog
        $private:notFound = $true

if ($private:notFound) {
    Write-Host ${date}": Fatal error: See $private:errorLog"
    exit 3

# We know that these exist from earlier verification.
$firstWarnText = Get-Content $firstWarnFile
$lastWarnText  = Get-Content $lastWarnFile

Add-PSSnapin Quest.ActiveRoles.AdManagement

$private:users = Get-QADUser -SizeLimit 0 | Select-Object SamAccountName, GivenName, Sn, $config.emailAttribute, PasswordExpires

# Get users whose passwords will expire in the given amounts of days
$private:firstWarnUsers = $private:users | Where-Object { $_.PasswordExpires -and ((Get-Pwd-Days $_.PasswordExpires) -eq $config.firstWarningDays) }
$private:lastWarnUsers  = $private:users | Where-Object { $_.PasswordExpires -and ((Get-Pwd-Days $_.PasswordExpires) -eq $config.lastWarningDays ) }

<# Debug

if ($private:firstWarnUsers) { Iterate-Users 'first' $private:firstWarnUsers }
if ($private:lastWarnUsers)  { Iterate-Users 'last' $private:lastWarnUsers   }

Personal tools