PowerShell: Remove-ADComputer v. Remove-ADObject
So, as I mentioned the other day, we needed to do some major cleanup of defunct and orphaned computer accounts. Most computers that hadn't been logged in to in the last year needed to go. And there were a LOT of them! Certainly more than anyone wanted to try to do in the GUI. So, having found them, it was time to remove them, using:
$oneyear = (Get-Date).AddDays(-365) Get-ADComputer -Filter {(LastLogonDate -lt $oneyear ) -AND ((Name -like "ws-*") -OR (Name -like "Desktop*") -OR (Name -like "XP-*"))} -Properties LastLogonDate ` | Remove-ADComputer -Confirm:$False -Verbose
And I started watching the deletions go by on the screen. Honestly, a fairly scary moment. Especially when I started to see some errors scroll by..
VERBOSE: Performing the operation "Remove" on target "CN=WS-DCOVENTRY-02,OU=\#Workstations,DC=Contoso,DC=com". VERBOSE: Performing the operation "Remove" on target "CN=WS-VTAWARE-02,CN=Computers,DC=Contoso,DC=com". VERBOSE: Performing the operation "Remove" on target "CN=WS-VIMALG-02,CN=Computers,DC=Contoso,DC=com". VERBOSE: Performing the operation "Remove" on target "CN=WS-FHEMMATI-02,OU=\#Workstations,DC=Contoso,DC=com". VERBOSE: Performing the operation "Remove" on target "CN=WS-BGL-ECOM,CN=Computers,DC=Contoso,DC=com". Remove-ADComputer : The directory service can perform the requested operation only on a leaf object At line:1 char:228 + ... LogonDate | Remove-ADComputer + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + CategoryInfo : NotSpecified: (CN=WS-BGL-ECOM,...C=Contoso,DC=com:ADComputer) [Remove-ADComputer], ADExce ption + FullyQualifiedErrorId : ActiveDirectoryServer:8213,Microsoft.ActiveDirectory.Management.Commands.RemoveADCompute r
I ended up with about 15% of the computer accounts refusing to be removed with Remove-ADComputer. So I checked the man pages for Remove-ADComputer, and there were no additional parameters that would overcome it. Well, phooie!
OK, so time to haul out the seriously powerful tool, Remove-ADObject -Recursive. A word of warning here -- you can do some serious damage with this command. First, I verified the cause of the failures -- the offending computer accounts had subsidiary objects that they probably shouldn't ever have had. OK, all that was before my time, but none of them were any longer relevant. So, now, my command needed to morph due to the somewhat more annoying syntax of Remove-ADObject. I couldn't just pipe the results of Get-ADComputer to it, I needed to return a list of objects and walk through them with a ForEach loop, like this:
$oneyear = (Get-Date).AddDays(-365) $adFilter = {(LastLogonDate -lt $oneyear ) -AND ((Name -like "ws-*") -OR (Name -like "Desktop*") -OR (Name -like "XP-*"))} ForEach ($Computer in (Get-ADComputer -Filter $adFilter -Properties LastLogonDate)) { Remove-ADObject $Computer -Recursive -Confirm:$False -Verbose }
And there go the last of the orphaned accounts! Notice, by the way, the use of a variable to hold the filtering criteria. This is a useful trick if you're iterating through a bunch of filters, or dealing with a fairly long and complicated one. You need to edit the variable with each iteration, but the actual command stays the same. Plus, IMHO, it makes the whole thing more readable.
PowerShell: Finding Orphaned Computer Accounts in AD
The other day we decided it was time and more to do some cleanup of orphaned computer accounts in our AD. We are about to do some AD restructuring, and figured it was a good opportunity to clean up and remove old computer accounts for machines that no longer existed. Now there are probably lots of ways to do this, but the way I chose was to look at the AD properties of the computer to see when it was last logged on to. Then arbitrarily deciding that any computer that hadn't been logged on to in the last year was a good candidate. At first glance, that's not part of the properties that are returned with Get-ADComputer:
Get-ADComputer -Identity srv2 DistinguishedName : CN=SRV2,OU=Servers,DC=contoso,DC=com DNSHostName : srv2.contoso.com Enabled : True Name : SRV2 ObjectClass : computer ObjectGUID : 0ce3c9fa-4b07-4dde-8323-ff94153d2bf9 SamAccountName : SRV2$ SID : S-1-5-21-2576220272-3971274590-1167723607-15115 UserPrincipalName :
But wait, I know there have to be more than that -- let's try making sure that we get all the properties, not just the most common:
Get-ADComputer -Identity srv2 -Properties * AccountExpirationDate : accountExpires : 9223372036854775807 AccountLockoutTime : AccountNotDelegated : False ... DistinguishedName : CN=SRV2,OU=Servers,DC=contoso,DC=com DNSHostName : srv2.contoso.com ... KerberosEncryptionType : {RC4, AES128, AES256} LastBadPasswordAttempt : 4/25/2016 6:28:41 PM LastKnownParent : lastLogoff : 0 lastLogon : 131689942478668713 LastLogonDate : 4/18/2018 10:18:47 PM lastLogonTimestamp : 131685887279055446 ... whenCreated : 4/23/2015 6:28:41 PM
Ah, that's more like it. Now I can see that there's a LastLogonDate property. That should do it. Now, it's just a case of simple math. And because we're looking for more than a single computer, we need to switch to using the -Filter parameter of Get-ADComputer. Plus I'll specify which server to query, and the account credentials to use to run the query:
Get-ADComputer ` -Server dc01 ` -Credential $cred ` -Filter * ` -Properties LastLogonDate ` | Where-Object LastLogonDate -lt (Get-Date).AddDays(-365) ` | Select-Object Name,LastLogonDate
Now that's fine for moderately sized Active Directories, but could be a bit of a problem for large ones. So, instead of grabbing every computer in the domain and then filtering them, let's only get the one's that fit our one year criteria.
$oneyear = (Get-Date).AddDays(-365) Get-ADComputer ` -Server dc01 ` -Credential $cred ` -Filter {LastLogonDate -lt $oneyear } ` -Properties LastLogonDate ` | Select-Object Name,LastLogonDate ` | ConvertTo-CSV -NoTypeInformation > C:\Temp\Defunct.csv
And now we have the list usefully exported to a CSV where we can manipulate it and verify the names really are those of orphaned computers. From there, I could feed the list of computers into Remove-ADComputer, or I can do it directly by piping this result to Remove-ADComputer, complete with a -Force parameter. Yeah. Right. And maybe a good idea to just verify the list first.
ETA: Well, it might be a good idea to check the available parameters for Remove-ADComputer before I post something. Sigh. There is no -Force parameter. Instead, you need to use -Confirm:$False if you want Remove-ADComputer to just do its work without prompting. And if the computer has any objects associated with it, you'll have to use Remove-ADObject. But more on that in another post.
Add a Domain User to the Local Administrators Group
When building out a workstation for an AD Domain user, in some environments the user is added to the local Administrators group to allow the user to install and configure applications. Now there are some of us who think that's a Bad Idea and a Security Risk, but the reality is that it's policy in some organizations. Doing this with the GUI is easy, but who wants to have to use the GUI for anything? Especially for a highly repetitive task that you're going to have to do on every user's workstation. So, let's use PowerShell and [ADSI] to do the heavy lifting.
The first step is to define the target we want to add the user to:
$ComputerName = "workstation01" $Group = "Administrators" $target=[ADSI]"WinNT://$ComputerName/$Group,group"
Next, we invoke the Add method on that target to add the user to the group.
$Domain = 'TreyResearch' $UserName = 'Charlie.Russel' $target.psbase.Invoke("Add",([ADSI]"WinNT://$Domain/$UserName").path)
And that's really all there is to it.
(Note, by the way, that this is one of the only places in PowerShell where CASE MATTERS. the WinNT commands are case sensitive so don't change that to winnt or WINNT. It won't work. )
Finally, let's pull all that together into a script that accepts the user name, the target computer, and the AD Domain as parameters:
<# .Synopsis Adds a user to the Local Administrators group .Description Add-myLocalAdmin adds a user to the local Administrators group on a computer. .Example Add-myLocalAdmin Charlie.Russel Adds the TreyResearch user Charlie.Russel to the Administrators local group on the localhost. .Example Add-myLocalAdmin Charlie.Russel -ComputerName ws-crussel-01 Adds the TreyResearch user Charlie.Russel to the Administrators local group on ws-crussel-01. .Example Add-myLocalAdmin -UserName Charlie.Russel -ComputerName ws-crussel-01 -Domain Contoso Adds the Contoso user Charlie.Russel to the Administrators local group on ws-crussel-01. .Parameter UserName The username to add to the Administrators local group. This should be in the format first.last. .Parameter ComputerName [Optional] The computer on which to modify the Administrators group. The default is localhost .Parameter Domain [Optional] The user's Active Directory Domain. The default is TreyResearch. .Inputs [string] [string] [string] .Notes Author: Charlie Russel Copyright: 2017 by Charlie Russel : Permission to use is granted but attribution is appreciated Initial: 21 June, 2017 (cpr) ModHist: : #> [CmdletBinding()] Param( [Parameter(Mandatory=$True,Position=0)] [alias("user","name")] [string] $UserName, [Parameter(Mandatory=$False,Position=1)] [string] $ComputerName = 'localhost', [Parameter(Mandatory=$False)] [string] $Domain = 'TreyResearch' ) $Group = 'Administrators' # Please be warned. The syntax of [ADSI] is CASE SENSITIVE! $target=[ADSI]"WinNT://$ComputerName/$Group,group" $target.psbase.Invoke("Add",([ADSI]"WinNT://$Domain/$UserName").path)
Building a Lab in Hyper-V with PowerShell, Part 5
Deploying a DHCP Server
Now that you have your forest and domain installed, including DNS, the next step to setting up a lab is the DHCP server. Start by creating a new VM for the DHCP server, trey-dhcp-03. (For details on how to create a VM with PowerShell, see Building a Lab in Hyper-V Part 2 and Part 3. ) There's no particular need to make this a GUI installation, so build it as a Server Core installation. We'll do the configuration all in PowerShell anyway.
Next, Install the DHCP role, add the local groups required, and authorize it in Active Directory. (Do note the slightly different server name when you go to do that, please, and you don't need or want to promote this server to a domain controller. )
Now, as you'll remember from earlier posts in this series, I configure all my VMs with known MAC addresses by first defining the range and then requiring a MAC address final pair parameter to New-myVM.ps1. This allows me to now configure a set of reservations for each VM in the lab, simplifying connections and making it a lot easier for me to keep track what is where.
Assuming by now you have installed the DHCP role and authorized it in Active Directory, the next step is to set up your IPv4 and IPv6 ranges. We do that by first adding a scope, then setting exclusion ranges and finally scope options. For IPv4, this is three commands:
Add-DhcpServerv4Scope -Name "Trey-Default" ` -ComputerName "trey-dhcp-03" ` -Description "Default IPv4 Scope for Lab" ` -StartRange "192.168.10.1" ` -EndRange "192.168.10.220" ` -SubNetMask "255.255.255.0" ` -State Active ` -Type DHCP ` -PassThru Add-DhcpServerv4ExclusionRange -ScopeID "192.168.10.0" ` -ComputerName "trey-dhcp-03" ` -StartRange "192.168.10.1" ` -EndRange "192.168.10.20" ` -PassThru Set-DhcpServerv4OptionValue -ScopeID 192.168.10.0 ` -ComputerName "trey-dhcp-03" ` -DnsDomain "TreyResearch.net" ` -DnsServer "192.168.10.2" ` -Router "192.168.10.1" ` -PassThru
Now, the same process for IPv6, though I usually do NOT create IPv6 reservations, but do want to set some default values.
Add-DhcpServerv6Scope -Name "Trey-IPv6-Default" ` -ComputerName "trey-dhcp-03" ` -Description "Default IPv6 Scope for Lab" ` -Prefix 2001:db8:0:10:: ` -State Active ` -PassThru Add-DhcpServerv6ExclusionRange –ComputerName trey-dhcp-03 ` -Prefix 2001:db8:0:10:: ` -StartRange 2001:db8:0:10::1 ` -EndRange 2001:db8:0:10::20 ` -PassThru Set-DhcpServerv6OptionValue -Prefix 2001:db8:0:10:: ` -ComputerName "trey-dhcp-03" ` -DnsServer 2001:db8:0:10::2 ` -DomainSearchList "TreyResearch.net" ` -PassThru
Now, create a CSV file with Names,MAC addresses(ClientID), and IPv4 Addresses. You can use your favourite plain text editor (mine is gVim), or Excel to create the CSV file. My lab has the following for the 192.168.10.xxx range of IP addresses:
Name,ClientID,IPAddress trey-edge-01,00-15-5D-32-0A-01,192.168.10.1 trey-dc-02,00-15-5D-32-0A-02,192.168.10.2 trey-dhcp-03,00-15-5D-32-0A-03,192.168.10.3 trey-dc-04,00-15-5D-32-0A-04,192.168.10.4 trey-srv-05,00-15-5D-32-0A-05,192.168.10.5 trey-wds-11,00-15-5D-32-0A-0B,192.168.10.11 Trey-Srv-12,00-15-5D-32-0A-0C,192.168.10.12 Trey-Srv-13,00-15-5D-32-0A-0D,192.168.10.13 Trey-Srv-14,00-15-5D-32-0A-0E,192.168.10.14 Trey-Srv-15,00-15-5D-32-0A-0F,192.168.10.15 Trey-Srv-16,00-15-5D-32-0A-10,192.168.10.16 Trey-client-21,00-15-5D-32-0A-15,192.168.10.21 Trey-client-22,00-15-5D-32-0A-16,192.168.10.22 Trey-client-23,00-15-5D-32-0A-17,192.168.10.23 Trey-client-24,00-15-5D-32-0A-18,192.168.10.24 Trey-client-25,00-15-5D-32-0A-19,192.168.10.25
Save the CSV file as "TreyDHCP.csv". Now, to create the reservations, first read in the CSV file with:
$TreyDHCP = Import-CSV TreyDHCP.csv
Then, create the IPv4 reservations with a simple ForEach loop:
ForEach ($addr in $TreyDHCP ) { $ErrorActionPreference = "Continue" Add-DhcpServerv4Reservation -ScopeID 192.168.10.0 ` -Name $addr.Name ` -ClientID $addr.ClientID ` -IPAddress $addr.IPAddress ` -PassThru }
If you run multiple NICs on your lab environment, you'll want to repeat all of the above for the second range of IP addresses.
So, here's the whole thing in a script that supports running remotely.
<# .Synopsis Install and configure DHCP for the TreyResearch.net lab environment .Description The New-TreyDHCP script installs and configures the DHCP environment for the TreyResearch.net lab environment. It assumes a DHCP server "trey-dhcp-03" has already been created, but accepts a parameter to change the server name. The script reads a CSV file with the machine names, MAC addresses (ClientIDs), and IPv4 addresses that the that the network will use and then creates IPv4 DHCP reservations for those machines. .Example New-TreyDHCP.ps1 Reads in a list of DHCP addresses from TreyDHCP.csv and configures trey-dhcp-03 as a DHCP server with those addresses. .Example New-TreyDHCP.ps1 -ComputerName Trey-core-03 -Path c:\temp\dhcp.csv Reads in a list of DHCP addresses from c:\temp\dhcp.csv and configures the server Trey-core-03 as a DHCP server with those address reservations. .Parameter ComputerName The server to install and configure DHCP on. Default value is trey-dhcp-03 .Parameter Path The path to a CSV file with the machine names, client IDs, and IPv4 addresses to configure DHCP reservations for. The default value is .\TreyDHCP.csv. .Inputs [string] [string] .Notes Author: Charlie Russel Copyright: 2017 by Charlie Russel : Permission to use is granted but attribution is appreciated Initial: 25 March, 2014 (cpr) ModHist: 14 March, 2017 (cpr) Added ComputerName parameter and man page : #> [CmdletBinding()] Param( [Parameter(Mandatory=$False)] [alias("server")] [string] $ComputerName = 'trey-dhcp-03', [Parameter(Mandatory=$False)] [Alias("filename")] [string] $Path = '.\TreyDHCP.csv' ) if ( (Get-WindowsFeature -Name DHCP -ComputerName $ComputerName) -ne "Installed" ) { Install-WindowsFeature -Name DHCP -ComputerName $ComputerName -IncludeManagementTools } if (Test-Path $Path ) { $TreyDHCP = Import-CSV $Path } else { Throw "This script requires an input CSV file with the DHCP Reservations in it." } # Find out if the DHCP Server is already authorized. If it is, # we assume all the rest of this is done. If ( (Get-DhcpServerInDC).DnsName -match $ComputerName ) { $IsAuth = $True } else { $IsAuth = $False $DnsName = $ComputerName + ".TreyResearch.net" } # If the server isn't authorized, then nothing is set yet, so set up # our DHCP server. if (! $IsAuth) { Add-DhcpServerInDC -DnsName $DnsName -PassThru # Create local groups for DHCP # The WinNT in the following IS CASE SENSITIVE $connection = [ADSI]"WinNT://$ComputerName" $lGroup = $connection.Create("Group","DHCP Administrators") $lGroup.SetInfo() $lGroup = $connection.Create("Group","DHCP Users") $lGroup.SetInfo() Add-DhcpServerv4Scope -Name "Trey-Default" ` -Description "Default IPv4 Scope for TreyResearch Lab" ` -StartRange "192.168.10.1" ` -EndRange "192.168.10.220" ` -SubNetMask "255.255.255.0" ` -State Active ` -Type DHCP ` -ComputerName $ComputerName ` -PassThru Add-DhcpServerv4ExclusionRange -ScopeID "192.168.10.0" ` -StartRange "192.168.10.1" ` -EndRange "192.168.10.20" ` -ComputerName $ComputerName ` -PassThru Set-DhcpServerv4OptionValue -ScopeID 192.168.10.0 ` -DnsDomain "TreyResearch.net" ` -DnsServer "192.168.10.2" ` -Router "192.168.10.1" ` -ComputerName $ComputerName ` -PassThru Add-DhcpServerv6Scope -Name "Trey-IPv6-Default" ` -Description "Default IPv6 Scope for TreyResearch Lab" ` -Prefix 2001:db8:0:10:: ` -State Active ` -ComputerName $ComputerName ` -PassThru Add-DhcpServerv6ExclusionRange -Prefix 2001:db8:0:10:: ` -StartRange 2001:db8:0:10::1 ` -EndRange 2001:db8:0:10::20 ` -ComputerName $ComputerName ` -PassThru Set-DhcpServerv6OptionValue -Prefix 2001:db8:0:10:: ` -DnsServer 2001:db8:0:10::2 ` -DomainSearchList "TreyResearch.net" ` -ComputerName $ComputerName ` -PassThru } ForEach ($addr in $TreyDHCP ) { $ErrorActionPreference = "Continue" Add-DhcpServerv4Reservation -ScopeID 192.168.10.0 ` -Name $addr.Name ` -ClientID $addr.ClientID ` -IPAddress $addr.IPAddress ` -ComputerName $ComputerName ` -PassThru }
I hope you find this script useful, and I'd love to hear comments, suggestions for improvements, or bug reports as appropriate. As always, if you use this script as the basis for your own work, please respect my copyright and provide appropriate attribution.
PowerShell: Sending password expiration notices via GMail – Part 3
In Part 1 of this series, I showed you how to identify users whose password was about to expire. Then in Part 2 of the series, I took that list of users and sent email to them using gmail-hosted company email. This third part of the series pulls all that together into a single script, complete with comment-based help. As always, this and all my scripts are copyrighted, but you're welcome to use them as the basis for your own scripts. However, I do appreciate attribution. Thanks, and enjoy.
<# .Synopsis Sends a "Password Expiring" warning email through TreyResearch's gmail. .Description Send-TreyPasswordExpiryNotice first creates a list of accounts whose password will expire in the near future (default is 1 week). It then emails the users to warn them that their password will expire soon. This initial version runs interactively only. .Example Send-TreyPasswordExpiryNotice Sends a warning notice to all TreyResearch users whose password will expire in the next 7 days or less. .Example Send-TreyPasswordExpiryNotice -Logging Sends a warning notice to all TreyResearch users whose password will expire in the next 7 days or less, and creates a log file that is echoed to the console at the end. .Example Send-TreyPasswordExpiryNotice -DaysWarning 14 Sends a warning notice to all TreyResearch users whose password will expire in the next 14 days or less. .Example Send-TreyPasswordExpiryNotice -DaysWarning 5 -Logging -Testing -Verbose Does NOT send a warning notice to TreyResearch users, but rather processes the first user and sends a notice to the admin user(s) and writes to the log file. The -Verbose switch will make it additionally chatty. .Parameter DaysWarning The number of days advanced warning to give users whose passwords are close to expiration. The default is 7 days or less. .Parameter Logging Switch to enable logging. Logs are written to C:\Temp\emaillogs.csv. When this switch is true, Send-TreyPasswordExpiryNotice outputs a table with a list of accounts due to expire as well as writing to a log file. .Parameter Testing Switch to enable testing. When enabled, email is sent to a list of Admin users and only a single account is processed. .Inputs [int] [switch] [Switch] .Notes Author: Charlie Russel ThanksTo: Robert Pearman (WSSMB MVP),Jeffrey Hicks (PS MVP) Copyright: 2016 by Charlie Russel : Permission to use is granted but attribution is appreciated Initial: 06 Sept, 2016 (cpr) : 09 Dec, 2016 (cpr) -(Ver 1.5) -- Reworked: Only process users who need reminding. Formatting changes #> [CmdletBinding()] Param( [Parameter(Mandatory=$False,Position=0)] [int] $DaysWarning = 7, [parameter(Mandatory=$false)] [Switch] $Logging, [parameter(Mandatory=$false)] [switch] $Testing ) #Set parameters for gmail. $smtpServer ="smtp.gmail.com" $SMTPPort = 587 $from = "IT Notification <it-notification@TreyResearch.net>" $AdminUser1 = "Charlie.Russel@TreyResearch.net" $AdminUser2 = "admin.user2@TreyResearch.net" $AdminUser3 = "admin.user3@TreyResearch.net" $externalUser= "external.account@example.com" # Cast this to a list of strings to allow for multiple test recipients [string[]]$testRecipient = $AdminUser1,$AdminUser2,$AdminUser3 <# This uses a stored password sitting on a local hard drive. This is a reasonably secure way to work with passwords in a file, and is ONLY accessible by the user that created it. Create the password with: PSH> Read-Host -AsSecureString | ConvertFrom-SecureString | Out-File $home\Documents\TreyPW.txt See blog post at: http://blogs.msmvps.com/russel/2016/10/04/powershell-get-credential-from-a-file for full details. Alternately, simply prompt for the credentials here with Get-Credential. #> $TreyUsr = "charlie.russel@TreyResearch.net" $TreyPW = Get-Content $Home\Documents\TreyPW.txt | ConvertTo-SecureString $Cred = New-Object System.Management.Automation.PSCredential -ArgumentList $TreyUsr, $TreyPW # Check Logging Settings if ($Logging) { $logFile = "C:\Temp\emaillogs.csv" if (! (Test-Path "C:\Temp") ) { Write-Verbose "No C:\Temp directory, so creating one..." New-Item -Path "C:\" -Name Temp -ItemType Directory } # Remove Logfile if it already exists If ( (Test-Path $logFile)) { Remove-Item $logFile } # Create CSV File and Headers New-Item -Path $logfile -ItemType File Add-Content $logfile "Date,Name,EmailAddress,DaysLeft,ExpiresOn,Notified" } # System Settings $textEncoding = [System.Text.Encoding]::UTF8 $date = Get-Date -format "MM/dd/yyyy" # Explicitly import the Active Directory module, but get rid of the noise if it's already loaded. Import-Module ActiveDirectory 4>$NULL # Use the following to query the domain for who the PDC Emulator role holder is. $TreyDC = (Get-ADDomain -Identity "TreyResearch.net" -Credential $Cred).PDCEmulator # Send a cc: to myself or a list of users $AdminUser = "charlie.russel@TreyResearch.net" $cclist = @($AdminUser) # Do calculations outside the ForEach loop whenever possible $maxPasswordAge = (Get-ADDefaultDomainPasswordPolicy -Server $TreyDC -Credential $Cred).MaxPasswordAge $today = (get-date) # Notice this doesn't get Expired or NeverExpires users. Don't want to send them emails. $TreyUsers = Get-ADUser -filter * ` -properties Name,PasswordNeverExpires,PasswordExpired,PasswordLastSet,EmailAddress ` -Server $TreyDC ` -Credential $Cred ` | where { $_.Enabled -eq $True ` -AND $_.PasswordNeverExpires -eq $False ` -AND $_.passwordexpired -eq $False ` -AND $_.EMailAddress ` -AND (($today - $_.PasswordLastSet).Days -ge ($MaxPasswordAge.Days - $DaysWarning)) } <# Get notification credentials. Prompt with Get-Credential if not using stored creds. $gCred = Get-Credential -UserName "it-notification@TreyResearch.net" ` -Message "Enter Password for IT-Notification account" #> $gUsr = "it-notification@TreyResearch.net" $gPW = Get-Content "$Home\Documents\itnotificationsPW.txt" | ConvertTo-SecureString $gCred = New-Object System.Management.Automation.PSCredential -ArgumentList $gUsr, $gPW # Now, we start to do the work. foreach ($user in $TreyUsers) { Write-Verbose "Processing user $user" $Name = $user.Name $Email = $user.emailaddress $SAM = $user.SAMAccountName $sent = " " $passwordSetDate = $user.PasswordLastSet Write-Verbose "$SAM last set their password on $PasswordSetDate" $expiresOn = $passwordSetDate + $maxPasswordAge $DaysLeft = (New-TimeSpan -Start $today -End $Expireson).Days if (($DaysLeft) -gt "1") { $MessageDays = "in " + "$DaysLeft" + " days." } else { $MessageDays = "today!" } # Email Subject Set Here $subject="Your password will expire $messageDays" Write-Verbose "$Name`'s password will expire $messageDays" # Email Body Set Here, Note You can use HTML, including Images. # This uses PowerShell's here-string. $body =@" Dear $name, <p>Your TreyReseach.net Active Directory Domain credentials <b>will expire $messagedays</b> Please update your credentials as soon as possible! <br> </p> <p>If you are using a Windows domain joined system and are connected to the intranet, press ctrl-alt-delete and select change password. Alternatively, if you are outside of the network, connect to the corporate VPN and reset your password with the same process.<br> </p> <p>If you are not using a Windows based system, ensure you are on the intranet or connected to the corporate VPN. Proceed to https://password.TreyResearch.net <https://password.TreyResearch.net> and reset your password.<br> </p> <p>This process will also sync your newly created AD password to your Gmail password. Please allow up to 5 minutes for replication of the passwords to occur.<br><br> </p> <p><br><b>Problems</b>? <br>Please open a Service Desk request by clicking on the Help Agent icon on your system. If you are NOT running a Help Agent, please contact a member of the IT Team for instructions on how to install the agent. It is a strict TreyResearch company policy that all company-owned systems run the Help Agent. <br></p> <p>Thanks, <br> IT Team </P> "@ # If Testing Is Enabled - Email Administrator if ($testing) { $email = $testRecipient $Subject = "PasswordExpiration Test Message" $cclist = $AdminUser2,$externalUser } # Send Email Message Write-Verbose "$SAM's password is due to expire in $DaysLeft which is less than the " Write-Verbose "DaysWarning Parameter setting of $DaysWarning days." # I've left this as a straight output to the host. If you want it quieter, make it a Write-Verbose "Sending Email Message to $email using $gUsr account" Send-Mailmessage -smtpServer $smtpServer ` -from $from ` -to $email ` -cc $cclist ` -subject $subject ` -body $body ` -bodyasHTML ` -priority High ` -Encoding $textEncoding ` -UseSSL ` -port $SMTPPort ` -Credential $gCred $sent = "Yes" # Used for logging if ($Logging) { Add-Content $logfile "$date,$Name,$email,$DaysLeft,$expiresOn,$Sent" } if ($Testing) { "Sleeping 5, then breaking so we only process a single record" Sleep 5 Break } } If ($Logging) { # Use the logging file to display a table of the accounts about to expire. $expiringAccts = Import-Csv -Path $logfile $expiringAccts | Sort-Object -Property ExpiresOn ` | Format-Table -AutoSize ` @{Expression={$_.DaysLeft};` Label="#Days";` align="Right";` width=7}, ` Name,` @{Expression={(Get-Date -Date $_.ExpiresOn -Format 'MMMM dd')};` Label="Expires On:"} }
ETA: Minor bug fix (-Identity instead of -Identify. Sheesh!)
PowerShell: Sending password expiration notices via GMail – Part 2
In the first part of this trio of posts, I showed a way to identify users whose password was about to expire. Which is useful, but now you need to notify them. If your company email is in GMail, there's a few gotchas you'll need to watch out for, but I'll show you how to get it all working.
The process to email each of those users you identified in Part 1 has the following component parts:
- Setup your log file if the -Logging switch was used
- Get credentials for sending the email from a notification only address.
- Extract the relevant user details for each user
- Calculate the number of days before the user's password expires
- Set the content of the email (subject, body, etc.)
- Configure logging and testing behaviours
- Send the actual email
First, to set up our logfile (a CSV file with details on each user we've sent email to):
if ($Logging) { $logFile = 'C:\Temp\emaillogs.csv' if (! (Test-Path 'C:\Temp') ) { Write-Verbose 'No C:\Temp directory, so creating one...' New-Item -Path 'C:\' -Name 'Temp' -ItemType 'Directory' } # Remove Logfile if it already exists if ( (Test-Path $logFile)) { Remove-Item $logFile } # Create CSV File and Headers New-Item -Path $logfile -ItemType File Add-Content $logfile "Date,Name,EmailAddress,DaysLeft,ExpiresOn,Notified" }
Now there are certainly other was to do this, but this works, so I use it.
When you go to send the email, you'll likely want to use a notification only address, rather than your real one. But for that, you'll need to prompt for credentials. Or store them in a file, as described in an earlier post. To prompt, use:
$gCred = Get-Credential -UserName "it-notification@treyresearch.net" ` -Message "Enter Password for IT-Notification account"
Now, let's start to build the ForEach loop you'll use to do the work of this process for each user. Start by extracting the relevant user details. (Note that $TreyUsers was built in Part 1 of this series).
foreach ($user in $TreyUsers) { $Name = $user.Name $Email = $user.emailaddress $Sam = $user.SAMAccountName $sent = "" # Reset Sent Flag $passwordSetDate = $user.PasswordLastSet }
Now, continuing in that same ForEach loop, calculate how many days until their password expires, and set some text based on that:
$expiresOn = $passwordSetDate + $maxPasswordAge $DaysLeft = (New-TimeSpan -Start $today -End $Expireson).Days if (($DaysLeft) -gt "1") { $MessageDays = "in " + "$DaysLeft" + " days." } else { $MessageDays = "today." }
You'll use that $MessageDays variable as you build your email message. That email message needs to tell them how long they have, and where and how to change their password. This last part is particularly important if you have users who regularly work remote, or whose primary work computer is a non-Windows computer. But even connected Windows users often have problems. :(
So, for the message subject you have:
$subject="Your password will expire $messageDays"
And for the message body, use something like this:
$body =@" Dear $name, <p>Your TreyReseach.net Active Directory Domain credentials <b>will expire $messagedays</b> Please update your credentials as soon as possible! <br> </p> <p>If you are using a Windows domain joined system and are connected to the intranet, press ctrl-alt-delete and select change password. Alternatively, if you are outside of the network, connect to the corporate VPN and reset your password with the same process.<br> </p> <p>If you are not using a Windows based system, ensure you are on the intranet or connected to the corporate VPN. Proceed to https://password.TreyResearch.net <https://password.TreyResearch.net> and reset your password.<br> </p> <p>This process will also sync your newly created AD password to your Gmail password. Please allow up to 5 minutes for replication of the passwords to occur.<br><br> </p> <p><br><b>Problems</b>? <br>Please open a Service Desk request by clicking on the Help Agent icon on system. If you are NOT running a Help Agent, please contact a member of the IT Team for instructions on how to install the agent. It is a strict TreyResearch company policy that all company-owned systems run the Help Agent. <br></p> <p>Thanks, <br> IT Team </P> "@
Now you'll notice two things about that email body. First, it uses PowerShell's Here-String capability, making it easier to edit and even include double-quotes inside it if you want. You can do this without using a Here-String, but for any long text like this, it's easier in the long run. The second thing is that it includes some basic HTML codes. Gmail allows them, so use them for emphasis where appropriate.
Next, let's set up the processing of test messages and logging. First, for test messages, you'll want to override the user's email to only send it to yourself, or to your team. Still inside the ForEach loop, do that with:
[string[]]$testRecipient = $Charlie,$AdminUser2,$AdminUser3 # If Testing Is Enabled--Email Admins, not real users, and cc an external address if ($testing) { $email = $testRecipient $Subject = "PasswordExpiration Test Message" $cclist = $externalUser }
You'll also want to handle logging, so write the logging information to the log file you created earlier.
if ($Logging) { Add-Content $logfile "$date,$Name,$email,$DaysLeft,$expiresOn,$Sent" }
Finally, send the actual message:
Send-MailMessage -smtpServer $smtpServer ` -from $from ` -to $email ` -cc $cclist ` -subject $subject ` -body $body ` -bodyasHTML ` -priority High ` -Encoding $textEncoding ` -UseSSL ` -port $SMTPPort ` -Credential $gCred
You'll notice a couple of things there. One, you need to identify your SMTP server and the SMTP port to use. That's usually smtp.gmail.com and port 587 for mail sent this way. Next, you need to have set the $textEncoding variable somewhere. In most cases, this will be UTF8, and you'll set that with:
$textEncoding = [System.Text.Encoding]::UTF8
Adjust as necessary for your environment.
Now, let's finish off by handling testing - you only want to send a single message to yourself if you're testing, not 60 of them! Do that with:
if ($Testing) { "Sleeping 5, then breaking so we only process a single record" Sleep 5 Break }
Finally, outside the ForEach loop, if logging is enabled, display the results of the logging file in a way you can read:
if ($Logging) { # Use the logging file to display a table of the accounts about to expire. $expiringAccts = Import-Csv -Path $logfile $expiringAccts | Sort-Object -Property ExpiresOn ` | Format-Table -AutoSize ` @{Expression={$_.DaysLeft};` Label="#Days";` align="Right"; width=7}, ` Name,` @{Expression={(Get-Date -Date $_.ExpiresOn -Format 'MMMM dd')};` Label="Expires On:"} }
I'll post the entire script in Part 3, shortly, complete with comment-based help and full details.