Multi-Tenant Office365 password reminder

I’m working on revising and updating a password reminder script that will email users from several different Office 365 accounts if their password is within 14 days of expiring(people ignore the Outlook notifications).

It currently works, but poorly. I want to add HTML reports via email or SMB to an admin as well as much better error handling(and add errors to the html report in case of failed logins). I also want to bring the entire thing in line with best practices and more secure credential handling. Below is my script, any tips or suggestions on how to go about this?

Function PullCredentials
{
    ForEach ($account in $credhash.GetEnumerator()) # Go through each account listed in the INI file.
    {
        # Make a PSCredential object out of the gathered credentials
        Try
        {
            $365creds = New-Object System.Management.Automation.PSCredential (($account.Value.user).ToString(), (ConvertTo-SecureString ($account.Value.pass).tostring() -AsPlainText -Force))
            Write-Host "Logging in with" $account.Value.user
            # Connect to Office 365
            $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://ps.outlook.com/powershell/ -Credential $365creds -Authentication Basic -AllowRedirection
            Import-PSSession $Session -AllowClobber
            Import-Module MSOnline
            Connect-MsolService -credential $365creds

            # Move to function for gathering users and emailing them.
            EmailUsers($account)
        
            # End Office 365 Connection
            Remove-PSSession $Session
            $365creds = $null
        }
        Catch
        {
            Write-Verbose "Failed to connect to $account.Name"
        }
    }
}


Function EmailUsers($arg1)
{
    # Get Users From 365 who are Enabled, Passwords Expire and are Not Currently Expired
    Write-Host $arg1.Name
    $users = Get-MsolUser | where {$_.PasswordNeverExpires -eq $false}
    $users = $users | where {$_.userprincipalname.endswith($arg1.Name)}
    $domain = Get-MsolDomain | where {$_.IsDefault -eq $true }
    $maxPasswordAge = ((Get-MsolPasswordPolicy -DomainName $domain.Name).ValidityPeriod)
    # If the default password policy is configured(90 days) the previous command returns null.
    If ($maxPasswordAge -eq $null)
    {
        $maxPasswordAge = "90"
    }
    $maxPasswordAge = $maxPasswordAge.ToString()

    foreach ($user in $users)
    {
        $Name = $user.DisplayName
        $emailaddress = $user.UserPrincipalName
        $passwordSetDate = $user.LastPasswordChangeTimestamp
        $ExpiresOn = $passwordSetDate + $maxPasswordAge
        $today = (get-date)
        $daystoexpire = (New-TimeSpan -Start $today -End $Expireson).Days
        #write-host (Add-Content $logfile "$date,$Name,$emailaddress,$daystoExpire,$expireson")
        # Set Greeting based on Number of Days to Expiry.
        $messageDays = $daystoexpire

        if (($messageDays) -ge "1")
        {
            $messageDays = "in " + "$daystoexpire" + " days."
        }
        else
        {
            $messageDays = "today."
        }

        # Email Subject Set Here
        $subject="Your Office 365 password will expire $messageDays"
  
        # Email Body Set Here, Note You can use HTML, including Images.
        $body ="
        Dear $name,
         Your Office 365 Password will expire $messageDays.
        To change your password, please follow the attached instructions.
        You will need to visit http://mail.office365.com/ to complete this process.
        
        You will receive this notice once per day until your password is changed.
        Thanks, 
        Support Staff

        Please do not reply to this email."

       # If Testing Is Enabled - Email Administrator
        if (($testing) -eq "Enabled")
        {
            $emailaddress = $testRecipient
        } # End Testing

        # If a user has no email address listed
        if (($emailaddress) -eq $null)
        {
            $emailaddress = $testRecipient
        }# End No Valid Email

        if (($daystoexpire -ge "0") -and ($daystoexpire -lt $expireindays))
        {
            # If Logging is Enabled Log Details
            if (($logging) -eq "Enabled")
            {
                Add-Content $logfile "$date,$Name,$emailaddress,$daystoExpire,$expireson" 
            }

            # Send Email Message
            if (($daystoexpire -ge "0") -and ($daystoexpire -lt $expireindays))
            {
                Write-Host $user.UserPrincipalName "is expiring"
                Send-Mailmessage -smtpServer $smtpServer -from $from -to $emailaddress -subject $subject -body $body -bodyasHTML -priority High -Attachments $attachments -UseSsl -Port 587 -Credential $SMTPcreds
            } # End Send Message
    
        } # End User Processing
    }
}


Function Get-IniContent
{  
    <#  
    .Synopsis  
        Gets the content of an INI file  
          
    .Description  
        Gets the content of an INI file and returns it as a hashtable  
          
    .Notes  
        Author        : Oliver Lipkau   
        Blog        : http://oliver.lipkau.net/blog/  
        Source        : https://github.com/lipkau/PsIni 
                      http://gallery.technet.microsoft.com/scriptcenter/ea40c1ef-c856-434b-b8fb-ebd7a76e8d91 
        Version        : 1.0 - 2010/03/12 - Initial release  
                      1.1 - 2014/12/11 - Typo (Thx SLDR) 
                                         Typo (Thx Dave Stiff) 
          
        #Requires -Version 2.0  
          
    .Inputs  
        System.String  
          
    .Outputs  
        System.Collections.Hashtable  
          
    .Parameter FilePath  
        Specifies the path to the input file.  
          
    .Example  
        $FileContent = Get-IniContent "C:\myinifile.ini"  
        -----------  
        Description  
        Saves the content of the c:\myinifile.ini in a hashtable called $FileContent  
      
    .Example  
        $inifilepath | $FileContent = Get-IniContent  
        -----------  
        Description  
        Gets the content of the ini file passed through the pipe into a hashtable called $FileContent  
      
    .Example  
        C:\PS>$FileContent = Get-IniContent "c:\settings.ini"  
        C:\PS>$FileContent["Section"]["Key"]  
        -----------  
        Description  
        Returns the key "Key" of the section "Section" from the C:\settings.ini file  
          
    .Link  
        Out-IniFile  
    #>  
      
    [CmdletBinding()]  
    Param(  
        [ValidateNotNullOrEmpty()]  
        [ValidateScript({(Test-Path $_) -and ((Get-Item $_).Extension -eq ".ini")})]  
        [Parameter(ValueFromPipeline=$True,Mandatory=$True)]  
        [string]$FilePath  
    )  
      
    Begin  
        {Write-Verbose "$($MyInvocation.MyCommand.Name):: Function started"}  
          
    Process  
    {  
        Write-Verbose "$($MyInvocation.MyCommand.Name):: Processing file: $Filepath"  
              
        $ini = @{}  
        switch -regex -file $FilePath  
        {  
            "^\[(.+)\]$" # Section  
            {  
                $section = $matches[1]  
                $ini[$section] = @{}  
                $CommentCount = 0  
            }  
            "^(;.*)$" # Comment  
            {  
                if (!($section))  
                {  
                    $section = "No-Section"  
                    $ini[$section] = @{}  
                }  
                $value = $matches[1]  
                $CommentCount = $CommentCount + 1  
                $name = "Comment" + $CommentCount  
                $ini[$section][$name] = $value  
            }   
            "(.+?)\s*=\s*(.*)" # Key  
            {  
                if (!($section))  
                {  
                    $section = "No-Section"  
                    $ini[$section] = @{}  
                }  
                $name,$value = $matches[1..2]  
                $ini[$section][$name] = $value  
            }  
        }  
        Write-Verbose "$($MyInvocation.MyCommand.Name):: Finished Processing file: $FilePath"  
        Return $ini  
    }  
          
    End  
        {Write-Verbose "$($MyInvocation.MyCommand.Name):: Function ended"}  
} 



$myDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Start-Transcript -Path "$myDir\log.txt"
$credfile = $myDir + "\credfile.ini"
$logging = "Enabled" # Set to Disabled to Disable Logging
$logFile = $myDir + "\PasswordExpirations.csv" # ie. c:\mylog.csv
$smtpServer="smtp.office365.com"
$SMTPpass = ConvertTo-SecureString "atotallylegitpassword" -AsPlainText -Force
$SMTPcreds = New-Object System.Management.Automation.PSCredential ("Expirations@myorgs-site.com", $SMTPpass)
$date = Get-Date -format ddMMyyyy
$expireindays = 7
$credhash = Get-IniContent $credfile
$testRecipient = "expirations@myorgs-site.com"
$testing = "Enabled" # Set to Disabled to Email Users
$from = "Password Expirations "
$attachments = $myDir + "\Password Change Walkthrough v0.1.docx"

if (($logging) -eq "Enabled")
{
    # Test Log File Path
    $logfilePath = (Test-Path $logFile)
    if (($logFilePath) -ne "True")
    {
        # Create CSV File and Headers
        New-Item $logfile -ItemType File
        Add-Content $logfile "Date,Name,EmailAddress,DaystoExpire,ExpiresOn"
    }
} # End Logging Check


PullCredentials


Stop-Transcript

Hey Matthew, I’ve been trying to get to you post but been a little nuts on this end.

If you look at how Powershell functions are typically designed, they use verbs to indicate what they are doing (e.g. Get, Set, Send, etc.). Your logic is parse a INI, pass it to function to connect to MSOL and then call another function to email the user where you are actually returning all users and searching for a user in that resultset passed from a loop of your INI results. In pseudo code, my thoughts are that the logic should be more like:

  • Get-MailDomains - return all of the mail domains that you are going to search
  • Get-User -Domain [domains from Get-MailDomains] - this function would connect to MSOL and return users for each domain. You need to work on your filtering here. There is a switch on Get-MSOLUser for -DomainName or -SearchString that should allow you to filter the users returned versus returning all users versus returning ALL users and then doing Where statements to do the filtering. Additionally, when you call your Import-PSSession, if you're importing like 3000 cmdlets but only using 3, you may want to consider only importing the commands you will need:
    Import-PSSession -Session $Exch365Session -CommandName Get-Mailbox, Set-MailBox | Out-Null
    

    The goal of this function is to return all users that you need to work with in a PSObject.

  • Send-ExpNotification - this function could be built to with parameters like -EmailAddress, -ExpirationDate -User that you would need to build a message and send the message. You should also take a look at Powershell Splatting to keep long command lines neat and Here-Strings to build HTML with variables indented and easy to read.

If your script you separate your logic to get the users and then do something with them, you can process each item in that PSObject and even add status to each person and at the end have a single PSObject that indicates what happened for each user.

Thanks Rob, the functions and logic flow is definitely going to get overhauled. I’ve been looking at splatting recently and think that it will make the error logging via HTML much easier.

Here is a snipplet of some code in the beginning block of a function using Exchange 365

    begin{
        $sessionParam = @{
            ConfigurationName = "Microsoft.Exchange";
            ConnectionUri = "https://outlook.office365.com/powershell-liveid/";
            Authentication = "Basic"
            AllowRedirection = $true
        }
        if ($Credential) {$sessionParam.Add("Credential", $Credential)}
        try {
            #Create a cloud session
            $Exch365Session = New-PSSession @sessionParam  -ErrorAction StopThis 
            #Import all of the commands for Exchange 365
            Import-PSSession -Session $Exch365Session -CommandName Get-Mailbox, Set-MailBox | Out-Null
        }
        catch {
            Write-Verbose ("{0}: Issue connecting to Office 365 cloud session. {1}" -f $MyInvocation.Command, $_.Exception.Message)
            Write-Error -Message ("{0}: Issue connecting to Office 365 cloud session. {1}" -f $MyInvocation.Command, $_.Exception.Message)
        } #catch New-PSSession\Import-PSSession
    } #begin

Then wrap the function call with a try\catch:

try {
    Get-User -ErrorAction Stop
}
catch {
    #Email admin bad news
}