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.

## Contents

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 ad.example.com/someOU/someSubOU -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 need to edit the code, and can use either the parameter "-IncludeAllProperties" to the line shown below, to include all properties (will be slower and is not recommended against an entire AD (at all)) or "-IncludedProperties yourProp" to specifically include your (missing) property (recommended). Thanks to Steve Kutsenkow for pointing this out.

$private:users = Get-QADUser -SearchRoot ad.example.com/someOU/someSubOU -SizeLimit 0 -IncludeAllProperties | Select-Object SamAccountName, GivenName, Sn,$config.emailAttribute, PasswordExpires


## Requirements

This script uses the Get-QADUser cmdlet from the Quest ActiveRoles AD management cmdlets. The Quest cmdlets require .NET 3.5 SP1 or later, which for Windows 7 is not installed by default. It requires PowerShell version 2 or later (default in Windows 7 and Windows Server 2008 R2).

This article is now showing its age, and Get-ADUser from the Microsoft cmdlets are likely what people would use now. Also, the cmdlet Send-MailMessage would be handy, and is in PSv2. This could really be made pretty simple. The script does work fine as it is, though, and for people stuck with 2003 (R2) servers still, the Quest cmdlets are manna from the heavens above.

## 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=smtp.getmail.no
smtpPort=25
firstWarningDays=30
lastWarningDays=3
from=helpdesk@svendtech.no
useDefaultCredentials=false
enableSSL=true
XXXemailAttribute=CustomAttribute42

• 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 "CustomAttribute42" like this:

emailAttribute=CustomAttribute42


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!

## Example

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.

<#
.SYNOPSIS

Emails users a custom notification message the specified number of days before
.DESCRIPTION
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 }$config

}

# Function to actually send the mail
function Send-Warn-Mail {

param(
[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 return }$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 continue }$private:mailGreeting = 'Dear ' + $user.GivenName +' '+$user.Sn + ' (username: ' + $user.SamAccountName.ToLower() + ").nn"$private:to = ($user.($config.emailAttribute)).ToLower()

if ($private:type -ieq 'first') {$private:message = "$private:mailGreetingn" + ($firstWarnText -join "n")

}

else {

$private:message = "$private:mailGreetingn" + ($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

$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
'';'';'First:';'';''
$private:firstWarnUsers '';'';'Last:';'';''$private:lastWarnUsers
#>

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