Get-Date

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.

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 1

In a perfect world, users would never forget their password, and never forget to change it before the expiration date. But we don't live in that perfect world. I covered how to unlock AD accounts earlier in this post, but now I'd like to talk about how to first find the users whose accounts are about to expire, and then email a warning to them. It turns out to be a fairly big script, so I'm going to break it up into a couple of posts. In this first post, I'll cover how to identify the users whose password will expire in the next n days. Then in the next post, we'll send them an email via the company's GMail account. Finally, in the third post in this series, I'll pull the whole thing together into a complete script, with comment-based help.

 

The process to find the users breaks down into several component parts:

  • Get domain credentials
  • Connect to the AD DS domain
  • Do some Date arithmatic
  • Query Active Directory for the users whose passwords will expire

The first part, getting domain credentials, you can use a simple Get-Credential if you want to be prompted every time, or store the credentials securely in a file, as I described here. Notice that I don't suggest that you should simply log in as a Domain Admin and run the script with your domain credentials - that's because I much prefer to always run as a limited user.

 

After we have those credentials, we connect to the Active Directory domain and query it for the MaxPasswordAge property of the domain.

$ADCred = Get-Credential -UserName TreyResearch\Domain.Admin `
                         -Message "Enter the domain admin's password"
$PDC = (Get-Domain -Identity 'TreyResearch.net' `
                   -Credential $ADCred).PDCEmulator
$maxPasswordAge = (Get-ADDefaultDomainPasswordPolicy `
                         -Server $PDC `
                         -Credential $ADCred).MaxPasswordAge

Next, we're going to have to do some Date arithmetic. Rarely fun, but needs must. We'll start by getting some initial values. Our final script will assume a week of warning, but we'll want to be able to change that with a DaysWarning parameter:

[CmdletBinding()]
Param([Parameter(Mandatory=$False,Position=0)]
      [int]$DaysWarning = 7)

And we'll need to know today's date, that's easy:

$today = (Get-Date)

The other bit of information we need is the number of days since the user last set their password. We query for all users (-Filter *), but then discard all the ones we don't need, storing (in $TreyUsers) only those whose password will expire between now and the DaysWarning value. We don't need to do anything with those users whose password is set to never expire, nor do we care about users whose password has already expired. They won't be able to read any emails we send them anyway. :) While we're getting a list of these users, we need to get some properties that aren't returned by default when we use Get-ADUser. We'll use the -Properties parameter to specify those.

$TreyUsers = Get-ADUser -Filter * `
                        -Server $PDC `
                        -Credential $ADCred `
                        -Properties Name,`
                                    PasswordNeverExpires,`
                                    PasswordExpired,`
                                    PasswordLastSet,`
                                    EmailAddress `
      | Where-Object {$_.Enabled -eq $True `
          -AND $_.PasswordNeverExpires -eq $False `
          -AND $_.passwordexpired -eq $False `
          -AND $_.EMailAddress `
          -AND (($today-$_.PasswordLastSet).Days -ge ($MaxPasswordAge.Days-$DaysWarning))
      }

Whew, that looks a right mess. But it's not as bad as it looks. I've tried to make it as efficient as I could, taking advantage of PowerShell's 'short-cut' processing. Yes, I have to query AD DS for all the users, but I quickly stop processing the user if their account is disabled, their password never expires, or their password has already expired. That gets us down to only those users who are actually active. On those, we do a bit of date math. This is slightly complicated by the PasswordLastSet and MaxPasswordAge properties which return an object with more information than we need or want. All we really want is the Days property of those values.

 

The math here is a bit convoluted, so let's work through it. First ($today-$_.PasswordLastSet) is the number of days since the user set their password. The users we want to send an email to are those for whom that is greater than, or equal to,  MaxPasswordAge-DaysWarning.

 

How does that work? Let's assume we have a policy that says you need to change your password at least every 90 days, and I want to start warning users a week ahead of time.  Therefore, MaxPasswordAge-DaysWarning is equal to 83 days. So we only want to send warnings to those users who set their password more than 83 days ago.

 

Next time, we'll send each of those users an email, warning them that they need to change their password.