Getting Last Logged on User from a List of Workstations

I am trying to create a PowerShell script that will read a text file of workstations on my domain, get the last logged on user and write it to a CSV file. This is the code I am using:

$computers = Get-Content 'C:\Scripts\ADscripts\pcList.txt'
    $folderlocation = Read-Host "C:\Scripts\ADscripts"
    $path = $folderlocation + 'C:\Scripts\ADScripts\computers.csv'

$array = @()

foreach($computer in $computers)
{
$scan = Get-ChildItem "\\$computer\c$\Users" | Sort-Object LastWriteTime -Descending | Select-Object Name, LastWriteTime -first 1

$array += New-Object PSObject -Property @{
                                            "Computer" = $computer
                                            "User" = $scan.Name
                                            "Last Modified" = $scan.LastWriteTime
                                            }
}
$array | Export-Csv $path

When I try running, simply nothing happens. No errors. The CSV file does not populate with any data. I am a domain admin

Any clue what I am doing wrong?
Thanks in advance

can you edit your post and use the Preformatted Text option so that your code is formatted?
You may have to hit the gear icon and find ā€œPreformatted Textā€ there.

Sorry, done
Thanks for the heads up

Thanks for doing that.
First thing, Iā€™m confused by this section:

$computers = Get-Content 'C:\Scripts\ADscripts\pcList.txt'
$folderlocation = Read-Host "C:\Scripts\ADscripts"
$path = $folderlocation + 'C:\Scripts\ADScripts\computers.csv'

Getting the content of a text file for the list of computers makes sense, but why is $FolderLocation a read-host prompt with prompt text of a fodler?
Then whatever the user inputs at that prompt gets added to the beginning of a filepath for $Path which effectively ruins that path:
image
I would just change it to this for now:

$computers = Get-Content 'C:\Scripts\ADscripts\pcList.txt'
$path = 'C:\Scripts\ADScripts\computers.csv'

Then it looks like your method for determining last logged on user may have come from here:

That is unfortunately not an accurate way to determine last logged on user. The userā€™s profile folder does not update LastWriteTime every time they log in. For instance, on my work computer that ā€œLastWriteTimeā€ is March sometime, but Iā€™m logged in right now.

Itā€™s an intersting problem though, and one I donā€™t have an immediate solution for. Searching around yields mixed results, but Event Viewer might hold more accurate info.
Iā€™m going to play around with this for a couple minutes and see if I can come up with a better filter:

Get-WinEvent -FilterHashtable @{Logname='Security';ID=4672} | Where-Object {-not(($_.Properties[0].Value -like  "S-1-5-18") -or ($_.Properties[0].Value -like  "S-1-5-19") -or ($_.Properties[0].Value -like  "S-1-5-20"))}

alright hereā€™s what iā€™ve got

# get our list of computers and specify where to save the output
$computers = Get-Content 'C:\Scripts\ADscripts\pcList.txt'
$path = 'C:\Scripts\ADScripts\computers.csv'

# define an XML filter for use with Get-WinEvent.  
# this gets all events from the Security log of event ID 4672 (logon) and filters out hits for some built-in accounts (identified by SID) and the Windows Manager
$Query = @"
<QueryList>
<Query Id="0" Path="Security">
  <Select Path="Security">
    *[System[(EventID=4672)]]
     and
    *[EventData[Data[@Name='SubjectUserSid'] !='S-1-5-18' and Data[@Name='SubjectUserSid'] !='S-1-5-19' and Data[@Name='SubjectUserSid'] !='S-1-5-20' and Data[@Name='SubjectDomainName'] != "Window Manager"]]
    </Select>
</Query>
</QueryList>
"@

# instead of creating an array and using the += syntax to tear down the array and rebuild it with each new object, we're just spitting out our objects right in to the $Results variable which becomes an array.
$Results = Foreach ($Computer in $Computers) {
# try/catch block because doing anything remotely against a computer has the potential to fail
    try {
# only getting a max of 5 events because there can be a LOT of logs that match this. 
        $Events = Get-WinEvent -ComputerName $Computer -FilterXml $Query -MaxEvents 5 -ErrorAction Stop
        Foreach ($Event in $Events) {
            [XML]$XMLEvent = $Event.ToXml()
            [PSCustomObject]@{
                TimeCreated = $Event.TimeCreated
                UserName    = $XMLEvent.Event.SelectSingleNode("//*[@Name='SubjectUserName']").'#text'
                Domain      = $XMLEvent.Event.SelectSingleNode("//*[@Name='SubjectDomainName']").'#text'
                SID         = $XMLEvent.Event.SelectSingleNode("//*[@Name='SubjectUserSid']").'#text'
            }
        }
    } catch {
        Write-Warning "Failed to get Windows Events for $Computer"
        continue
    }
}
# checking to make sure we actually have some results before outputting
if ($Results) {
# added the NoTypeInformation switch to the Export so our first row isn't information about the objects
    $Results | Export-Csv $Path -NoTypeInformation
}

only tested against my current computer and one other computer. In both cases it does not reflect the last person to log in to the graphical desktop environment, but rather the last person to ā€œlogonā€ which includes authenticating remotely for Powershell.

Yes, I grabbed this from the site. You would be surprised how little googling yielded on this exact process. Seems like it would be a pretty standard Script that many people would use/need.

Hmmm, I wonder if there is a way to parse the Powershell Authentication. I really appreciate you checking on this!

Sadly, I dont have time to play with this much. Maybe one of you can make use of something like this?

$logfile = 'Microsoft-Windows-Winlogon/Operational'
Get-WinEvent -ComputerName $Computer -LogName $logFile | Where-Object{$_.ID -eq '812' -And $_.Message -Match 'SessionEnv'} | Sort-Object -Property 'TimeCreated' | Select-Object -Last 1

The values returned dont quit match what you see in Event Viewer which shows the user and domain. Not sure if you can get that but you could translate the SID to the username??

What Iā€™ve seen people do thatā€™s more reliable is leverage a Logon script to write to a file who the last user was to logon.
Since the logon script is ran by whoever the user is that logged on you can use $ENV:USERNAME to capture who that is and either write it to a file locally, or set up a network share for logs like this.
One example would be: have the logon script log to a file on a network share named for the user (i.e. ā€˜j.smith.logā€™). Have it write a row with a timestamp, computername, whatever else you want.
Then at the same time have the logon script log to a file named after the computer (i.e. ā€˜workstation1504.logā€™) where it writes a row with the timestamp, username, and whatever else you want.
With this in place you could then read those text files at will and find out when a user logged in to a particular computer, or what computers a user has logged in to.

this function I found here is pretty nice:

Itā€™s essentially looking at and filtering the Security Event log as well but the output data is much more enriched. The extra properties give you something to filter on:
image
running it against the same computer I tested my own code against I can see yesterday at 2:26pm when I Remote Desktopā€™d into that machine there are an ā€œInteractiveā€ and a ā€œRemoteInteractiveā€ logintype recorded for that user account. If we pre-filtered out all the System stuff and window manager stuff, we could then just focus on interactive logins and it might be more accurate.

I think you can more accurately get the last user by examining the lastload timestamp of the user profiles this way.

$UserProfiles = Get-Ciminstance -ClassNAme Win32_UserProfile -Filter "LocalPath like '%Users%'" | Sort-Object -Property LastUseTime
$Name = $UserProfiles[0].LocalPath | Split-Path -Leaf
Get-CimInstance -ClassName Win32_UserAccount -Filter "Name like '$Name'"

Well, I am doing this to figure out what workstation name each of my users is using. I just started here and they donā€™t have a real inventory, so I have been tapped to gather this information

Maybe I should just figure out a way to get the profile names on each pc instead?

Nice! I am going to try this out

fair warning, one of the forums i looked at stated that the Win32_UserProfile property for LastUseTime could get updated by other things other then a user actually logging in. It may not be 100% accurate.

Hereā€™s a quick function that combines some of the filtering I was doing with that function I linked to earlier:

Function Get-LastLoggedInUser {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory=$false)]
        [string]$ComputerName = $env:COMPUTERNAME
    )

    $Filter = @"
<QueryList>
    <Query Id="0" Path="Security">
        <Select Path="Security">
        *[System[(EventID=4624)]]
        and
            *[EventData[
            Data[@Name='TargetUserName'] != 'SYSTEM' and
            Data[@Name='TargetUserName'] != '$($ComputerName)$' and
            Data[@Name='TargetDomainName'] != "Window Manager" and
            Data[@Name='TargetDomainName'] != "Font Driver Host" and
            (Data[@Name='LogonType'] = 2 or Data[@Name='LogonType'] = 11) and
            Data[@Name='SubjectDomainName'] != "Window Manager"
        ]
    ]
        </Select>
    </Query>
</QueryList>
"@

    try {
        $Events = Get-WinEvent -ComputerName $ComputerName -FilterXml $Filter -MaxEvents 15 -ErrorAction Stop
        Foreach ($Event in $Events) {
            [XML]$XMLEvent = $Event.ToXml()
            [PSCustomObject]@{
                TimeCreated = $Event.TimeCreated
                UserName    = $XMLEvent.Event.SelectSingleNode("//*[@Name='TargetUserName']").'#text'
                Domain      = $XMLEvent.Event.SelectSingleNode("//*[@Name='TargetDomainName']").'#text'
                SID         = $XMLEvent.Event.SelectSingleNode("//*[@Name='TargetUserSid']").'#text'
            }
        }
    } catch {
        Write-Warning "Failed to get Windows Events for $ComputerName"
        continue
    }
}

When I run this against the remote PC I was using for testing I get this for output:
image
Itā€™s not very fast, but that is and accurate result for that PC. And this is coming from the Security log on the machine.
Caveats to this: on my own machine there are no results returned and this is because the Event ID 4624 correlating to my login this morning is already gone due to log turnover. If you were inspecting a machine that last had a user login days ago, this might happen to use as well.

The Get-CimInstance example from @neemobeer when ran against my same test PC does not yield the same result. In fact, when ran against my local PC, none of the timestamps returned for LastUseTime are even today, which means itā€™s not accurately correlating to the last logged in user. According to the LastUsetime field my last logged in user is c:\Users\Administrator, which is an account that doesnā€™t even exist on my machine.

I like this one a lot. But, I wonder if there is a way to deliver the workstation name instead of the SID? Translating each individual SID-to-name for a company user-to-workstation name inventory project may be a bit much. Any ideas?

If you have the AD powershell module you can map the SID to the name as one option

the SID was just kind of ā€œextraā€. The function technically returns the correct domain name and username so you could just combine that with the Computer that you know you ran the function against and thereā€™s your user/device affinity.
Otherwise @neemobeer is right that Get-ADUser can take the SID as an argument and return the user from Active Directory

Hereā€™s an updated version of the function that may potentially translate the username from sid. It worked on my workgroup system but my limited testing in domain environment showed no matching events.

Function Get-LastLoggedInUser {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory=$false)]
        [string]$ComputerName
    )

    $Filter = @"
<QueryList>
    <Query Id="0" Path="Security">
        <Select Path="Security">
        *[System[(EventID=4624)]]
        and
            *[EventData[
            Data[@Name='TargetUserName'] != 'SYSTEM' and
            Data[@Name='TargetUserName'] != '$($ComputerName)$' and
            Data[@Name='TargetDomainName'] != "Window Manager" and
            Data[@Name='TargetDomainName'] != "Font Driver Host" and
            (Data[@Name='LogonType'] = 2 or Data[@Name='LogonType'] = 11) and
            Data[@Name='SubjectDomainName'] != "Window Manager"
        ]
    ]
        </Select>
    </Query>
</QueryList>
"@

    $params = @{
         FilterXml   = $Filter
         MaxEvents   = 15
         ErrorAction = 'Stop'
    }

    if($ComputerName){
        $params.Add('ComputerName',$ComputerName)
    }

    try {
        $Events = Get-WinEvent @params

        Foreach ($Event in $Events) {
            [XML]$XMLEvent = $Event.ToXml()
            $sid = $XMLEvent.Event.SelectSingleNode("//*[@Name='TargetUserSid']").'#text'
            $principal = [System.Security.Principal.SecurityIdentifier]::new($sid)
            $user = ($principal.Translate([System.Security.Principal.NTAccount]).Value -split '\\')[1]

            [PSCustomObject]@{
                TimeCreated = $Event.TimeCreated
                UserName    = $XMLEvent.Event.SelectSingleNode("//*[@Name='TargetUserName']").'#text'
                Domain      = $XMLEvent.Event.SelectSingleNode("//*[@Name='TargetDomainName']").'#text'
                SID         = $user
            }
        }
    } catch {
        Write-Warning $_.exception.message
        continue
    }
}
1 Like

Nice, so could I change a parameter to translate the workstation name to netbios name?

Thanks for your help on this. It works great

Your workstation names arenā€™t the same as the netbios name? Whatā€™s your current method of translating these now? DNS?