Log off Idle User with Quser and Powershell

I recently had a reason to need a solution to log off idle users from a workstation. “Group Policy!” was my first thought – but, no, the group policy idle logoff is only for Remote Desktop Sessions. Looking online, there were a lot of posts pointing this out – and decrying why Microsoft is so stupid as to not have this available. Its 2022 for crying out loud! I pieced together a script to log off idle users that actually works in a real company environment.

In my searching, however, I did find quser and powershell. You use quser, parse the output and log off idle users over an hour. Easy peasey, right? Nope. Not at all. For one thing, quser has some reality issues. ( I posted asking for help on sorting it out! )

The first thing about the quser output is how to properly put it in a good, powershell readable table. Some have suggested “convertfrom-string” – but that doesn’t handle the “Idle Time” and “Logon Time” very well, due to spaces.

The ‘original’ used :
Select-Object -Skip 1 | ForEach-Object {($_ -replace '\s{2,}', ',').Trim()} | ConvertFrom-Csv -Header $Header |

But that sometimes had issues as well, because if another user is logged on (switch user), the Session is empty, so you have a missing column and everything after ‘session’ gets shifted left by one.

Olaf (on the reality issues link above) helped me see how to use substring() to get what will work most, if not all, of the time. I haven’t seen an issue for it. I guess I may have been just too lazy to count it out. Either, way, it works.

The next thing is that “Idle Time” outputs a lot of different things – it can be ‘none’ or a ‘.’ or a measure of time ‘5’ minutes, ’14:54′ hours and minutes, or ‘1+13:33’ days plus hours and minutes. So, you have to, in a real-world situation, make allowances for such output and do your best to If/Then for all iterations.

The next thing is, that in Windows 10 and below, “Idle Time” doesn’t seem to actually be the individual user’s Idle Time. In testing, it seems more like ‘the time since any user last had a log on event.’
UserName SessionName ID State IdleTime
-------- ----------- -- ----- --------
bkearan console 1 Active 1+03:57
bktest 2 Disc 1+03:57

See how both users have the same ‘Idle Time?’ Even the Active user has an idle time. Now, in Windows 11, it appears to be closer to the user’s idle time (at least on my home, pc – my company laptop is 11, but has the same issue as 10) – but corporate environments haven’t all got to Windows 11. Some places still have Windows 7, but I digress. In Windows 10, you can’t use only idle time to log off a user – as it will log off an active user. … Yes, I got logged off as I was testing this theory. I clicked run in PowerShell ISE and got logged off immediately.

Now for the final kicker – a user is “Active” as long as they are the primary account logged on. Yep, the last logged on user NEVER goes to an Idle state. So far, I haven’t found a way around this last issue. However, if another user logs on after an hour – the Idle Time counter resets and the first user won’t be logged off for another hour.

I have the script set to run as a scheduled task on boot of the computers that need to log off users (so as not to get bogged down with multiple user sessions).

And I just added the Days as I was writing this. Just so much to parse! My Windows 11 laptop has been running the script for 3 days, 4 hours and 41 minutes – and I haven’t been near the laptop for two days, but my logged on user is still showing “Active.”

Then, I started looking for how to get the actual Idle time of the whole system. Now, I have added, as I write this, code to detect the time the system has not received any input whatsoever. So, here is the code that will log off ‘disconnected’ users and even the Console user if the whole system has been idle for over an hour: — and of course that doesn’t work. The Idle Console piece doesn’t get any input when tracking as the SYSTEM account – so logs off everything after an hour – even new logons. So I had to break it up into TWO scripts. The first script (below) is the one to run as a scheduled task on startup, run as SYSTEM.

#
# Complied from pieces of code and additions by Bobby Kearan with inspiration from
# https://www.reddit.com/r/PowerShell/comments/8r56tr/getting_idle_time_for_logged_on_domain_users/
# and Thanks to Olaf (https://forums.powershell.org/u/Olaf) for helping with parsing the output of quser properly


function Get-QUserInfo
    {
    [CmdletBinding()]
    Param (
        [Parameter ()]
            [string[]]
            $ComputerName = $env:COMPUTERNAME
        )

    begin
        {
        $Header = 'UserName','SessionName','ID','State','IdleTime','LogonTime'
        $No_Connection = '-- No Connection --'
        }

    process
        {
        foreach ($CN_Item in $ComputerName)
            {
            if (Test-Connection -ComputerName $CN_Item -Count 1 -Quiet)
                {
                $QuserOutput = quser /server:$CN_Item
                $QuserOutput -split "`n" | Select-Object -Skip 3
                $QuserOutput | Select-Object -Skip 1 |
                    ForEach-Object {
                    
						$IdleTime = $_.Substring(54, 11).trim()
						#Write-Host $IdleTime
						If (($IdleTime -eq 'none') -or ($IdleTime -eq '.') -or ($IdleTime -eq '$Null')) {
						$IdleTime = "Not Idle"
						}
						#Write-Host "Testing Idle Time " $IdleTime "for the + sign"
						IF ($IdleTime -like '*+*') {
                        $IdleTime = $IdleTime.replace("+",":")
                        #Write-Host $IdleTime " was the Plus sign replaced + ? "
						}
              
					If ($IdleTime -as [DateTime]) {
                        If ($IdleTime -match "\d\d\:\d\d"){
							#$IdleTime = [timespan]$IdleTime
							$Idleness = $IdleTime -split ":"
                        #Write-Host $Idleness.length
							If($Idleness.length -eq 2){
							$IdleTime = New-Object -TypeName PSObject
							$d = [ordered]@{Days=0;Hours=$Idleness[0];Minutes=$Idleness[1]}
							$IdleTime | Add-Member -NotePropertyMembers $d -TypeName Asset
							}
                        Else {
							$IdleTime = New-Object -TypeName PSObject
							$d = [ordered]@{Days=$Idleness[0];Hours=$Idleness[1];Minutes=$Idleness[2]}
							$IdleTime | Add-Member -NotePropertyMembers $d -TypeName Asset
							}
                        #Write-Host $Idleness[0] " = " $IdleTime.Hours
                        }
                        ELSE {
                          $IdleTime = New-TimeSpan -Start $IdleTime -End (Get-Date)
                          #Write-Host "IdleTime was a datetime-" $IdleTime
                          }
                       }
					   #Write-Host $IdleTime
						$final = $_.length - 65
						$Username = $_.Substring(1, 22).trim()
                    
						[PSCustomObject]@{
						UserName    = $_.Substring(1, 22).trim()
						SessionName = $_.Substring(23, 19).trim()
						ID          = $_.Substring(42, 3).trim()
						State       = $_.Substring(46, 8).trim()
						IdleTime    = $IdleTime
						LogonTime   = $_.Substring(65, $final).trim()
                        }
					}
                }
                else
                {
                [PSCustomObject]@{
                    ComputerName = $CN_Item
                    UserName = $No_Connection
                    SessionName = $No_Connection
                    ID = $No_Connection
                    State = $No_Connection
                    IdleTime = $No_Connection
                    LogonTime = $No_Connection
                    }                
                }
            } # end >> foreach ($CN_Item in $ComputerName)
        } # end >> process {}

    end {}

    } # end >> function Get-QUserInfo

    #$Info = Get-QUserInfo -ComputerName localhost

    $Logfile = "c:\scripts\LogOff.log"
    $oldLogFile = "c:\scripts\Logoff.old.log"
    $LogMessage = ""
    #If log file exists, copy to old log and start a new one
    if (Test-Path $Logfile) {
        Copy-Item $Logfile $oldLogFile -Force
        Remove-Item $LogFile
    }
    #Initalize Log file
    $LogMessage = "Log Start: " + (Get-Date)
    $LogMessage >> $Logfile

    $state = "Active"
    While($state = "Active"){
        $Info = Get-QUserInfo -ComputerName LocalHost
        ForEach ($user in $Info) {
			$IdleHou = $user.IdleTime.Hours
			$IdleMin = $user.IdleTime.Minutes
			$IdleDays = $user.IdleTime.Days
			#Write-Host $user.State " User " $user.UserName " has been idle " $IdleDays " Days " $IdleHou " hours, " $IdleMin " Minutes - Logged On Time =" $user.IdleTime
			If (($user.State.Trim() -ne "Active") -and ($IdleHou -ge 1)) {
				#Write-Host $user.UserName "Should be logged off"
				logoff $user.ID
                $LogMessage = Get-Date
                $LogMessage >> $Logfile
                $LogMessage = $user.State + " User " + $user.UserName + " has been idle " + $IdleDays + " Days " + $IdleHou + " hours, " + $IdleMin + " Minutes - Logged On Time =" + $user.IdleTime
                $LogMessage >> $Logfile
			}
			
        }
        $logsize = ((Get-Item $LogFile).length/1MB)
        if ($logsize -gt 5) {
            Copy-Item $Logfile $oldLogFile -Force
            Remove-Item $LogFile
            $LogMessage = Get-Date
            $LogMessage >> $Logfile
        }
        Sleep -Seconds 260
    }
    
    

I did add a couple things after posting. That would be adding in self-cleaning Logging. Now, the second one, runs as a link from the Startup folder for all users. ( C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp )

#
# Written by Bobby Kearan with inspiration and code from:
# https://stackoverflow.com/questions/15845508/get-idle-time-of-machine
#

Add-Type @'
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace PInvoke.Win32 {

    public static class UserInput {

        [DllImport("user32.dll", SetLastError=false)]
        private static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);

        [StructLayout(LayoutKind.Sequential)]
        private struct LASTINPUTINFO {
            public uint cbSize;
            public int dwTime;
        }

        public static DateTime LastInput {
            get {
                DateTime bootTime = DateTime.UtcNow.AddMilliseconds(-Environment.TickCount);
                DateTime lastInput = bootTime.AddMilliseconds(LastInputTicks);
                return lastInput;
            }
        }

        public static TimeSpan IdleTime {
            get {
                return DateTime.UtcNow.Subtract(LastInput);
            }
        }

        public static int LastInputTicks {
            get {
                LASTINPUTINFO lii = new LASTINPUTINFO();
                lii.cbSize = (uint)Marshal.SizeOf(typeof(LASTINPUTINFO));
                GetLastInputInfo(ref lii);
                return lii.dwTime;
            }
        }
    }
}
'@

    $SysIdle = @('00','00')
    $Logfile = "c:\scripts\LogOff-console.log"
    $oldLogFile = "c:\scripts\Logoff-console.old.log"
    $LogMessage = ""
    #If log file exists, copy to old log and start a new one
    if (Test-Path $Logfile) {
        Copy-Item $Logfile $oldLogFile -Force
        Remove-Item $LogFile
    }
    #Initalize Log file
    $LogMessage = "Log Start: " + (Get-Date)
    $LogMessage >> $Logfile
       
    $state = "Active"

    While($state = "Active"){
	$SysIdle = [PInvoke.Win32.UserInput]::IdleTime
	$SysIdle = $SysIdle -split ":"
	#Write-Host $SysIdle[0] " " $SysIdle[1]
			
	If([int]$SysIdle[0] -ge 1){
		#Write-Host "System has been Idle for over an hour. Log everyone off"
		logoff $user.ID
        $LogMessage = Get-Date
        $LogMessage >> $Logfile
        $LogMessage = $user.State + " User " + $user.UserName + " has been idle " + $SysIdle[0] + " hours and " + $SysIdle[1] + " minutes."
        $LogMessage >> $Logfile
	    }
        Else {
        $SysIdle = @('00','00')
        }
        $logsize = ((Get-Item $LogFile).length/1MB)
        if ($logsize -gt 5) {
            Copy-Item $Logfile $oldLogFile -Force
            Remove-Item $LogFile
            $LogMessage = Get-Date
            $LogMessage >> $Logfile
            }
        Sleep -Seconds 260
    }

Leave a Reply

Your email address will not be published.

*
*