Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 53 You must be signed in to star a gist
  • Fork 17 You must be signed in to fork a gist
  • Save meoso/3488ef8e9c77d2beccfd921f991faa64 to your computer and use it in GitHub Desktop.
Save meoso/3488ef8e9c77d2beccfd921f991faa64 to your computer and use it in GitHub Desktop.
PowerShell Active Directory Password Expiration Email Notification

Password-Expiration-Notifications.ps1 is a powerShell script designed to be run on a schedule to automatically email Active Directory users of soon-to-expire and recently-expired passwords.

This version is a highly modified fork of the original v1.4 by Robert Pearman from https://gallery.technet.microsoft.com/Password-Expiry-Email-177c3e27. Pearman's 2.x version was completely re-written.

New in this version:

  • A SearchBase is required.
  • OU ExcludeList [Commented out in code]
  • When logging, the CSV will always be overwritten. (unless you specify a variable file name such as "c:\PS-pwd-expiry-$(Get-Date -format yyyyMMdd-HHmmss).csv")
  • Accounts with recently-expired passwords can be notified by specifying a "negativedays" value.
  • Email attempts will handle basic errors, but nothing more. This script does not account for SMTP credentials. [Please review July 2022 comments for SMTP auth.]
  • Accounts with MaxPasswordAge 00:00:00 (never) are skipped. (Same as PasswordNeverExpires.)
  • Testing-mode will allow a specified number of sample notifications to be emailed to the Administrator(s). (Rather than defaulting to all users' expiration emails.)
  • Processing information and basic statistics are written to console.
  • When logging, the CSV file and basic statistics will be emailed to the specified Administrator(s).

Self-Help Resources:

Prefer scheduling with a Service account

#################################################################################################################
#
# Password-Expiration-Notifications v20220823
# Highly Modified fork. https://gist.github.com/meoso/3488ef8e9c77d2beccfd921f991faa64
#
# Originally from v1.4 @ https://gallery.technet.microsoft.com/Password-Expiry-Email-177c3e27
# https://windowspoweressentials.com/2017/02/21/powershell-password-reminder-script-updated/
# https://github.com/titlerequired/public
# Robert Pearman (WSSMB MVP)
# TitleRequired.com
# Script to Automated Email Reminders when Users Passwords due to Expire.
#
# Requires: Windows PowerShell Module for Active Directory
#
##################################################################################################################
# Please Configure the following variables....
$testing = $true # Set to $false to Email Users. $true to email samples to administrators only (see $sampleEmails below)
$SearchBase="DC=EXAMPLE,DC=COM"
### PURGING this option; seems to cause issue. # $ExcludeList="'New Employees'|'Separated Employees'" #in the form of "SubOU1|SubOU2|SubOU3" -- possibly needing single quote for OU's with spaces, separate OU's with pipe and double-quote the list.
$smtpServer="smtp.example.com"
$expireindays = 7 #number of days of soon-to-expire paswords. i.e. notify for expiring in X days (and every day until $negativedays)
$negativedays = -3 #negative number of days (days already-expired). i.e. notify for expired X days ago
$from = "Administrator <administrator@example.com>"
$logging = $true # Set to $false to Disable Logging
$logNonExpiring = $false
$logFile = "c:\PS-pwd-expiry.csv" # ie. c:\mylog.csv
$adminEmailAddr = "Admin1@example.com","Admin2@example.com","Admin3@example.com" #multiple addr allowed but MUST be independent strings separated by comma
$sampleEmails = 3 #number of sample email to send to adminEmailAddr when testing ; in the form $sampleEmails="ALL" or $sampleEmails=[0..X] e.g. $sampleEmails=0 or $sampleEmails=3 or $sampleEmails="all" are all valid.
# please edit $body variable within the code
###################################################################################################################
# System Settings
$textEncoding = [System.Text.Encoding]::UTF8
$date = Get-Date -format yyyy-MM-dd #for logfile only
$starttime=Get-Date #need time also; don't use date from above
Write-Host "Processing `"$SearchBase`" for Password-Expiration-Notifications"
Write-Host "Testing Mode: $testing"
# Get Users From AD who are Enabled, Passwords Expire
Import-Module ActiveDirectory
Write-Host "Gathering User List"
$users = get-aduser -SearchBase $SearchBase -Filter {(enabled -eq $true) -and (passwordNeverExpires -eq $false)} -properties sAMAccountName, displayName, PasswordNeverExpires, PasswordExpired, PasswordLastSet, EmailAddress, lastLogon, whenCreated
Write-Host "Filtering User List"
### PURGING this option; seems to cause issue. # $users = $users | Where-Object {$_.DistinguishedName -notlike $ExcludeList} ##also try -notmatch, needs heavy testing
$DefaultmaxPasswordAge = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge
$countprocessed=${users}.Count
$samplesSent=0
$countsent=0
$countnotsent=0
$countfailed=0
$nonexpiring=0
Write-Host "${countprocessed} user-accounts selected to iterate."
#set max sampleEmails to send to $adminEmailAddr
if ( $sampleEmails -isNot [int]) {
if ( $sampleEmails.ToLower() -eq "all") {
$sampleEmails=$users.Count
} #else use the value given
}
if (($testing -eq $true) -and ($sampleEmails -ge 0)) {
Write-Host "Testing only; $sampleEmails email samples will be sent to $adminEmailAddr"
} elseif (($testing -eq $true) -and ($sampleEmails -eq 0)) {
Write-Host "Testing only; emails will NOT be sent"
}
# Create CSV Log
if ($logging -eq $true) {
#Always purge old CSV file
Out-File $logfile
Add-Content $logfile "`"Date`",`"SAMAccountName`",`"DisplayName`",`"Created`",`"PasswordSet`",`"DaystoExpire`",`"ExpiresOn`",`"EmailAddress`",`"Notified`""
}
# Process Each User for Password Expiry
foreach ($user in $users) {
$dName = $user.displayName
$sName = $user.sAMAccountName
$emailaddress = $user.emailaddress
$whencreated = $user.whencreated
$passwordSetDate = $user.PasswordLastSet
$sent = "" # Reset Sent Flag
$PasswordPol = (Get-AduserResultantPasswordPolicy $user)
# Check for Fine Grained Password
if (($PasswordPol) -ne $null) {
$maxPasswordAge = ($PasswordPol).MaxPasswordAge
} else {
# No FGPP set to Domain Default
$maxPasswordAge = $DefaultmaxPasswordAge
}
#If maxPasswordAge=0 then same as passwordNeverExpires, but PasswordCannotExpire bit is not set
if ($maxPasswordAge -eq 0) {
Write-Host "$sName : MaxPasswordAge = $maxPasswordAge (i.e. PasswordNeverExpires) but bit not set -- User not selected to receive email."
}
$expiresOn = $passwordsetdate + $maxPasswordAge
$today = (get-date)
if ( ($user.passwordexpired -eq $false) -and ($maxPasswordAge -ne 0) ) { #not Expired and not PasswordNeverExpires
$daystoexpire = (New-TimeSpan -Start $today -End $expiresOn).Days
} elseif ( ($user.passwordexpired -eq $true) -and ($passwordSetDate -ne $null) -and ($maxPasswordAge -ne 0) ) { #if expired and passwordSetDate exists and not PasswordNeverExpires
# i.e. already expired
$daystoexpire = -((New-TimeSpan -Start $expiresOn -End $today).Days)
} else {
# i.e. (passwordSetDate = never) OR (maxPasswordAge = 0)
$daystoexpire="NA"
$nonexpiring += 1
#continue #"continue" would skip user, but bypass any non-expiry logging
}
#Write-Host "$sName : DaysToExpire: $daystoexpire MaxPasswordAge: $maxPasswordAge" #debug
# Set verbiage based on Number of Days to Expiry.
Switch ($daystoexpire) {
{$_ -ge $negativedays -and $_ -le "-1"} {$messageDays = "has expired"}
"0" {$messageDays = "will expire today"}
"1" {$messageDays = "will expire in 1 day"}
default {$messageDays = "will expire in " + "$daystoexpire" + " days"}
}
# Email Subject Set Here
$subject="Your password $messageDays"
# Email Body Set Here, Note You can use HTML, including Images.
$body="
<p>Your Active Directory password for your <b>$sName</b> account $messageDays. After expired, you will not be able to login until your password is changed.</p>
<p>Please visit selfservice.example.com to change your password. Alternatively, on a Windows machine, you may press Ctrl-Alt-Del and select `"Change Password`".</p>
<p>If you do not know your current password, <a href='https://selfservice.example.com/?action=sendtoken'>click here to email a password reset link</a>.</p>
Example.com Administrator<br>
Administrator@example.com<br>
www.example.com/support/<br>
</p>
"
# If testing-enabled and send-samples, then set recipient to adminEmailAddr else user's EmailAddress
if (($testing -eq $true) -and ($samplesSent -le $sampleEmails)) {
$recipient = $adminEmailAddr
} else {
$recipient = $emailaddress
}
#if in trigger range, send email
if ( ($daystoexpire -ge $negativedays) -and ($daystoexpire -le $expireindays) -and ($daystoexpire -ne "NA") ) {
Write-Host "$sName : Selected to receive email: password ${messageDays}"
# Send Email Message
if (($emailaddress) -ne $null) {
if ( ($testing -eq $false) -or (($testing -eq $true) -and ($samplesSent -lt $sampleEmails)) ) {
try {
Send-Mailmessage -smtpServer $smtpServer -from $from -to $recipient -subject $subject -body $body -bodyasHTML -priority High -Encoding $textEncoding -ErrorAction Stop -ErrorVariable err
} catch {
write-host "Error: Could not send email to $recipient via $smtpServer"
$sent = "Send fail"
$countfailed++
} finally {
if ($err.Count -eq 0) {
write-host "Sent email for $sName to $recipient"
$countsent++
if ($testing -eq $true) {
$samplesSent++
$sent = "toAdmin"
} else { $sent = "Yes" }
}
}
} else {
Write-Host "Testing mode: skipping email to $recipient"
$sent = "No"
$countnotsent++
}
} else {
Write-Host "$dName ($sName) has no email address."
$sent = "No addr"
$countnotsent++
}
# If Logging is Enabled Log Details
if ($logging -eq $true) {
Add-Content $logfile "`"$date`",`"$sName`",`"$dName`",`"$whencreated`",`"$passwordSetDate`",`"$daystoExpire`",`"$expireson`",`"$emailaddress`",`"$sent`""
}
} else {
#if ( ($daystoexpire -eq "NA") -and ($maxPasswordAge -eq 0) ) { Write-Host "$sName PasswordNeverExpires" } elseif ($daystoexpire -eq "NA") { Write-Host "$sName PasswordNeverSet" } #debug
# Log Non Expiring Password
if ( ($logging -eq $true) -and ($logNonExpiring -eq $true) ) {
if ($maxPasswordAge -eq 0 ) {
$sent = "NeverExp"
} else {
$sent = "No"
}
Add-Content $logfile "`"$date`",`"$sName`",`"$dName`",`"$whencreated`",`"$passwordSetDate`",`"$daystoExpire`",`"$expireson`",`"$emailaddress`",`"$sent`""
}
}
} # End User Processing
$endtime=Get-Date
$totaltime=($endtime-$starttime).TotalSeconds
$minutes="{0:N0}" -f ($totaltime/60)
$seconds="{0:N0}" -f ($totaltime%60)
Write-Host "$countprocessed Users from `"$SearchBase`" Processed in $minutes minutes $seconds seconds."
Write-Host "Email trigger range from $negativedays (past) to $expireindays (upcoming) days of user's password expiry date."
Write-Host "$nonexpiring Non-Expiring accounts."
Write-Host "$countsent Emails Sent."
Write-Host "$countnotsent Emails skipped."
Write-Host "$countfailed Emails failed."
# sort the CSV file
if ($logging -eq $true) {
Rename-Item $logfile "$logfile.old"
import-csv "$logfile.old" | sort ExpiresOn | export-csv $logfile -NoTypeInformation
Remove-Item "$logFile.old"
Write-Host "CSV File created at ${logfile}."
if ($testing -eq $true) {
$body="<b><i>Testing Mode.</i></b><br>"
} else {
$body=""
}
$body+="
CSV Attached for $date<br>
$countprocessed Users from `"$SearchBase`" Processed in $minutes minutes $seconds seconds.<br>
Email trigger range from $negativedays (past) to $expireindays (upcoming) days of user's password expiry date.<br>
$nonexpiring Non-Expiring accounts.<br>
$countsent Emails Sent.<br>
$countnotsent Emails skipped.<br>
$countfailed Emails failed.
"
try {
Send-Mailmessage -smtpServer $smtpServer -from $from -to $adminEmailAddr -subject "Password Expiry Logs" -body $body -bodyasHTML -Attachments "$logFile" -priority High -Encoding $textEncoding -ErrorAction Stop -ErrorVariable err
} catch {
write-host "Error: Failed to email CSV log to $adminEmailAddr via $smtpServer"
} finally {
if ($err.Count -eq 0) {
write-host "CSV emailed to $adminEmailAddr"
}
}
}
# End
@jeremytbeau
Copy link

I have had this setup for quite some time and receive the admin emails with .csv, although my users are not receiving the emails. I have verified that the email properties in AD are correct. Does this line in the CSV give any tips? (9:45:56 AM","Skipped - Interval")

@meoso
Copy link
Author

meoso commented Sep 9, 2022

Does this line in the CSV give any tips? (9:45:56 AM","Skipped - Interval")

please tell me "version" from the .ps1 file ... i dont have AM/PM specified anywhere as well as don't remember Skipped - Interval text.

@meoso
Copy link
Author

meoso commented Sep 9, 2022

@jeremytbeau , i found the text Skipped - Interval, it exists in Robert Pearman's original script. You'll have to get his support for his script. However the link-back to the Microsoft Original seems "404". i found the script elsewhere (linked below) in which there is an if/then/else which outputs the text in question.

https://windowspoweressentials.com/2017/02/21/powershell-password-reminder-script-updated/ --> https://github.com/titlerequired/public --> PasswordChangeNotification.ps1

look for if(($interval) -Contains($daysToExpire)).

@jeremytbeau
Copy link

jeremytbeau commented Sep 12, 2022 via email

@jeremytbeau
Copy link

@jeremytbeau , i found the text Skipped - Interval, it exists in Robert Pearman's original script. You'll have to get his support for his script. However the link-back to the Microsoft Original seems "404". i found the script elsewhere (linked below) in which there is an if/then/else which outputs the text in question.

https://windowspoweressentials.com/2017/02/21/powershell-password-reminder-script-updated/ --> https://github.com/titlerequired/public --> PasswordChangeNotification.ps1

look for if(($interval) -Contains($daysToExpire)).

Ahh, sorry about the confusion. Perhaps I'll implement yours since it's newer.

@AndreOrlando
Copy link

Hello all, on my AD i have two users OU, how can i set these two searchbases? i tried using $SearchBase="OU=First,DC=EXAMPLE,DC=COM", "OU=Second,DC=EXAMPLE,DC=COM", but i got the error "Get-ADUser : Cannot convert 'System.Object[]' to the type 'System.String' required by parameter 'SearchBase'. Specified method is not supported."

The paramater Searchbase only accepts one value.

@meoso
Copy link
Author

meoso commented Oct 31, 2022

Hello all, on my AD i have two users OU, how can i set these two searchbases? i tried using $SearchBase="OU=First,DC=EXAMPLE,DC=COM", "OU=Second,DC=EXAMPLE,DC=COM", but i got the error "Get-ADUser : Cannot convert 'System.Object[]' to the type 'System.String' required by parameter 'SearchBase'. Specified method is not supported."

The paramater Searchbase only accepts one value.

$SearchBase1="OU=First,DC=EXAMPLE,DC=COM"
$SearchBase2="OU=Second,DC=EXAMPLE,DC=COM"
$users1 = get-aduser -SearchBase $SearchBase1 ...
$users2 = get-aduser -SearchBase $SearchBase2 ...
$users = $users1 + $users2

@Tolcheen
Copy link

This script works great. Thank you.
Does anyone know the minimum rights needed to run this script as a scheduled task? Could you do it with a managed service account?

@meoso
Copy link
Author

meoso commented Nov 16, 2022

This script works great. Thank you. Does anyone know the minimum rights needed to run this script as a scheduled task? Could you do it with a managed service account?

the account is up to you. read-only account should work if desired. most user-accounts have AD read access.

examples (set program/arguments):

C:\WINDOWS\system32\WindowsPowerShell\v1.0\powershell.exe
-ExecutionPolicy Bypass -WindowStyle Hidden -File "\path\to\script.ps1"

C:\WINDOWS\system32\WindowsPowerShell\v1.0\powershell.exe
-ExecutionPolicy RemoteSigned -WindowStyle Hidden -File "\path\to\script.ps1"

C:\WINDOWS\system32\WindowsPowerShell\v1.0\powershell.exe
-ExecutionPolicy RemoteSigned -NoProfile -NonInteractive -File "\path\to\script.ps1"

image
image

@jeremytbeau
Copy link

jeremytbeau commented Nov 22, 2022 via email

@zuku81
Copy link

zuku81 commented Jan 18, 2023

How could I use this script for standalone workgroup windows server?

@meoso
Copy link
Author

meoso commented Jan 18, 2023

How could I use this script for standalone workgroup windows server?

I don't honestly know the commends to get user-list from non-Active-Directory groups. If you can figure that out, then you may have a direction to follow. You'd have to remove the Password-Policy related code in it as well. any of the commands Get-AD**** is AD related.

@DavidKelly97
Copy link

Has anyone been successful making this work with Outlook? I'm currently having issues with authentication. I'm struggling to get around the following error message:
Send-Mailmessage : Error in processing. The server response was: 5.7.3 STARTTLS is required to send mail

Any ideas would be amazing, thank you.

@meoso
Copy link
Author

meoso commented Feb 16, 2023

Has anyone been successful making this work with Outlook? I'm currently having issues with authentication. I'm struggling to get around the following error message: Send-Mailmessage : Error in processing. The server response was: 5.7.3 STARTTLS is required to send mail

Any ideas would be amazing, thank you.

please see the conversations/comments above, specifically July-2022.

@Odakolo
Copy link

Odakolo commented Feb 28, 2023

Hi, How can I modify this code to send out notification to a security group?

@jerick70
Copy link

Is there a way to set this up with a CSV of emails for the each user name? We have multiple domains that don't have email addresses assigned to the username.

@meoso
Copy link
Author

meoso commented Apr 17, 2023

Is there a way to set this up with a CSV of emails for the each user name? We have multiple domains that don't have email addresses assigned to the username.

would have to program it yourself. powershell is rather easy to get started. good luck.

@dfranken
Copy link

dfranken commented Sep 6, 2023

is it also possible to set an interval? For example set a interval like on day 21,14,7,6,5,4,3,2,1 to send emails to the user if the expiry date matches one of the interval days?

@meoso
Copy link
Author

meoso commented Sep 7, 2023

is it also possible to set an interval? For example set a interval like on day 21,14,7,6,5,4,3,2,1 to send emails to the user if the expiry date matches one of the interval days?

i'm sure it would be possible with some clever if/then statement. look at line 150.
then maybe this resource: https://devblogs.microsoft.com/scripting/powertip-does-powershell-array-contain-a-value/
so i presume you could set an array of days, then compare $array.Contains($daystoexpire), but i'll leave this to you as it may need some testing, troubleshooting, light modifications overall.

@dfranken
Copy link

dfranken commented Sep 13, 2023

is it also possible to set an interval? For example set a interval like on day 21,14,7,6,5,4,3,2,1 to send emails to the user if the expiry date matches one of the interval days?

i'm sure it would be possible with some clever if/then statement. look at line 150. then maybe this resource: https://devblogs.microsoft.com/scripting/powertip-does-powershell-array-contain-a-value/ so i presume you could set an array of days, then compare $array.Contains($daystoexpire), but i'll leave this to you as it may need some testing, troubleshooting, light modifications overall.

i've got it working with help of your pointer and the newly rebuilt script of Robert Pearman.

On line 22 i added this:
$expireinterval = 239,230,21,14,7,6,5,4,3,2,1,0 #how many days before of soon-to-expire paswords. i.e. notify for expiring on certain number of days (and every day until $negativedays)

On line 41 i added:
[array]$expireinterval | Out-Null

On line 150 i added -and ($expireinterval -contains $daystoexpire) and thus changed in to this:
if ( ($daystoexpire -ge $negativedays) -and ($expireinterval -contains $daystoexpire) -and ($daystoexpire -ne "NA") ) {

@bspropro
Copy link

Has anyone tested this on Windows server 2019?

@meoso
Copy link
Author

meoso commented Dec 20, 2023

Has anyone tested this on Windows server 2019?

yes, running it for years now on a 2019 DC.

@bspropro
Copy link

bspropro commented Jan 9, 2024

I'm sure I am missing something simple but the script is saying all our users have "non-expiring accounts" aside from us admins, which is not the case.

@meoso
Copy link
Author

meoso commented Jan 9, 2024

I'm sure I am missing something simple but the script is saying all our users have "non-expiring accounts" aside from us admins, which is not the case.

such is an attribute of each AD account. You will need to inspect/modify your AD accounts.
each account may have one or the other of passwordSetDate = never OR maxPasswordAge = 0.

probably: image

@bspropro
Copy link

bspropro commented Jan 9, 2024

I'm sure I am missing something simple but the script is saying all our users have "non-expiring accounts" aside from us admins, which is not the case.

such is an attribute of each AD account. You will need to inspect/modify your AD accounts. each account may have one or the other of passwordSetDate = never OR maxPasswordAge = 0.

probably: image

As for maxPasswordAge, that is set to 90 and the password never expires is not checked but I am unable to find the passwordSetDate part.

@meoso
Copy link
Author

meoso commented Jan 9, 2024

sorry, i needed to review the code.
line https://gist.github.com/meoso/3488ef8e9c77d2beccfd921f991faa64#file-example-com-password-expiration-notifications-ps1-L84: $passwordSetDate = $user.PasswordLastSet.
Later an else is triggered when such is null (never set): https://gist.github.com/meoso/3488ef8e9c77d2beccfd921f991faa64#file-example-com-password-expiration-notifications-ps1-L109

so the AD's user attribute is PasswordLastSet.

you could run get-aduser -filter * -properties Name, passwordlastset, passwordneverexpires | Where-Object {$_.passwordlastset -eq $null} | ft Name, passwordlastset, Passwordneverexpires to list all the null PasswordLastSet's.

typically when accounts are created, the passwordLastSet is updated when the user changes their own password. I believe if a script creates an account and sets a password at such time, then it does not count; however, i could be mistaken.

@bspropro
Copy link

sorry, i needed to review the code. line https://gist.github.com/meoso/3488ef8e9c77d2beccfd921f991faa64#file-example-com-password-expiration-notifications-ps1-L84: $passwordSetDate = $user.PasswordLastSet. Later an else is triggered when such is null (never set): https://gist.github.com/meoso/3488ef8e9c77d2beccfd921f991faa64#file-example-com-password-expiration-notifications-ps1-L109

so the AD's user attribute is PasswordLastSet.

you could run get-aduser -filter * -properties Name, passwordlastset, passwordneverexpires | Where-Object {$_.passwordlastset -eq $null} | ft Name, passwordlastset, Passwordneverexpires to list all the null PasswordLastSet's.

typically when accounts are created, the passwordLastSet is updated when the user changes their own password. I believe if a script creates an account and sets a password at such time, then it does not count; however, i could be mistaken.

thank you for the help but I'm just dumb and needed to run as admin

@smaligetgroupcom
Copy link

what rights needed in Active Directory to extract the information

@meoso
Copy link
Author

meoso commented Feb 9, 2024

what rights needed in Active Directory to extract the information

you may have to do some deeper research on your own, but essentially make a new user-account, assign it to the root of the domain and set the security tab for only READ, disabling anything WRITE/DELETE/ADD,etc. Also set it for password-never-expires and cannot-change-password. You can also setup a GPO at root level denying any interactive login for that user. This is what we call a "service-account". some service accounts need write access depending on the usage.

[this comment edited several times]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment