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 Log-Message {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Message
    )

    $currentSize = 0
    if (Test-Path $Logfile) {
        $currentSize = (Get-Item $Logfile).length/1MB
    }

    if ($currentSize -gt 10) {
        if (Test-Path $oldLogFile) {
            Remove-Item $oldLogFile -Force
        }
        Move-Item $Logfile $oldLogFile -Force
    }

    Add-Content -Path $Logfile -Value ("[" + (Get-Date) + "] " + $Message)
}

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"
    #Initalize Log file
    Log-Message "Log Start: " + (Get-Date)
    

    $state = "Active"
    while ($state -eq "Active") {
    $Info = Get-QUserInfo -ComputerName LocalHost
    foreach ($user in $Info) {
        if ($user.State.Trim() -ne "Active" -and $user.IdleTime.Hours -ge 1) {
            logoff $user.ID
            Log-Message "$($user.State) User $($user.UserName) has been idle $($user.IdleTime.Days) Days $($user.IdleTime.Hours) hours, $($user.IdleTime.Minutes) Minutes - Logged On Time = $($user.IdleTime)"
        }
    }
    Start-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 scheduled task as well, but on logon of any user, and it runs in the console session.

# Written by Bobby Kearan with inspiration and code from: 
# https://www.reddit.com/r/PowerShell/comments/8r56tr/getting_idle_time_for_logged_on_domain_users/
# and, finally, 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;
            }
        }
    }
}
'@

function Log-Message {
    param (
        [Parameter(Mandatory = $true)]
        [string]$Message
    )

    $currentSize = 0
    if (Test-Path $Logfile) {
        $currentSize = (Get-Item $Logfile).length / 1MB
    }

    if ($currentSize -gt 5) {
        if (Test-Path $oldLogFile) {
            Remove-Item $oldLogFile -Force
        }
        Move-Item $Logfile $oldLogFile -Force
    }

    Add-Content -Path $Logfile -Value ("[" + (Get-Date) + "] " + $Message)
}

$Logfile = "c:\scripts\LogOff-console.log"
$oldLogFile = "c:\scripts\Logoff-console.old.log"

Log-Message "Log Start"

$state = "Active"

while ($state -eq "Active") {
    $idleTime = [PInvoke.Win32.UserInput]::IdleTime

    if ($idleTime.Hours -ge 1) {
        $sessionid = (Get-Process -PID $pid).SessionID
        Log-Message "$env:USERNAME has been idle for $($idleTime.Hours) hours and $($idleTime.Minutes) minutes."
        logoff $sessionid
    }

    Start-Sleep -Seconds 260
}

To assist with this, I wrote this powershell to create the scheduled tasks (mostly) – it launches task scheduler so you can check and verify.

# Check if the files are in C:\scripts
$scriptPath = "C:\scripts"
$files = @("Logoffonidle.ps1", "LogoffOnIdle_Console.ps1")

foreach ($file in $files) {
    if (-not (Test-Path "$scriptPath\$file")) {
        Write-Output "Error: $file is not present in $scriptPath"
        exit
    }
}


# Create or Update scheduled tasks
$TaskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -WakeToRun
$TaskSettings.ExecutionTimeLimit = 'PT0S'
$TaskSettings.Compatibility = 'win8'

$actionStartup = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $scriptPath\Logoffonidle.ps1"
$triggerStartup = New-ScheduledTaskTrigger -AtStartup

$taskNameStartup = "LogOffOnIdleAtStartup"
if (Get-ScheduledTask -TaskName $taskNameStartup -ErrorAction SilentlyContinue) {
    Set-ScheduledTask -TaskName $taskNameStartup -Action $actionStartup -Trigger $triggerStartup -Settings $TaskSettings
} else {
    Register-ScheduledTask -Action $actionStartup -Trigger $triggerStartup -TaskName $taskNameStartup -User "SYSTEM" -RunLevel Highest -Settings $TaskSettings
}

$actionIdle = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File $scriptPath\LogoffOnIdle_Console.ps1"
$triggerIdle = New-ScheduledTaskTrigger -AtLogOn

$taskNameIdle = "LogOffOnIdleWhenUserIdle"
$principalIdle = New-ScheduledTaskPrincipal -UserId "NT AUTHORITY\INTERACTIVE" -RunLevel Limited
if (Get-ScheduledTask -TaskName $taskNameIdle -ErrorAction SilentlyContinue) {
    Set-ScheduledTask -TaskName $taskNameIdle -Action $actionIdle -Trigger $triggerIdle -Principal $principalIdle -Settings $TaskSettings
} else {
    # Register for any user that logs on
    Register-ScheduledTask -Action $actionIdle -Trigger $triggerIdle -TaskName $taskNameIdle -Principal $principalIdle -Settings $TaskSettings
}

taskschd.msc

This should be working for both disconnected and console sessions now.

Run the script and make sure it creates both Scheduled Tasks.

Open the LogOffOnIdleAtStartup scheduled task and make sure it is set the Configure For: to the latest system there is available

Now open the LogOffOnIdleWhenUserIdle scheduled task and make sure it is set for: to the latest system there is – AND  the User is set to Interactive (or “NT AUTHORITY\INTERACTIVE”) and Run Only when user is logged on.

Edit each task to Uncheck the Stop the task if it runs more than 3 days.  We don’t want these tasks to stop.

Run task as soon as possible after scheduled start is missed.  Make the other show changes as needed:

The script creates a log of every logoff event it does in c:\scripts\logoff.log

If you need to keep a system alive when important tasks are running, add to the top section of LogoffIdle:

# Important processes that should prevent logging off
$KeepAliveTasks = @("robocopy", "ping", "Syncing")

Then, adjust the While loop:

while ($state -eq "Active") {
    $idleTime = [PInvoke.Win32.UserInput]::IdleTime

    $importantProcessRunning = Get-Process | Where-Object {
        $_.Name -in $KeepAliveTasks -or 
        $KeepAliveTasks -contains $_.MainWindowTitle
    }

    if ($idleTime.Hours -ge 1 -and (-not $importantProcessRunning)) {
        Log-Message "User $muser has been idle for $($idleTime.Hours) hours and $($idleTime.Minutes) minutes. Attempting logoff."
        
        # Trying a direct PowerShell approach for logoff
        $sessionid = (Get-Process -PID $pid).SessionID
        logoff $sessionid
    }

    Start-Sleep -Seconds 260
}