Public/activedirectory/Get-ADPasswordStatus.ps1

#Requires -Version 5.1

function Get-ADPasswordStatus {
    <#
    .SYNOPSIS
        Audits password status of all Active Directory user accounts
 
    .DESCRIPTION
        Returns the password status of all enabled Active Directory user accounts including
        password age, expiry date, applied password policy (Fine-Grained Password Policy or
        Default Domain Policy), and problem flags. By default all enabled accounts are returned.
        Use the -ProblemsOnly switch to filter to accounts with password concerns only
        (expired, never expires, or must change at next logon).
 
    .PARAMETER ProblemsOnly
        When specified, returns only accounts with at least one password concern:
        expired password, password set to never expire, or must change at next logon.
        By default all enabled accounts are returned.
 
    .PARAMETER SearchBase
        The distinguished name of the OU to search within. If omitted, searches the entire domain.
 
    .PARAMETER Server
        Specifies the Active Directory Domain Services instance to connect to.
 
    .PARAMETER Credential
        Specifies the credentials to use for the Active Directory query.
 
    .EXAMPLE
        Get-ADPasswordStatus
 
        Returns password status for all enabled user accounts in the domain.
 
    .EXAMPLE
        Get-ADPasswordStatus -ProblemsOnly -Server 'dc01.contoso.com'
 
        Returns only accounts with password concerns from a specific domain controller.
 
    .EXAMPLE
        Get-ADPasswordStatus -SearchBase 'OU=Users,DC=contoso,DC=com' | Where-Object DaysUntilExpiry -lt 14
 
        Returns accounts in a specific OU whose passwords expire within 14 days.
 
    .OUTPUTS
        PSWinOps.ADPasswordStatus
        Returns objects with account identity, password state flags, applied password
        policy name, password age, expiry date, and days until expiry.
 
    .NOTES
        Author: Franck SALLET
        Version: 2.0.0
        Last Modified: 2026-04-04
        Requires: PowerShell 5.1+ / Windows only
        Requires: ActiveDirectory module (RSAT)
 
    .LINK
        https://github.com/k9fr4n/PSWinOps
 
    .LINK
        https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-aduser
 
    .LINK
        https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-adfinegrainedpasswordpolicy
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [switch]$ProblemsOnly,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$SearchBase,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Server,

        [Parameter()]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]$Credential
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"

        try {
            Import-Module -Name 'ActiveDirectory' -ErrorAction Stop -Verbose:$false
        }
        catch {
            Write-Error -Message "[$($MyInvocation.MyCommand)] ActiveDirectory module is not available: $_"
            return
        }

        $adSplat = @{}
        if ($PSBoundParameters.ContainsKey('Server')) {
            $adSplat['Server'] = $Server
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            $adSplat['Credential'] = $Credential
        }

        # -----------------------------------------------------------------
        # Pre-fetch password policies (1 query for default + 1 for all PSOs)
        # -----------------------------------------------------------------
        $defaultMaxAge = [TimeSpan]::Zero
        try {
            $defaultPolicy = Get-ADDefaultDomainPasswordPolicy -ErrorAction Stop @adSplat
            $defaultMaxAge = $defaultPolicy.MaxPasswordAge
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Default Domain Policy MaxPasswordAge: $defaultMaxAge"
        }
        catch {
            Write-Warning -Message "[$($MyInvocation.MyCommand)] Could not retrieve Default Domain Password Policy: $_"
        }

        $psoCache = @{}
        try {
            $fineGrainedPolicies = Get-ADFineGrainedPasswordPolicy -Filter * -ErrorAction Stop @adSplat
            foreach ($pso in $fineGrainedPolicies) {
                $psoCache[$pso.DistinguishedName] = $pso
            }
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Loaded $($psoCache.Count) Fine-Grained Password Policies"
        }
        catch {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] No Fine-Grained Password Policies found or access denied: $_"
        }
    }

    process {
        $adProperties = @(
            'PasswordLastSet'
            'PasswordExpired'
            'PasswordNeverExpires'
            'PasswordNotRequired'
            'CannotChangePassword'
            'Enabled'
            'Description'
            'DistinguishedName'
            'msDS-ResultantPSO'
        )

        $searchSplat = @{
            Filter      = "Enabled -eq `$true"
            Properties  = $adProperties
            ErrorAction = 'Stop'
        }
        if ($PSBoundParameters.ContainsKey('SearchBase')) {
            $searchSplat['SearchBase'] = $SearchBase
        }

        try {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Querying enabled users"
            $users = Get-ADUser @searchSplat @adSplat
        }
        catch {
            Write-Error -Message "[$($MyInvocation.MyCommand)] Failed to query users: $_"
            return
        }

        if (-not $users) {
            Write-Warning -Message "[$($MyInvocation.MyCommand)] No enabled user accounts found"
            return
        }

        $now = Get-Date
        $queryTimestamp = $now.ToString('o')
        $results = [System.Collections.Generic.List[PSCustomObject]]::new()

        foreach ($user in $users) {
            $isExpired = $user.PasswordExpired
            $neverExpires = $user.PasswordNeverExpires
            $mustChange = ($null -eq $user.PasswordLastSet)

            if ($ProblemsOnly) {
                if (-not ($isExpired -or $neverExpires -or $mustChange)) {
                    continue
                }
            }

            # Resolve applied password policy
            $policyName = 'Default Domain Policy'
            $maxAge = $defaultMaxAge
            $psoDN = $user.'msDS-ResultantPSO'

            if ($psoDN -and $psoCache.ContainsKey($psoDN)) {
                $appliedPSO = $psoCache[$psoDN]
                $policyName = $appliedPSO.Name
                $maxAge = $appliedPSO.MaxPasswordAge
            }
            elseif ($psoDN) {
                # PSO exists but was not in cache (permission issue) — extract name from DN
                $policyName = ($psoDN -split ',')[0] -replace '^CN='
            }

            # Calculate password age
            $passwordAge = $null
            if ($user.PasswordLastSet) {
                $passwordAge = [math]::Round(($now - $user.PasswordLastSet).TotalDays)
            }

            # Calculate expiry date and days until expiry
            $expiresOn = $null
            $daysUntilExpiry = $null

            if ($user.PasswordLastSet -and -not $neverExpires -and $maxAge -and $maxAge -gt [TimeSpan]::Zero) {
                $expiresOn = $user.PasswordLastSet + $maxAge
                $daysUntilExpiry = [math]::Round(($expiresOn - $now).TotalDays)
            }

            $maxAgeDays = $null
            if ($maxAge -and $maxAge -gt [TimeSpan]::Zero) {
                $maxAgeDays = [math]::Round($maxAge.TotalDays)
            }

            $entry = [PSCustomObject]@{
                PSTypeName           = 'PSWinOps.ADPasswordStatus'
                Name                 = $user.Name
                SamAccountName       = $user.SamAccountName
                Enabled              = $user.Enabled
                PasswordExpired      = $isExpired
                PasswordNeverExpires = $neverExpires
                MustChangePassword   = $mustChange
                PasswordNotRequired  = $user.PasswordNotRequired
                PasswordLastSet      = $user.PasswordLastSet
                PasswordAgeDays      = $passwordAge
                PasswordPolicy       = $policyName
                MaxPasswordAgeDays   = $maxAgeDays
                PasswordExpiresOn    = $expiresOn
                DaysUntilExpiry      = $daysUntilExpiry
                Description          = $user.Description
                DistinguishedName    = $user.DistinguishedName
                Timestamp            = $queryTimestamp
            }

            $results.Add($entry)
        }

        $results | Sort-Object -Property @{
            Expression = {
                if ($null -eq $_.PasswordAgeDays) { [int]::MaxValue } else { $_.PasswordAgeDays }
            }
            Descending = $true
        }

        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed - $($results.Count) account(s) returned"
    }
}