- Password expiration email notification with PowerShell - Mon, Aug 26 2013
- WSUS basics and troubleshooting tips - Mon, Sep 24 2012
- FREE: SolarWinds Diagnostic Tool for the WSUS Agent - Fri, Jul 13 2012
For most sysadmins, resetting passwords is not a fun part of the job. In most organizations it requires much manual intervention: a frantic phone call or email from a user who cannot login, confirmation of the user’s identity, and a password reset. Not only are such requests time-consuming; frequent resets are also a security risk, as admins who deal with them frequently may slip up and forget to confirm the user’s identity before resetting his or her password.
Windows provides warnings a number of days before passwords expire, yet some users ignore them, others are logged on through the notification period, and still others (often remote users) fail to receive them entirely for a variety of reasons.
Thankfully, we do not have to rely on Windows login notifications to notify users that their passwords are soon to expire. We can actually leverage PowerShell to notify users via email! This makes the warnings harder to ignore and more likely to be delivered, especially when dealing with some remote user setups.
PowerShell password expiration notification
PowerPasswordNotify.ps1 is a PowerShell script I wrote to get you started on notifying users of password expiration. Here’s the gist of how it works:
- Find the maximum password age for your domain
- Search for all users in a container you specify
- Find all users who have a password that a) expires and b) will expire within a certain number of days that you specify
- Notify those users of their impending password expiration via email
######################################### # PowerPasswordNotify.ps1 # # # # Notifies users of password expiration # # # # 4sysops.com # # Author: Justin Shin # ######################################### ################# # CONFIGURATION # ################# # Variables $PPNConfig_DebugLevel = 0 $PPNConfig_NotificationTimeInDays = 10 $PPNConfig_SMTPServerAddress = "your.smtp.server.address.or.ip.com" $PPNConfig_FromAddress = "noreply@company.com" $PPNConfig_BodyIsHtml = $true $PPNConfig_DirectoryRoot = "LDAP://OU=Employees,DC=corp,DC=company,DC=com" $PPNConfig_MaxPageSize = 1000 # Functions function Configure-Notification-Subject($nName, $nNumDays) { return "$nName, your password will expire in $nNumDays days." } function Configure-Notification-Body-Plain($nName, $nNumDays) { return "Please be sure to change your password within $nNumDays." } function Configure-Notification-Body-Html($nName, $nNumDays) { $bodyHtml = "<h2>Your password is expiring in $nNumDays days.</h2><p>Please change your password before that time.</p>" return $bodyHtml } ########################################################################## # Edit below as needed. For most environments, this should be sufficient.# ########################################################################## ############# # FUNCTIONS # ############# function Get-Domain-MaxPassword-Age { $ThisDomain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain() $DirectoryRoot = $ThisDomain.GetDirectoryEntry() $DirectorySearcher = [System.DirectoryServices.DirectorySearcher]$DirectoryRoot $DirectorySearcher.Filter = "(objectClass=domainDNS)" $DirectorySearchResult = $DirectorySearcher.FindOne() $MaxPasswordAge = New-Object System.TimeSpan([System.Math]::ABS($DirectorySearchResult.properties["maxpwdage"][0])) return $MaxPasswordAge } function Get-Users-With-Expiring-Passwords { $UsersToNotify = @() $DirectoryRoot = New-Object System.DirectoryServices.DirectoryEntry($PPNConfig_DirectoryRoot) $DirectorySearcher = New-Object System.DirectoryServices.DirectorySearcher($DirectoryRoot) $DirectorySearcher.filter = "(&(objectCategory=Person)(objectClass=User)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!(userAccountControl:1.2.840.113556.1.4.803:=65536)))" $DirectorySearcher.pagesize = $PPNConfig_MaxPageSize $MaxPasswordAge = Get-Domain-MaxPassword-Age $MaxPasswordAgeDays = $MaxPasswordAge.Days $DirectorySearchResult = $DirectorySearcher.FindAll() | ForEach-Object -ErrorAction "SilentlyContinue" ` -Process ` { $PwdChanged = ([adsi]$_.path).psbase.InvokeGet("PasswordLastChanged") $DaysTillExpiring = $MaxPasswordAgeDays - ((Get-Date) - $PwdChanged).Days if ($DaysTillExpiring -le $PPNConfig_NotificationTimeInDays) { $UserToAdd = New-Object psobject $UserToAdd | Add-Member NoteProperty -Name "Name" -Value ([adsi]$_.path).name[0] $UserToAdd | Add-Member NoteProperty -Name "Email" -Value ([adsi]$_.path).mail[0] $UserToAdd | Add-Member NoteProperty -Name "DaysLeft" -Value $DaysTillExpiring $UsersToNotify += $UserToAdd } } return $UsersToNotify } function Send-Email-Notification-Of-Expiry($nName, $nEmail, $nDaysLeft) { $SmtpClient = New-Object System.Net.Mail.SmtpClient($PPNConfig_SMTPServerAddress) $NewMail = New-Object System.Net.Mail.MailMessage $NewMail.From = $PPNConfig_FromAddress $NewMail.To.Add($nEmail) $NewMail.Subject = Configure-Notification-Subject $nName $nDaysLeft if ($PPNConfig_BodyIsHtml) { $NewMail.IsBodyHtml = $true $NewMail.Body = Configure-Notification-Body-Html $nName $nDaysLeft } else { $NewMail.IsBodyHtml = $false $NewMail.Body = Configure-Notification-Body-Plain $nName $nDaysLeft } $SmtpClient.Send($NewMail) } ######## # MAIN # ######## $UsersToNotify = Get-Users-With-Expiring-Passwords foreach ($User in $UsersToNotify) { if ($PPNConfig_DebugLevel -gt 0) { Write-Host $User } else { Send-Email-Notification-Of-Expiry $User.Name $User.Email $User.DaysLeft } }
At a minimum, you will need to download the script, configure it, and create a scheduled task to run on a reasonable schedule (for instance, every day). You may want to modify or extend the script to suit your needs, though, and I have provided a bit of background on the more opaque aspects of the script at the end of the article.
Configuring the PowerShell script
At the top of the script are the configuration variables and functions that you must modify to make the script work, along with some optional parameters. I have denoted the “required” parameters in red.
- PPNConfig_DebugLevel: 0 is normal operation (sends email notifications); 1 prints the users who would be notified without actually sending the notification.
- PPNConfig_NotificationTimeInDays: The number of days before expiration (inclusive) that you wish to notify users
- PPNConfig_SMTPServerAddress: Your SMTP Server Address
- PPNConfig_FromAddress: Who the email is from
- PPNConfig_BodyIsHTML: Whether or not to send the email body as HTML
- PPNConfig_DirectoryRoot: The LDAP container that you wish to search in; this can be your entire domain (“LDAP://dc=corp,dc=company,dc=com”) or more specific (“LDAP://ou=finance,ou=management,dc=corp,dc=company,dc=com”)
- PPNConfig_MaxPageSize: The maximum number of results you wish to be returned from the directory search
The functions determine the subject and body of the message. Two body functions are defined: one for plain text and the other for HTML email. I have provided some very bare bones implementations.
- Configure-Notification-Subject($nName, $nNumDays)
- Configure-Notification-Body-Plain($nName,$nNumDays)
- Configure-Notification-Body-Html($nName,$nNumDays)
The default notifications are not terribly creative, so adjust to taste!
Schedule the PowerShell script
After you have configured the script and tested it manually in PowerShell, you should create a scheduled task in the Task Scheduler (or Scheduled Tasks for Windows Server 2003/R2) to execute the script on a reasonable interval. Because we are searching through the directory for user’s login details you will need to make sure that the user running it is sufficiently privileged –a domain administrator, for example. Make sure that this interval is shorter than the PPNConfig_NotificationTimeInDays setting, or otherwise some users may not get notifications before their passwords expire!
A scheduled task that executes PowerPasswordNotify.ps1
Many admins like to invoke PowerShell scheduled tasks using a batch file. The scheduler runs the batch file; the batch file starts the script. For instance, suppose you have placed PowerPasswordNotify.ps1 in the folder C:\Tasks\PPN. You might want to create a batch file (in your favorite text editor) named “StartPPN.ps1” in that folder with the text:
"C:\Windows\syswow64\Windowspowershell\v1.0\powershell.exe" -executionpolicy Unrestricted -file "C:\Tasks\PPN\PowerPasswordNotify.ps1"
Of course, depending on your Powershell version and Execution Policy, your mileage may vary.
Some explanations
There are a few places in the script that are not obvious as to how and why they work. I’ve identified the biggest potential sources of confusion – and trouble –to help you debug and extend the functionality.
Get-Domain-MaxPassword-Age
This function returns the maximum password age of the executing computer’s domain. This value is provided as a TimeSpan. You may want to extend it to return the policy of a different domain, which you can do by modifying the directory root of the searcher:
$ThisDomain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain() $DirectoryRoot = $ThisDomain.GetDirectoryEntry() #or another domain ... $MaxPasswordAge = New-Object System.TimeSpan([System.Math]::ABS($DirectorySearchResult.properties["maxpwdage"][0]))
Get-Users-With-Expiring-Passwords
In order for us to search the container for enabled users with expiring passwords, we must be narrow down our filter string to include only:
- Users: (objectCategory=Person)(objectClass=User)
- who are enabled: (!userAccountControl:1.2.840.113556.1.4.803:=2)
- with passwords that expire: (!(userAccountControl:1.2.840.113556.1.4.803:=65536)
If you were to delete (3) from the filter string, for example, you would end up with extraneous results – users whose passwords never expire would be notified that their passwords were about to expire.
Finally, you may want to bring over additional information in your implementation of the email notification. This is very easy to do: just add a NoteProperty for the new field that you wish to capture:
if ($DaysTillExpiring -le $PPNConfig_NotificationTimeInDays) { $UserToAdd = New-Object psobject ... $UserToAdd | Add-Member NoteProperty -Name "Name" -Value ([adsi]$_.path).NEW_FIELD_TO_CAPTURE[0] $UsersToNotify += $UserToAdd }
I’m sorry but as a PowerShell enthusiast, I must admit that the wheel has just been reinvented… This VB-script style is just… not real PowerShell.
Why don’t you use MS ActiveDirectory module?
System.Net.Mail.MailMessage? There’s a Send-MailMessage cmdlet + $PSEmailServer variable since PowerShell version 2.0.
etc.
@Serge, good points. It’s a little obtuse. I was trying to present the script and all of the moving parts in a straightforward manner. I don’t think it would be difficult to substitute out certain calls (ie sending mail or scouring AD) with slicker ones, though. Plus, people who are completely unfamiliar with PS2 may not have their profiles setup and will need to do additional research into setting $PSEmailServer, for example.
Still, you make a good point. Even better would be if you could offer an amended script that is more “true” to the intent and style of PS.
Hi Justin, great script! Do you have any idea why I get the following error when I execute the script with the DebugLevel = 1? I do get the list of users but first I get the error 3 times.
Thank you,
TEO
Exception calling “InvokeGet” with “1” argument(s): “Exception from HRESULT: 0x8000500D”
At C:\Users\olteanut\Desktop\PowerPasswordNotify.ps1:63 char:3
+ $PwdChanged = ([adsi]$_.path).psbase.InvokeGet(“PasswordLastChanged”)
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : DotNetMethodTargetInvocation
Here is a simplification of the Get-Users-With-Expiring-Passwords function. You would need to fill in your own $UsersOU string
Hope this helps.
function Get-Users-With-Expiring-Passwords
{
$Date = Get-Date
$Users = Get-AdUser -LDAPFilter “(objectClass=User)” -Properties * -SearchBase $UsersOU | ? PasswordLastSet -ne $null
$UsersToNotify = $Users | ? {($Date – $_.passwordlastset).days -le $PPNConfig_NotificationTimeInDays } | % {
[PSCustomObject] @{
Name = $_.Name
Email = $_.mail
DaysLeft = ($Date – $_.passwordlastset).days
}
}
Return $UsersToNotify
}
Also, a simplification of the password age function…
(Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge
Whoops. Found a glaring error in my first post.
function Get-Users-With-Expiring-Passwords
{
$Date = Get-Date
$MaxPasswordAgeDays = (Get-Domain-MaxPassword-Age).Days
$Users = Get-AdUser -LDAPFilter “(objectClass=User)” -Properties * -SearchBase $UsersOU | ? { ($_.PasswordLastSet -ne $null) -and ($_.PasswordNeverExpires -ne $true) }
$UsersToNotify = $Users | % {
$PasswordAgeDays = ($Date – $_.PasswordLastSet).days
$DaysTillExpiring = $MaxPasswordAgeDays – $PasswordAgeDays
if (($PPNConfig_NotificationTimeInDays -ge $DaysTillExpiring) -and ($DaysTillExpiring -ge 0))
{
[PSCustomObject] @{
Name = $_.Name
Email = $_.mail
DaysLeft = $DaysTillExpiring
}
}
}
return $UsersToNotify
}
$Global:MaxPasswordAgeDays = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge.Days
Function Get-Users-With-Expiring-Passwords {
$MaxPasswordAgeDays = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge.Days
$Users = $Global:Users | ? { ($_.PasswordLastSet -ne $null) -and ($_.PasswordNeverExpires -ne $true) }
Return $Users | ? { (0..$Global:NotificationTimeInDays).Contains((Get-DaysTillExpiring $_)) }
}
Function Get-DaysTillExpiring ($User) { Return ($User | % { ($Global:MaxPasswordAgeDays) – (Get-Date – $_.PasswordLastSet).days }) }
Hey Justin, great script, thank you for sharing.
@Teodor
I had the same error like you describe. In my case, the error occurs with users who dont have password set yet (new users, with must Change Password at next logon).
You need to update the filter with “(!pwdLastSet=0)”.
The full script line becames: $DirectorySearcher.filter = “(&(objectCategory=Person)(objectClass=User)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!(userAccountControl:1.2.840.113556.1.4.803:=65536))(!pwdLastSet=0))”
Unlinke Serge, here’s a useful update.
If some of you use FineGrainedPasswords, here’s a way to get the MaxPasswordAge of the resultant password policy of the user.
Edit Code from line 75:
$PwdChanged = ([adsi]$_.path).psbase.InvokeGet(“PasswordLastChanged”)
#Check for Resultant Password Policy of the user
$ADURPP = Get-ADUserResultantPasswordPolicy ([adsi]$_.path).SamAccountName.ToString()
If ($ADURPP -ne $Null)
{
#Write-Host “UsersPWMaxAge= ” $ADURPP.MaxPasswordAge.Days
$UsersMaxPasswordAgeDays = $ADURPP.MaxPasswordAge.Days
$DaysTillExpiring = $UsersMaxPasswordAgeDays – ((Get-Date) – $PwdChanged).Days
}
else
{
$DaysTillExpiring = $MaxPasswordAgeDays – ((Get-Date) – $PwdChanged).Days
}
Thank you MiniAdmin!
Do you guys have any advice related to how to create a log of the people the e-mails are being sent?
Hey everyone! first of all, thank you very much for your script. i cant get it to run, thats why i hope someone can help me out here.
Its a german server and i get the following error if i run the script:
PS C:\inst\powershell> & ‘.\Neues Textdokument.ps1’
Ausnahme beim Aufrufen von “Add” mit 1 Argument(en): “Der Wert darf nicht NULL
sein.
Parametername: item”
Bei C:\inst\powershell\Neues Textdokument.ps1:109 Zeichen:20
+ $NewMail.To.Add <<<< ($nEmail)
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : DotNetMethodException
Ausnahme beim Aufrufen von "Send" mit 1 Argument(en): "Es muss ein Empfänger a
ngegeben werden."
Bei C:\inst\powershell\Neues Textdokument.ps1:123 Zeichen:21
+ $SmtpClient.Send <<<
As i am a total newbie in scripting i have not really an idea what it could be. but it seems to me that the script is not able to read out the mail address? Can someone tell me how to modify a user properly so i can test the script?
Thanks for any help in advance!
Regards Jonas
Sorry the errors weren’t copied properly.
Here again:
PS C:\inst\powershell> & ‘.\Neues Textdokument.ps1’
Ausnahme beim Aufrufen von “Add” mit 1 Argument(en): “Der Wert darf nicht NULL
sein.
Parametername: item”
Bei C:\inst\powershell\Neues Textdokument.ps1:109 Zeichen:20
+ $NewMail.To.Add <<<< ($nEmail)
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : DotNetMethodException
Ausnahme beim Aufrufen von "Send" mit 1 Argument(en): "Es muss ein Empfänger a
ngegeben werden."
Bei C:\inst\powershell\Neues Textdokument.ps1:123 Zeichen:21
+ $SmtpClient.Send <<<< ($NewMail)
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : DotNetMethodException
Thanks!
Hi guys, this is grate script but how do I Insert embedded Picture to the email with logo of company?
Thanks for posting this, it works well. I think this will save me a lot of headaches!
One thing I ran into was a problem using multi-line html for the $bodyHtml variable. Once I enclosed it all in @” “@ it worked fine.
How do I make the emails high Priority?
last week the script runs very wel. today when i run it i got an error:
”
function Get-Domain-MaxPassword-Age
{
$ThisDomain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
$DirectoryRoot = $ThisDomain.GetDirectoryEntry()
$DirectorySearcher = [System.DirectoryServices.DirectorySearcher]$DirectoryRoot
$DirectorySearcher.Filter = “(objectClass=domainDNS)”
$DirectorySearchResult = $DirectorySearcher.FindOne()
$MaxPasswordAge = New-Object System.TimeSpan([System.Math]::ABS($DirectorySearchResult.properties[“maxpwdage”][0]))
return $MaxPasswordAge
}
function Get-Users-With-Expiring-Passwords
{
$UsersToNotify = @()
$DirectoryRoot = New-Object System.DirectoryServices.DirectoryEntry($PPNConfig_DirectoryRoot)
$DirectorySearcher = New-Object System.DirectoryServices.DirectorySearcher($DirectoryRoot)
$DirectorySearcher.filter = “(&(objectCategory=Person)(objectClass=User)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!(userAccountControl:1.2.840.113556.1.4.803:=65536)))”
$DirectorySearcher.pagesize = $PPNConfig_MaxPageSize
$MaxPasswordAge = Get-Domain-MaxPassword-Age
$MaxPasswordAgeDays = $MaxPasswordAge.Days
$DirectorySearchResult = $DirectorySearcher.FindAll() |
ForEach-Object -ErrorAction “SilentlyContinue” `
-Process `
{
$PwdChanged = ([adsi]$_.path).psbase.InvokeGet(“PasswordLastChanged”)
$DaysTillExpiring = $MaxPasswordAgeDays – ((Get-Date) – $PwdChanged).Days
if ($DaysTillExpiring -le $PPNConfig_NotificationTimeInDays)
{
$UserToAdd = New-Object psobject
$UserToAdd | Add-Member NoteProperty -Name “Name” -Value ([adsi]$_.path).name[0]
$UserToAdd | Add-Member NoteProperty -Name “Email” -Value ([adsi]$_.path).mail[0]
$UserToAdd | Add-Member NoteProperty -Name “DaysLeft” -Value $DaysTillExpiring
$UsersToNotify += $UserToAdd
}
}
return $UsersToNotify
}
function Send-Email-Notification-Of-Expiry($nName, $nEmail, $nDaysLeft)
{
$SmtpClient = New-Object System.Net.Mail.SmtpClient($PPNConfig_SMTPServerAddress)
$NewMail = New-Object System.Net.Mail.MailMessage
$NewMail.From = $PPNConfig_FromAddress
$NewMail.To.Add($nEmail)
$NewMail.Subject = Configure-Notification-Subject $nName $nDaysLeft
if ($PPNConfig_BodyIsHtml)
{
$NewMail.IsBodyHtml = $true
$NewMail.Body = Configure-Notification-Body-Html $nName $nDaysLeft
}
else
{
$NewMail.IsBodyHtml = $false
$NewMail.Body = Configure-Notification-Body-Plain $nName $nDaysLeft
}
$SmtpClient.Send($NewMail)
}
########
# MAIN #
########
$UsersToNotify = Get-Users-With-Expiring-Passwords
foreach ($User in $UsersToNotify)
{
if ($PPNConfig_DebugLevel -gt 0)
{
Write-Host $User
}
else
{
Send-Email-Notification-Of-Expiry $User.Name $User.Email $User.DaysLeft
}
}
”
the code on the line:
”
$PwdChanged = ([adsi]$_.path).psbase.InvokeGet(“PasswordLastChanged”)
”
any solution?
Has anyone found that when using this variable:
$PPNConfig_DirectoryRoot = “ldap://ou=test,dc=example,dc=com”
it will only use the root? I’m not sure why?
Hi Mark,
I believe one extra line need to be introduced in function Get-Users-With-Expiring-Passwords
$DirectorySearcher.SearchScope = “Subtree”
will be looking like that:
$DirectoryRoot = New-Object System.DirectoryServices.DirectoryEntry($PPNConfig_DirectoryRoot)
$DirectorySearcher = New-Object System.DirectoryServices.DirectorySearcher($DirectoryRoot)
$DirectorySearcher.filter = “(&(objectCategory=Person)(objectClass=User)(!userAccountControl:1.2.840.113556.1.4.803:=2)(!(userAccountControl:1.2.840.113556.1.4.803:=65536))(!pwdLastSet=0))”
$DirectorySearcher.pagesize = $PPNConfig_MaxPageSize
$DirectorySearcher.SearchScope = “Subtree”
From trialing and erro default scope doesn’t look entire tree just OU leven by default, changing scope allows search entire subtree
BTW many thanks to everyone – great effort to get script going in its current form.
Regards
Gorafan
How would i edit the script to send it only to an admin, i am looking to use this for service accounts that we use.
79 line getting error , Any one suggest it
Exception calling “FindAll” with “0” argument(s): “There is no such object on the server.
”
At C:\Users\……\Documents\password expired.ps1:79 char:5
+ $DirectorySearchResult = $DirectorySearcher.FindAll() |
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : DirectoryServicesCOMException
how can you add two search base so that i can look two OU as we have two OU for users.
Getting an error, the recipient needs to be entered.
Hi Justin, very nice your script and thanks for sharing. I would like to add an image at the end of the email, such as a company logo. How could I do this? Thank you again.