convert to gitea

This commit is contained in:
2025-09-15 13:39:33 +09:00
commit e6080ad87e
3 changed files with 293 additions and 0 deletions

BIN
ADLockUser.exe Normal file

Binary file not shown.

217
AccountUnlocker.ps1 Normal file
View File

@ -0,0 +1,217 @@
#requires -modules ActiveDirectory
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Xaml, System.Windows.Forms
# AD 모듈 로드
try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Host "[오류] AD 모듈 로드 실패"; exit 1 }
# AD 함수 정의
function Get-LockedADAccounts {
try {
$pdc = (Get-ADDomain).PDCEmulator
$adAccounts = @(Search-ADAccount -LockedOut -Server $pdc | Get-ADUser -Properties Name, SamAccountName, LockedOut, LastLogonDate, BadPwdCount, DisplayName -Server $pdc)
$list = @()
foreach ($account in $adAccounts) {
$lastLogon = if ($account.LastLogonDate -and $account.LastLogonDate.Year -ne 1601) { $account.LastLogonDate } else { $null }
$list += [PSCustomObject]@{
Name = if ([string]::IsNullOrWhiteSpace($account.DisplayName)) { $account.Name } else { $account.DisplayName }
SamAccountName = $account.SamAccountName
LockedOut = $account.LockedOut
LastLogonDate = $lastLogon
BadPwdCount = $account.BadPwdCount
}
}
return ,$list
} catch { Write-Log "AD 조회 오류: $($_.Exception.Message)" "Red"; return $null }
}
function Unlock-ADUserAccount { param([string]$SamAccountName)
try {
$pdc = (Get-ADDomain).PDCEmulator
Unlock-ADAccount -Identity $SamAccountName -Server $pdc
return $true
} catch { Write-Log "잠금 해제 실패: $SamAccountName - $($_.Exception.Message)" "Red"; return $false }
}
function Get-ADLockoutEvent { param([string]$SamAccountName)
$dcs = (Get-ADDomainController -Filter *).Name
$latest = $null
foreach ($dc in $dcs) {
try {
$filter = @{ LogName = 'Security'; ID = 4740; StartTime = (Get-Date).AddDays(-1) }
$event = Get-WinEvent -ComputerName $dc -FilterHashtable $filter -ErrorAction SilentlyContinue |
Where-Object { $_.Properties[0].Value -eq $SamAccountName } |
Sort-Object TimeCreated -Descending | Select-Object -First 1
if ($event -and ($null -eq $latest -or $event.TimeCreated -gt $latest.TimeCreated)) { $latest = $event }
} catch {}
}
if (-not $latest) { return $null }
$lockoutTime = $latest.TimeCreated
$sourceDC = $latest.MachineName
$caller = $latest.Properties[3].Value
if ([string]::IsNullOrWhiteSpace($caller) -or $caller -eq 'S-1-5-18' -or $caller -eq $sourceDC) {
try {
$filter4625 = @{ LogName='Security'; ID=4625; StartTime=$lockoutTime.AddMinutes(-5); EndTime=$lockoutTime }
$fail = Get-WinEvent -ComputerName $sourceDC -FilterHashtable $filter4625 -ErrorAction SilentlyContinue |
Where-Object { ($_.Properties[5].Value -like "*$SamAccountName*") -and ($_.Properties[10].Value -eq '3' -or $_.Properties[10].Value -eq '10') } |
Sort-Object TimeCreated -Descending | Select-Object -First 1
if ($fail) {
$ws = $fail.Properties[13].Value
$ip = $fail.Properties[19].Value
$caller = if ($ws -and $ws -ne '-') { "$ws (IP: $ip)" } else { "IP: $ip" }
} else { $caller = "$sourceDC (자체 발생 - 4625 없음)" }
} catch {}
}
return [PSCustomObject]@{ TimeCreated=$lockoutTime; CallerComputer=$caller; DC=$sourceDC }
}
# 로그 출력
function Write-Log { param([string]$Message,[string]$Color="White")
try { $mainForm.Dispatcher.Invoke({$para=New-Object Windows.Documents.Paragraph;$run=New-Object Windows.Documents.Run($Message);$run.Foreground=(New-Object Windows.Media.BrushConverter).ConvertFromString($Color);$para.Inlines.Add($run);$LogBox.Document.Blocks.Add($para);$LogBox.ScrollToEnd()}) | Out-Null } catch { Write-Host $Message }
}
# XAML GUI 정의 (Dark, DataGrid, 버튼, 로그)
[xml]$xaml=@"
<Window xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
Title='AD ' Height='640' Width='960' WindowStartupLocation='CenterScreen' Background='#1E1E1E'>
<Grid Margin='10'>
<Grid.RowDefinitions>
<RowDefinition Height='Auto'/><RowDefinition Height='*'/><RowDefinition Height='Auto'/><RowDefinition Height='160'/>
</Grid.RowDefinitions>
<StackPanel Orientation='Horizontal' Grid.Row='0' Margin='0,0,0,10'>
<Grid Width='260' Height='28' Margin='0,0,10,0'>
<TextBox x:Name='SearchBox' Background='#2D2D30' Foreground='White' FontFamily='Segoe UI' FontSize='13' BorderThickness='0'/>
<TextBlock Text=' ...' Margin='6,0,0,0' VerticalAlignment='Center' Foreground='Gray' IsHitTestVisible='False'>
<TextBlock.Style>
<Style TargetType='TextBlock'>
<Setter Property='Visibility' Value='Collapsed'/>
<Style.Triggers>
<DataTrigger Binding='{Binding Text, ElementName=SearchBox}' Value=''><Setter Property='Visibility' Value='Visible'/></DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
<Button x:Name='SearchButton' Content='' Width='80' Height='28' Margin='0,0,10,0'/>
<Button x:Name='UnlockButton' Content=' ' Width='130' Height='28' Margin='0,0,10,0'/>
<Button x:Name='CheckSourceButton' Content=' ' Width='130' Height='28'/>
</StackPanel>
<DataGrid x:Name='LockedAccountsDataGrid' Grid.Row='1' AutoGenerateColumns='False' CanUserAddRows='False'
IsReadOnly='True' SelectionMode='Single' AlternatingRowBackground='#2D2D30' Background='#1E1E1E' Foreground='White'
RowBackground='#2D2D30' HorizontalGridLinesBrush='#3E3E42' VerticalGridLinesBrush='#3E3E42' ColumnHeaderHeight='30' RowHeaderWidth='0'>
<DataGrid.Resources>
<Style TargetType='DataGridColumnHeader'>
<Setter Property='Background' Value='#3E3E42'/><Setter Property='Foreground' Value='#DCDCDC'/><Setter Property='FontWeight' Value='Bold'/>
</Style>
<Style TargetType='DataGridCell'>
<Style.Triggers>
<DataTrigger Binding='{Binding LockedOut}' Value='True'>
<Setter Property='Foreground' Value='#FF6A00'/><Setter Property='FontWeight' Value='Bold'/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTextColumn Header=' ' Binding='{Binding Name}' Width='200'/>
<DataGridTextColumn Header=' ID' Binding='{Binding SamAccountName}' Width='150'/>
<DataGridTextColumn Header=' ' Binding='{Binding LockedOut}' Width='100'/>
<DataGridTextColumn Header=' ' Binding='{Binding LastLogonDate, StringFormat=yyyy-MM-dd HH:mm:ss}' Width='200'/>
<DataGridTextColumn Header=' ' Binding='{Binding BadPwdCount}' Width='120'/>
</DataGrid.Columns>
</DataGrid>
<Button x:Name='RefreshButton' Content='' Grid.Row='2' Width='110' Height='28' Margin='0,10,0,10' HorizontalAlignment='Left'/>
<RichTextBox x:Name='LogBox' Grid.Row='3' Background='Black' Foreground='White' IsReadOnly='True' VerticalScrollBarVisibility='Auto'/>
</Grid>
</Window>
"@
$reader = New-Object System.Xml.XmlNodeReader $xaml
$mainForm = [Windows.Markup.XamlReader]::Load($reader)
$reader.Close()
$SearchBox = $mainForm.FindName('SearchBox')
$SearchButton = $mainForm.FindName('SearchButton')
$UnlockButton = $mainForm.FindName('UnlockButton')
$CheckSourceButton = $mainForm.FindName('CheckSourceButton')
$LockedAccountsDataGrid = $mainForm.FindName('LockedAccountsDataGrid')
$RefreshButton = $mainForm.FindName('RefreshButton')
$LogBox = $mainForm.FindName('LogBox')
# 데이터 그리드 채우기
function Populate-DataGrid {
Write-Log "잠긴 계정 목록 가져오는 중..."
$data = Get-LockedADAccounts
if ($data) { $LockedAccountsDataGrid.ItemsSource = $data; Write-Log "잠김 계정 목록 새로고침 완료 (총 $($data.Count)개)" "Cyan" }
else { $LockedAccountsDataGrid.ItemsSource = $null; Write-Log "잠김 계정 없음!" "Red" }
}
$RefreshButton.Add_Click({ Populate-DataGrid })
$SearchButton.Add_Click({
$q=$SearchBox.Text.Trim()
if ([string]::IsNullOrWhiteSpace($q)) { Populate-DataGrid; return }
try {
$pdc=(Get-ADDomain).PDCEmulator
$result=Get-ADUser -Filter "SamAccountName -like '*$q*' -or Name -like '*$q*' -or DisplayName -like '*$q*'" -Server $pdc -Properties LockedOut, LastLogonDate, BadPwdCount, DisplayName |
Select-Object @{n='Name';e={ if ([string]::IsNullOrWhiteSpace($_.DisplayName)){ $_.Name } else { $_.DisplayName } }}, SamAccountName, LockedOut,
@{n='LastLogonDate';e={ $_.LastLogonDate }}, BadPwdCount
$LockedAccountsDataGrid.ItemsSource=@($result)
Write-Log "검색 완료: '$q' ($(@($result).Count)건)" "Cyan"
} catch { Write-Log "검색 실패: $($_.Exception.Message)" "Red" }
})
$UnlockButton.Add_Click({
if ($LockedAccountsDataGrid.SelectedItem) {
$sel=$LockedAccountsDataGrid.SelectedItem
Write-Log "잠금 해제 요청: $($sel.Name) [$($sel.SamAccountName)]" "Yellow"
if (Unlock-ADUserAccount -SamAccountName $sel.SamAccountName) { Write-Log "성공: '$($sel.Name)' 잠금 해제 완료" "Green"; Populate-DataGrid }
} else { Write-Log "계정을 선택하세요." "Orange" }
})
$CheckSourceButton.Add_Click({
if ($LockedAccountsDataGrid.SelectedItem) {
$sel=$LockedAccountsDataGrid.SelectedItem
Write-Log "잠금 출처 조회 중... ($($sel.SamAccountName))" "Yellow"
$info = Get-ADLockoutEvent -SamAccountName $sel.SamAccountName
if ($info) {
$details=@"
: $($sel.Name)
ID: $($sel.SamAccountName)
: $($info.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))
: $($info.CallerComputer)
DC: $($info.DC)
"@
$popup=New-Object Windows.Window
$popup.Title="잠금 출처 정보"; $popup.Width=520; $popup.Height=320; $popup.WindowStartupLocation="CenterOwner"; $popup.Owner=$mainForm; $popup.Background=[Windows.Media.Brushes]::Black
$grid=New-Object Windows.Controls.Grid
$grid.Margin=10
$grid.RowDefinitions.Add((New-Object Windows.Controls.RowDefinition))
$grid.RowDefinitions.Add((New-Object Windows.Controls.RowDefinition -Property @{Height=35}))
$tb=New-Object Windows.Controls.TextBox
$tb.Text=$details; $tb.FontFamily='Consolas'; $tb.FontSize=13; $tb.Foreground=[Windows.Media.Brushes]::White
$tb.Background=[Windows.Media.Brushes]::Black; $tb.TextWrapping='Wrap'; $tb.IsReadOnly=$true
$tb.VerticalScrollBarVisibility='Auto'; $tb.HorizontalScrollBarVisibility='Auto'
$btn=New-Object Windows.Controls.Button
$btn.Content="복사"; $btn.Height=28; $btn.Width=80; $btn.HorizontalAlignment='Right'; $btn.Margin='0,5,0,0'
$btn.Add_Click({ [Windows.Clipboard]::SetText($tb.Text); Write-Log "잠금 출처 정보 복사됨" "Cyan" })
$grid.Children.Add($tb); [Windows.Controls.Grid]::SetRow($tb,0)
$grid.Children.Add($btn); [Windows.Controls.Grid]::SetRow($btn,1)
$popup.Content=$grid; $popup.ShowDialog() | Out-Null
} else { Write-Log "잠금 이벤트 없음" "Orange" }
} else { Write-Log "계정을 선택하세요." "Orange" }
})
Populate-DataGrid
$mainForm.ShowDialog() | Out-Null

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# AD 계정 잠금 해제 툴 (AD Account Unlocker)
Active Directory(AD) 환경에서 잠긴 사용자 계정을 관리하기 위한 PowerShell 기반 GUI 툴. 잠긴 계정 목록 조회, 잠금 해제, 잠금 출처 추적 기능을 제공한다.
## 주요 기능
* **잠긴 AD 계정 목록 조회**: 도메인 내 잠긴 모든 사용자 계정 목록 실시간 조회.
* **간편한 계정 잠금 해제**: 선택한 사용자의 계정 잠금을 버튼 클릭으로 해제.
* **계정 잠금 출처 추적**: 계정 잠금을 유발한 컴퓨터(IP 또는 이름)를 추적하여 원인 파악 지원.
* **사용자 검색**: 표시 이름, 로그온 ID 등으로 전체 AD 사용자 검색 기능 제공.
* **직관적인 다크 모드 GUI**: 관리자 편의를 위한 다크 테마 인터페이스.
* **실시간 작업 로그**: 모든 작업의 성공 및 실패 여부를 로그 창에 실시간 표시.
## 사용 전제 조건
1. **Active Directory 모듈**: 스크립트 실행 컴퓨터에 `ActiveDirectory` PowerShell 모듈 설치 필요. (RSAT의 일부)
2. **실행 권한**:
* AD 사용자 조회 및 계정 잠금 해제 권한.
* 도메인 컨트롤러(DC)의 보안 이벤트 로그(Event ID 4740, 4625) 원격 조회 권한.
3. **실행 환경**: 도메인에 가입된 Windows PC.
4. **.NET Framework**: .NET Framework 4.5 이상 필요. (최신 Windows에 기본 설치됨)
## 사용법
### 방법 1: PowerShell 스크립트(.ps1) 직접 실행
1. `AccountUnlocker.ps1` 스크립트 파일을 PC에 저장.
2. PowerShell을 **관리자 권한으로** 실행.
3. 필요 시 PowerShell 실행 정책 변경: `Set-ExecutionPolicy RemoteSigned -Scope Process`
4. 스크립트가 있는 경로로 이동 후 아래와 같이 실행.
```powershell
.\AccountUnlocker.ps1
```
### 방법 2: 실행 파일(.exe)로 패키징
`PS2EXE` 모듈을 사용하여 스크립트를 단일 실행 파일로 변환 가능.
**1단계: PS2EXE 모듈 설치 (최초 1회)**
관리자 권한 PowerShell에서 아래 명령어로 `PS2EXE` 설치.
```powershell
Install-Module -Name PS2EXE
```
**2단계: 스크립트 패키징**
스크립트 경로에서 아래 명령어로 `.exe` 파일 생성.
```powershell
Invoke-PS2EXE -InputFile ".\AccountUnlocker.ps1" -OutputFile ".\ADLockUser.exe" -noConsole -title "AD 계정 잠 금 해제 툴" -product "AD Management Tools" -company "TigErJin" -requireAdmin
```
* `-noConsole`: 콘솔 창 없이 GUI만 실행.
* `-title`, `-product`, `-company`: 파일 속성 정보 지정.
* `-requireAdmin`: 실행 시 관리자 권한 자동 요구.
**3단계: 프로그램 실행**
생성된 `ADLockUser.exe` 파일 실행.
## 스크립트 주요 로직 설명
### `Get-LockedADAccounts`
* 도메인의 PDC(Primary Domain Controller) 에뮬레이터에 쿼리하여 가장 정확한 잠긴 계정 목록을 가져온다.
* `Search-ADAccount -LockedOut`으로 잠긴 계정을 찾고 `Get-ADUser`로 상세 정보(표시 이름, 마지막 로그온 등)를 추가 조회.
### `Unlock-ADUserAccount`
* 선택된 계정의 `SamAccountName`을 받아 PDC를 대상으로 `Unlock-ADAccount` cmdlet을 실행.
* 성공/실패 여부를 반환하여 GUI에 로그를 기록.
### `Get-ADLockoutEvent` (잠금 출처 추적)
이 기능은 2단계에 걸쳐 잠금 원인을 추적한다.
1. **1단계 (Event ID 4740 조회)**: 모든 DC를 대상으로 보안 로그에서 **Event ID 4740 (계정 잠김)** 이벤트를 검색. 이를 통해 잠금 시간, 발생 DC, 호출자 컴퓨터 이름을 확인.
2. **2단계 (Event ID 4625 조회)**: 4740 이벤트의 '호출자 컴퓨터 이름'이 명확하지 않을 경우, 해당 DC에서 잠금 직전의 **Event ID 4625 (로그온 실패)** 이벤트를 추가 검색. 이 로그의 네트워크 주소(IP) 정보를 통해 실제 인증 실패가 발생한 출처를 특정.