iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
IT Operation

系統工程師的 30 天自動化維運實戰:PowerShell × AD × Windows Server系列 第 14

Day 14|AD 帳號盤點:找出長期未登入與從未登入的 User

  • 分享至 

  • xImage
  •  

Day 13 我們正式開始用 PowerShell 查詢 Active Directory。

目前已經可以取得:

Get-ADUser
Get-ADComputer
Get-ADOrganizationalUnit

也開始把 AD 裡的:

Name
SamAccountName
Enabled
LastLogonDate
DistinguishedName

整理成 CSV。

到了 Day 14,我們開始處理一個企業 IT 很常遇到的需求:

哪些 AD 帳號已經很久沒有登入了?

這個需求看起來很簡單。

例如:

超過 180 天沒登入
→ 找出來

但真的開始做之後,很快會遇到很多問題:

LastLogonDate 到底準不準?

空白代表什麼?

從未登入跟很久沒登入一樣嗎?

Service Account 怎麼辦?

找到之後可以直接 Disable 嗎?

所以今天不會直接做:

Disable-ADAccount

而是先完成一套比較安全的:

Inactive Account Review Report

也就是:

AD

找出疑似閒置帳號

分類

輸出 CSV

人工確認
先定義什麼叫 Inactive

如果沒有先定義標準,「長期未登入」其實沒有意義。

今天先用一個簡單的範例:

超過 180 天沒有登入

Inactive

PowerShell 可以先算出 180 天前:

$CutoffDate = (Get-Date).AddDays(-180)

查看:

$CutoffDate

假設今天執行,可能得到:

2026/03/26 ...

意思就是:

最後登入時間早於這個日期,就列入 Review。

正式環境裡,180 天只是範例。

公司可能使用:

90 Days
180 Days
365 Days

實際標準應該跟公司的帳號生命週期、稽核與資訊安全規範一致。

先把 Enabled User 抓出來

如果帳號本來就已經:

Enabled = False

通常它跟「目前仍啟用但長期沒使用」是不同的盤點議題。

所以今天先鎖定:

Get-ADUser `
-Filter 'Enabled -eq $true'

接著取得我們需要的 Property:

$Users = Get-ADUser -Filter 'Enabled -eq $true'
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet

現在:

$Users.Count

就可以知道目前查到多少個 Enabled User。

先看看 LastLogonDate

例如:

$Users |
Select-Object `
Name,
SamAccountName,
Enabled,
LastLogonDate

可能得到:

Name SamAccountName Enabled LastLogonDate


User A usera True 2026/09/20 08:31
User B userb True 2026/02/10 13:22
User C userc True
User D userd True 2025/11/05 09:10

這裡立刻出現兩種值得注意的帳號。

第一種:

User B
User D

LastLogonDate 很久以前

第二種:

User C

LastLogonDate 空白

這兩種情況最好分開處理。

LastLogonDate 到底是什麼?

在使用:

Get-ADUser -Properties LastLogonDate

時,LastLogonDate 是 PowerShell Active Directory Module 提供的一個比較容易閱讀的屬性表示。

它主要是根據:

lastLogonTimestamp

轉換成 DateTime。

也就是說,它適合拿來做:

大範圍的 Inactive Account 盤點。

但它不是「精確到最後一次登入那一刻」的欄位。

AD 裡跟登入有關的三個名稱很容易搞混

這邊最好先釐清。

屬性 特性 適合用途
lastLogon 每台 DC 各自保存,不複寫 需要較精確的最後登入時間
lastLogonTimestamp 會在 DC 間複寫,但更新不是即時 Inactive Account 稽核
LastLogonDate PowerShell 對 lastLogonTimestamp 的易讀表示 報表、盤點

其中:

lastLogon

如果 Domain 有:

DC01
DC02
DC03

某個 User 最後一次登入 DC02:

DC01 lastLogon = 舊資料
DC02 lastLogon = 最新
DC03 lastLogon = 舊資料

因為 lastLogon 不會在 DC 之間複寫。

如果真的要精確查:

User 最後一次到底在哪個時間登入?

就需要查詢所有 DC,再取最大的 lastLogon。

今天的目標不是做到這麼精細。

今天要做的是:

定期找出可能長期沒使用的帳號。

所以使用 LastLogonDate 比較符合需求。

LastLogonDate 不是即時值

這一點一定要注意。

lastLogonTimestamp 的設計目的,本來就比較偏:

找長期未使用帳號

而不是:

確認 User 10 分鐘前有沒有登入

它的複寫更新可能有數天的延遲;在預設 AD 行為下,常見會落在大約 9~14 天的更新區間。

所以:

LastLogonDate = 2026/09/01

不一定代表:

這個人從 9/1 後完全沒登入過。

這也是為什麼:

7 天未登入

這種非常短的條件,不適合單純靠 LastLogonDate 做精確判定。

但如果是:

180 天
365 天

這種長期 Inactive Account 盤點,這個時間差通常就比較可以接受。

找出 180 天以前登入的帳號

現在有:

$CutoffDate = (Get-Date).AddDays(-180)

就可以:

$InactiveUsers = $Users |
Where-Object {

$_.LastLogonDate -ne $null -and
$_.LastLogonDate -lt $CutoffDate

}

這裡特別加:

$_.LastLogonDate -ne $null

因為我們目前只想找:

有登入紀錄,但很久沒有登入。

而不是把從未登入的帳號混進來。

結果可以:

$InactiveUsers |
Select-Object `
Name,
SamAccountName,
LastLogonDate

例如:

Name SamAccountName LastLogonDate


User B userb 2026/02/10
User D userd 2025/11/05
再找「從未登入」的帳號

接著處理:

LastLogonDate = 空白

可以:

$NeverLoggedOnUsers = $Users |
Where-Object {

$null -eq $_.LastLogonDate

}

例如:

$NeverLoggedOnUsers |
Select-Object `
Name,
SamAccountName,
Created,
Enabled

可能:

Name SamAccountName Created Enabled


User C userc 2026/01/10 True
NewUser01 newuser01 2026/09/20 True

但現在馬上出現新的問題。

從未登入,不代表一定是垃圾帳號

上面的:

User C
Created = 2026/01/10

已經建立很久,卻從未登入。

確實值得確認。

但:

NewUser01
Created = 2026/09/20

可能昨天才建立。

今天沒有登入非常正常。

所以「從未登入」最好再搭配:

Created

一起判斷。

例如:

建立超過 30 天,而且從未登入。

可以:

$NewAccountCutoff =
(Get-Date).AddDays(-30)

接著:

$NeverLoggedOnUsers = $Users |
Where-Object {

$null -eq $_.LastLogonDate -and
$_.Created -lt $NewAccountCutoff

}

這樣就不會把昨天剛建立的新進人員帳號直接列成異常。

把兩種類型分清楚

現在可以把帳號分成:

Inactive
→ 曾經登入過
→ 但最後登入早於 Cutoff Date

NeverLoggedOn
→ 沒有 LastLogonDate
→ 而且建立超過一定天數

這比全部叫:

Inactive

更容易 Review。

因為後續確認方式可能不一樣。

建立 ReviewReason

我們可以替每一筆資料增加:

ReviewReason

例如 Inactive:

$InactiveReport = $InactiveUsers |
Select-Object `
Name,
SamAccountName,
Enabled,
Department,
Description,
Created,
LastLogonDate,
PasswordLastSet,
@{Name="ReviewReason"; Expression={
"LastLogonDate older than 180 days"
}},
DistinguishedName

Never Logged On:

$NeverLogonReport = $NeverLoggedOnUsers |
Select-Object `
Name,
SamAccountName,
Enabled,
Department,
Description,
Created,
LastLogonDate,
PasswordLastSet,
@{Name="ReviewReason"; Expression={
"Never logged on and account older than 30 days"
}},
DistinguishedName

現在報表本身就會告訴你:

為什麼這個帳號被列出來?

最好再算「距離最後登入幾天」

比起只看:

2025/11/05

有時候:

321 Days

更容易理解。

可以建立:

@{
Name = "InactiveDays"

Expression = {

    if ($null -ne $_.LastLogonDate) {

        (
            (Get-Date) -
            $_.LastLogonDate
        ).Days

    }
    else {

        $null

    }

}

}

例如:

$InactiveReport = $InactiveUsers |
Select-Object `
Name,
SamAccountName,
Department,
LastLogonDate,
@{Name="InactiveDays"; Expression={
((Get-Date) - $_.LastLogonDate).Days
}}

結果:

Name SamAccountName LastLogonDate InactiveDays


User B userb 2026/02/10 223
User D userd 2025/11/05 320

這在 Review 時非常直覺。

OU 也應該放進報表

我們目前有:

DistinguishedName

例如:

CN=User A,OU=IT,OU=Taipei,DC=contoso,DC=com

但如果報表直接給主管看 DN,可能不太好讀。

今天可以先保留:

DistinguishedName

因為它非常適合後續工程師確認。

例如可以知道:

這是一般 User OU?
Service Account OU?
Admin OU?
Disabled OU?

後面我們再做:

從 DistinguishedName 自動取得第一層 OU。

為什麼 OU 很重要?

假設找出:

svc_backup
LastLogonDate = 300 天以前

如果看到:

OU=ServiceAccounts

就要非常小心。

因為這可能不是給人互動登入使用的帳號。

Service Account 有可能:

不需要 Interactive Logon
拿來跑 Scheduled Task
執行 Windows Service
Application Authentication

所以:

LastLogonDate 很久

不代表:

這個 Service Account 沒有人用。

這是 AD Inactive Account 清理裡很常見的誤判。

可以排除特定 OU

假設公司已經有:

OU=ServiceAccounts

第一版可以先排除。

例如:

$Users = Get-ADUser -Filter 'Enabled -eq $true'
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet |
Where-Object {

$_.DistinguishedName -notlike `
    "*OU=ServiceAccounts,*"

}

也可以排除:

OU=Admins
OU=SystemAccounts

不過這種條件最好依公司的 AD 架構調整。

不要直接照範例拿去 Production。

更好的方式:指定 SearchBase

如果公司的正常 User 都放在:

OU=Users,
OU=Taipei,
DC=contoso,
DC=com

與其:

查全 Domain

再排除 Service Account

我反而比較喜歡:

一開始就只查 User OU

例如:

$SearchBase =
"OU=Users,OU=Taipei,DC=contoso,DC=com"

然後:

$Users = Get-ADUser -Filter 'Enabled -eq $true'
-SearchBase $SearchBase -SearchScope Subtree
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet

這樣 Query 的範圍本身就比較精準。

Service Account 不一定只能靠 OU 判斷

真實環境可能沒有整理得那麼漂亮。

例如:

一般 User
Service Account
Admin Account

全部混在同一個 OU。

這時候可能需要靠:

命名規則
Description
Group
EmployeeID
Account Type
CMDB / HR 資料

交叉判斷。

例如公司 Service Account 都是:

svc_

開頭。

可以:

Where-Object {

$_.SamAccountName -notlike "svc_*"

}

但這種規則一定要先確認公司真的有遵循命名規範。

不能看到:

svc_

就當成全世界通用標準。

找到帳號後可以直接 Disable 嗎?

我不建議。

例如今天找到:

user01
InactiveDays = 250

還需要確認很多事情。

可能:

人在留職停薪
長期海外派駐
特殊主管帳號
共用帳號
應用程式帳號
備援帳號
離職流程漏處理

所以今天的 Script 只做到:

Query

Review Report

而不是:

Query

Disable

我希望這個系列後面即使真的做到帳號停用,也要變成:

Query

Report

Review / Approval

Action

Log

而不是全自動亂停。

建立完整 Review Report

現在把兩種帳號合在一起。

首先:

$InactiveDays = 180

$NewAccountGraceDays = 30

$CutoffDate =
(Get-Date).AddDays(-$InactiveDays)

$NewAccountCutoff =
(Get-Date).AddDays(-$NewAccountGraceDays)

取得 User:

$Users = Get-ADUser -Filter 'Enabled -eq $true'
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet
建立 Inactive Account Object
$InactiveUsers = $Users |
Where-Object {

$null -ne $_.LastLogonDate -and
$_.LastLogonDate -lt $CutoffDate

} |
ForEach-Object {

[PSCustomObject]@{

    Name =
        $_.Name

    SamAccountName =
        $_.SamAccountName

    Enabled =
        $_.Enabled

    Department =
        $_.Department

    Created =
        $_.Created

    LastLogonDate =
        $_.LastLogonDate

    InactiveDays =
        (
            (Get-Date) -
            $_.LastLogonDate
        ).Days

    PasswordLastSet =
        $_.PasswordLastSet

    ReviewType =
        "Inactive"

    ReviewReason =
        "Last logon older than $InactiveDays days"

    DistinguishedName =
        $_.DistinguishedName

}

}
建立 Never Logged On Object
$NeverLoggedOnUsers = $Users |
Where-Object {

$null -eq $_.LastLogonDate -and
$_.Created -lt $NewAccountCutoff

} |
ForEach-Object {

[PSCustomObject]@{

    Name =
        $_.Name

    SamAccountName =
        $_.SamAccountName

    Enabled =
        $_.Enabled

    Department =
        $_.Department

    Created =
        $_.Created

    LastLogonDate =
        $null

    InactiveDays =
        $null

    PasswordLastSet =
        $_.PasswordLastSet

    ReviewType =
        "NeverLoggedOn"

    ReviewReason =
        "Never logged on and account older than $NewAccountGraceDays days"

    DistinguishedName =
        $_.DistinguishedName

}

}
合併兩份資料

PowerShell 可以直接:

$ReviewUsers =
@($InactiveUsers) +
@($NeverLoggedOnUsers)

為什麼加:

@()

因為這樣即使結果只有一筆或零筆,後面操作會比較一致。

接著:

$ReviewUsers |
Sort-Object `
ReviewType,
InactiveDays -Descending

就會得到:

Inactive
Inactive
Inactive
NeverLoggedOn
NeverLoggedOn
輸出 CSV

建立:

$ReportFolder =
"C:\Temp\ADReports"

if (-not (Test-Path $ReportFolder)) {

New-Item `
    -Path $ReportFolder `
    -ItemType Directory |
    Out-Null

}

$Date =
Get-Date -Format "yyyyMMdd"

輸出:

$ReviewUsers |
Sort-Object ReviewType, InactiveDays -Descending | Export-Csv
-Path "$ReportFolder\Inactive_AD_Users_$Date.csv" -NoTypeInformation
-Encoding UTF8

最後:

C:\Temp\ADReports
└── Inactive_AD_Users_20260922.csv
報表會長什麼樣?

可能:

Name Account Type LastLogon InactiveDays Created
User A usera Inactive 2025/08/01 417 2022/05/01
User B userb Inactive 2026/01/15 250 2024/01/10
User C userc NeverLoggedOn 2025/11/01

這時候我們已經把原本:

幾千個 AD User

縮小成:

幾十個需要人工確認的 User

這才是 Automation 真正想做到的事情。

做一份 Summary

除了詳細報表,也可以產生簡單統計:

$Summary = [PSCustomObject]@{

CheckTime =
    Get-Date

InactiveThresholdDays =
    $InactiveDays

EnabledUsersChecked =
    @($Users).Count

InactiveUsers =
    @($InactiveUsers).Count

NeverLoggedOnUsers =
    @($NeverLoggedOnUsers).Count

TotalReviewUsers =
    @($ReviewUsers).Count

}

例如:

CheckTime : 2026/09/22
InactiveThresholdDays : 180
EnabledUsersChecked : 1850
InactiveUsers : 73
NeverLoggedOnUsers : 12
TotalReviewUsers : 85

主管如果只是想知道:

這次盤點有多少筆?

就不需要先打開 85 筆資料慢慢數。

把 Summary 也輸出
$Summary |
Export-Csv -Path "$ReportFolder\Inactive_AD_Summary_$Date.csv"
-NoTypeInformation `
-Encoding UTF8

最後:

ADReports

├── Inactive_AD_Users_20260922.csv
└── Inactive_AD_Summary_20260922.csv

一份看 Summary。

一份做 Review。

今天完整 Script

把 Day 14 主要內容整合起來:

===========================================

AD Inactive Account Review

Day 14

===========================================

Import-Module ActiveDirectory

===========================================

Configuration

===========================================

$InactiveDays = 180

$NewAccountGraceDays = 30

$ReportFolder =
"C:\Temp\ADReports"

$Date =
Get-Date -Format "yyyyMMdd"

$CutoffDate =
(Get-Date).AddDays(
-$InactiveDays
)

$NewAccountCutoff =
(Get-Date).AddDays(
-$NewAccountGraceDays
)

===========================================

Report Folder

===========================================

if (-not (Test-Path $ReportFolder)) {

New-Item `
    -Path $ReportFolder `
    -ItemType Directory |
    Out-Null

}

===========================================

Query Enabled Users

===========================================

$Users = Get-ADUser -Filter 'Enabled -eq $true'
-Properties `
LastLogonDate,
Department,
Description,
Created,
PasswordLastSet

===========================================

Inactive Users

===========================================

$InactiveUsers = $Users |
Where-Object {

$null -ne $_.LastLogonDate -and
$_.LastLogonDate -lt $CutoffDate

} |
ForEach-Object {

[PSCustomObject]@{

    Name =
        $_.Name

    SamAccountName =
        $_.SamAccountName

    Enabled =
        $_.Enabled

    Department =
        $_.Department

    Description =
        $_.Description

    Created =
        $_.Created

    LastLogonDate =
        $_.LastLogonDate

    InactiveDays =
        (
            (Get-Date) -
            $_.LastLogonDate
        ).Days

    PasswordLastSet =
        $_.PasswordLastSet

    ReviewType =
        "Inactive"

    ReviewReason =
        "Last logon older than $InactiveDays days"

    DistinguishedName =
        $_.DistinguishedName

}

}

===========================================

Never Logged On Users

===========================================

$NeverLoggedOnUsers = $Users |
Where-Object {

$null -eq $_.LastLogonDate -and
$_.Created -lt $NewAccountCutoff

} |
ForEach-Object {

[PSCustomObject]@{

    Name =
        $_.Name

    SamAccountName =
        $_.SamAccountName

    Enabled =
        $_.Enabled

    Department =
        $_.Department

    Description =
        $_.Description

    Created =
        $_.Created

    LastLogonDate =
        $null

    InactiveDays =
        $null

    PasswordLastSet =
        $_.PasswordLastSet

    ReviewType =
        "NeverLoggedOn"

    ReviewReason =
        "Never logged on and account older than $NewAccountGraceDays days"

    DistinguishedName =
        $_.DistinguishedName

}

}

===========================================

Merge Review Data

===========================================

$ReviewUsers =
@($InactiveUsers) +
@($NeverLoggedOnUsers)

===========================================

Summary

===========================================

$Summary = [PSCustomObject]@{

CheckTime =
    Get-Date

InactiveThresholdDays =
    $InactiveDays

EnabledUsersChecked =
    @($Users).Count

InactiveUsers =
    @($InactiveUsers).Count

NeverLoggedOnUsers =
    @($NeverLoggedOnUsers).Count

TotalReviewUsers =
    @($ReviewUsers).Count

}

===========================================

Export

===========================================

$ReviewUsers |
Sort-Object ReviewType, InactiveDays -Descending | Export-Csv
-Path "$ReportFolder\Inactive_AD_Users_$Date.csv" -NoTypeInformation
-Encoding UTF8

$Summary |
Export-Csv -Path "$ReportFolder\Inactive_AD_Summary_$Date.csv"
-NoTypeInformation `
-Encoding UTF8

===========================================

Display

===========================================

Write-Host ""
Write-Host "===== AD Inactive Account Review ====="
Write-Host ""

$Summary

Write-Host ""
Write-Host "Report:"
Write-Host "$ReportFolder\Inactive_AD_Users_$Date.csv"
這支 Script 還故意沒有做一件事情

你會發現整支程式完全沒有:

Disable-ADAccount

也沒有:

Remove-ADUser

這是故意的。

因為目前我們掌握的只是:

登入時間
帳號狀態
建立時間
OU
Description
Department

這些資料足夠:

找出值得 Review 的帳號。

但還不足以:

自動決定帳號可以被停用。

真正企業流程應該再多一層

我比較希望後面做到:

PowerShell 找出 Inactive Account

Review CSV

帳號負責人確認

主管 / IT 確認

Approved List

PowerShell Action

Log

也就是:

Detection 跟 Action 分開。

這個概念其實不只適用 AD。

前面 Server Health Check 也是一樣:

Disk > 90%

我們先:

Report / Alert

而不是看到:

Disk > 90%

就直接:

自動刪檔案

Automation 並不是:

看到異常就什麼都自動做。

真正重要的是:

哪些步驟適合自動、哪些步驟必須保留人工判斷。

Day 14 小結

今天第一次做出真正比較接近企業 AD 稽核的功能:

Enabled User

LastLogonDate

Cutoff Date

├── 曾登入但長期未登入

└── 從未登入且建立已久

Review Report

也開始釐清:

lastLogon
lastLogonTimestamp
LastLogonDate

不能全部當成同一回事。

今天最重要的幾個觀念是:

LastLogonDate 適合長期 Inactive Review
但不是精確的即時登入記錄。

LastLogonDate 空白
也不能直接等同垃圾帳號。

Service Account
不能只靠 User Logon 判斷使用狀態。

找到 Inactive
不等於可以直接 Disable。

所以目前我們做的是:

Inactive Account Detection。

而不是:

Inactive Account Deletion。

Day 15 預告
Day 15|AD Computer 物件盤點:找出長期未登入與可能已淘汰的電腦

今天處理的是:

User Account

但 Active Directory 裡另外一種非常容易累積的東西就是:

Computer Object

例如電腦:

已報廢
已重灌
換新機
離職設備
Hostname 改名
長期沒有連回公司網路

但 AD Object 一直留著。

所以 Day 15 我們會把今天的邏輯搬到:

Get-ADComputer

做:

Enabled Computer

LastLogonDate

Operating System

Computer Age

Inactive Days

OU

Review Report

最後產生:

Inactive AD Computer Review Report

並開始討論另一個實務問題:

「Computer 180 天沒登入,是不是就代表這台電腦可以從 AD 刪掉?」

答案一樣不會這麼簡單。


上一篇
Day 13|正式進入 Active Directory:用 PowerShell 查詢 User、Computer 與 OU
系列文
系統工程師的 30 天自動化維運實戰:PowerShell × AD × Windows Server14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言