Powershell & ASP : Automating account unlocks from website

This is a fun one to crack from the previous post which you can find here

The report was handy, but wouldn’t it be nice if you could click a red unlock button which would intern would queue an account unlock request for another script to process.

Note : I did originally go down the route of allowing the website to unlock an account in active directory, however, I quickly realize the security ramifications of completing such action so I’ve modified it so it doesn’t directly talk “direct” to ADDS - even with asp.net there are some issues with this process especially when linked to IIS websites.

How does this work?

Interesting question, you are presented with the same report as the previous post except on the right you will get red unlock button, when you click on the unlock button you will be prompted if you would like to queue a request for this account, when this is confirmed, the butter will turn green with pending on the button and it will write it to a file in the same folder the website is running from, this file will contain the date and the time of the request along with samAccountName attribute.

That is as far as the website goes from an IIS perspective, you are simply requesting the account to be unlocked.

Refresh the page button once again to unlock And you can request the same account to be unlocked - Did nothing does not reflect the state of their account only what’s in the report for lockouts - as this report runs daily, the same users should not be continually on the same report every day.

The actual unlocking of the account is done by another script back to monitors this file for requests every five seconds, When you requested added the second script will actually unlock the account and then remove it from the request file so it’s not continually being unlocked - this way once the unlock has been processed it will automatically be purged from the file after the processing of the unlock is complete.

Will this activity be logged?

Yes, when you request the account unlock that go in the first log then when the account unlock his process and completed that will go in the second log

Or request will be tracked, and on process request will remain in the original log locations whereas successful unlocks will end up in the final log location.

What does the look like?

You can see the visual below with example of clicked "Unlock" buttons and original "Unlock" buttons:


Scripting with Powershell

Collect the Data from the Domain Controllers

First we need to get all the scripting done so lets start with the event extractor, this will get all the events from the Domain Controllers and is required for the next set of scripts, this will create a account_lockouts.csv.

This file will retrieve all the lockout events from all the DC's using the Event ID 4740 entry, then it will ensure those accounts are locked out with a lockout validity check, then it will only show one account on the website later on, if there are more than one caller computer then more than one entry will be displayed.

Script : LockoutExtractor.ps1

# Define primary Domain Controller
$PrimaryDC = "beardc.bearl.local"

# Get list of Domain Controllers
$DCs = Get-ADDomainController -Filter * | Select-Object -ExpandProperty Name

# Collect lockout events from each DC
$Results = foreach($DC in $DCs) {
   Get-WinEvent -ComputerName $DC -FilterHashtable @{LogName='Security';ID=4740} -MaxEvents 10 -ErrorAction SilentlyContinue | 
   Select-Object @{N='DC';E={$DC}},
                 @{N='TimeCreated';E={$_.TimeCreated}},
                 @{N='LockedAccount';E={$_.Properties[0].Value}},
                 @{N='CallerComputer';E={$_.Properties[1].Value}}
}

# Sort results by time
$SortedResults = $Results | Sort-Object TimeCreated -Descending

# Filter for currently locked accounts and unique user+computer combinations
$ConfirmedLockouts = $SortedResults | 
    Group-Object LockedAccount, CallerComputer | 
    ForEach-Object {
        $_.Group | Select-Object -First 1
    } | 
    ForEach-Object {
        $lockoutStatus = Get-ADUser -Server $PrimaryDC $_.LockedAccount -Properties LockedOut | 
            Select-Object -ExpandProperty LockedOut
        if ($lockoutStatus -eq $true) {
            $_
        }
    }

# Export confirmed lockouts to CSV
$ConfirmedLockouts | Export-Csv -Path ".\account_lockouts.csv" -NoTypeInformation

# Display confirmed lockouts in console
$ConfirmedLockouts | Format-Table -AutoSize

This will then create a file called, as mentioned earlier, account_lockouts.csv this will have all the data fot the next script:


Create the website (with unlock button)

Now we need to create the website that will display the report this is done with a script that I have placed in a folder called "UnlockWriteFile" as this script will produce the website with the unlock button and then also give you the index.html that will be used on the IIS server later in this guide, you will also require the WriteUnlock.aspx file as well.

Note : This file will require the account_lockouts.csv file copied to this folder where the script is run from, else it will have no data to process.

Script : GenerateBase.ps1

# Generate-LockoutReport.ps1
# First, find the latest CSV file in the current directory
$LatestCSV = Get-ChildItem -Path "." -Filter "*.csv" | Sort-Object LastWriteTime -Descending | Select-Object -First 1

if ($null -eq $LatestCSV) {
    Write-Error "No CSV files found in the current directory"
    exit
}

# Import the CSV data
$Results = Import-Csv -Path $LatestCSV.FullName

# Convert string dates back to DateTime objects and get only the most recent lockout for each account
$Results = $Results | ForEach-Object {
    # Parse the date using specific format
    $dateTime = [DateTime]::ParseExact($_.TimeCreated, "dd/MM/yyyy HH:mm:ss", [System.Globalization.CultureInfo]::InvariantCulture)
    $_ | Add-Member -MemberType NoteProperty -Name 'TimeCreatedDate' -Value $dateTime -Force
    $_
} | Group-Object LockedAccount | ForEach-Object {
    # Get only the most recent entry for each account
    $_.Group | Sort-Object TimeCreatedDate -Descending | Select-Object -First 1
}

# Group results by date categories
$Today = (Get-Date -Year 2024).Date
$Yesterday = $Today.AddDays(-1)
$WeekStart = $Today.AddDays(-10)

$GroupedResults = @{
    Today = $Results | Where-Object { $_.TimeCreatedDate.Date -eq $Today }
    Yesterday = $Results | Where-Object { $_.TimeCreatedDate.Date -eq $Yesterday }
    ThisWeek = $Results | Where-Object { 
        $_.TimeCreatedDate.Date -ge $WeekStart -and $_.TimeCreatedDate.Date -lt $Today -and $_.TimeCreatedDate.Date -ne $Yesterday
    }
}

# Function to create HTML table from results
function Create-HTMLTable {
    param($Data)
    if ($null -eq $Data -or $Data.Count -eq 0) {
        return '<div class="no-data">No lockouts recorded during this period</div>'
    }
    
    $html = @"
    <table>
        <tr>
            <th>Time</th>
            <th>Locked Account</th>
            <th>Caller Computer</th>
            <th>Domain Controller</th>
            <th>Action</th>
        </tr>
"@
    
    foreach ($item in $Data) {
        $html += @"
        <tr>
            <td class="timestamp">$($item.TimeCreated)</td>
            <td>$($item.LockedAccount)</td>
            <td>$($item.CallerComputer)</td>
            <td>$($item.DC)</td>
            <td>
                <button class="unlock-btn" onclick="handleUnlock('$($item.LockedAccount)')">Unlock</button>
            </td>
        </tr>
"@
    }
    
    $html += "</table>"
    return $html
}

# Create HTML content
$HTMLContent = @"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Account Lockout Report</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            line-height: 1.6;
            max-width: 1200px;
            margin: 0 auto;
            padding: 20px;
            background: #f5f5f5;
            color: #333;
        }
        .container {
            background: white;
            padding: 30px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        h1 {
            color: #2c3e50;
            border-bottom: 2px solid #eee;
            padding-bottom: 10px;
            margin-bottom: 30px;
        }
        h2 {
            color: #34495e;
            margin-top: 30px;
            padding: 10px;
            background: #f8f9fa;
            border-radius: 4px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin: 20px 0;
            background: white;
        }
        th, td {
            padding: 12px;
            text-align: left;
            border-bottom: 1px solid #eee;
        }
        th {
            background: #f8f9fa;
            font-weight: 600;
        }
        tr:hover {
            background: #f8f9fa;
        }
        .timestamp {
            color: #666;
            font-size: 0.9em;
        }
        .no-data {
            padding: 20px;
            text-align: center;
            color: #666;
            font-style: italic;
        }
        .summary {
            margin-bottom: 30px;
            padding: 15px;
            background: #e1f5fe;
            border-radius: 4px;
        }
        .csv-info {
            color: #666;
            font-size: 0.9em;
            margin-top: 10px;
        }
        .unlock-btn {
            background-color: #dc3545;
            color: white;
            border: none;
            padding: 8px 16px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            transition: background-color 0.2s;
        }
        .unlock-btn:hover {
            background-color: #c82333;
        }
        .unlock-btn:disabled {
            background-color: #6c757d;
            cursor: not-allowed;
        }
        .success {
            background-color: #28a745 !important;
        }
        .loading {
            opacity: 0.7;
        }
        #notification {
            position: fixed;
            top: 20px;
            right: 20px;
            padding: 15px;
            border-radius: 4px;
            color: white;
            display: none;
            z-index: 1000;
        }
        .notification-success {
            background-color: #28a745;
        }
        .notification-error {
            background-color: #dc3545;
        }
    </style>
</head>
<body>
    <div id="notification"></div>
    <div class="container">
        <h1>Account Lockout Report</h1>
        <div class="summary">
            Report generated on: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")<br>
            Total unique accounts locked this week: $($Results.Count)<br>
            <span class="csv-info">Data source: $($LatestCSV.Name)</span>
        </div>

        <h2>Today</h2>
        $(Create-HTMLTable $GroupedResults.Today)

        <h2>Yesterday</h2>
        $(Create-HTMLTable $GroupedResults.Yesterday)

        <h2>Earlier This Week</h2>
        $(Create-HTMLTable $GroupedResults.ThisWeek)
    </div>

    <script type="text/javascript">
        // Define functions globally
        var handleUnlock = function(username) {
            console.log('Unlock requested for:', username);
            
            if (!confirm('Are you sure you want to unlock account: ' + username + '?')) {
                return;
            }

            var button = event.target;
            button.disabled = true;
            button.textContent = 'Processing...';

            var formData = new FormData();
            formData.append('username', username);

            fetch('WriteUnlock.aspx', {
                method: 'POST',
                body: formData
            })
            .then(function(response) {
                return response.text();
            })
            .then(function(text) {
                console.log('Response:', text);
                if (text.includes('Success')) {
                    button.textContent = 'Requested';
                    button.classList.add('success');
                    showNotification('Unlock request saved successfully', 'success');
                } else {
                    throw new Error(text || 'Unknown error occurred');
                }
            })
            .catch(function(error) {
                console.error('Error:', error);
                button.disabled = false;
                button.textContent = 'Unlock';
                showNotification('Error: ' + error.message, 'error');
            });
        }

        var showNotification = function(message, type) {
            var notification = document.getElementById('notification');
            notification.textContent = message;
            notification.className = 'notification-' + type;
            notification.style.display = 'block';
            
            setTimeout(function() {
                notification.style.display = 'none';
            }, 3000);
        }
    </script>
</body>
</html>
"@

# Save the HTML report
$HTMLContent | Out-File ".\index.html" -Encoding UTF8

Write-Host "Report generated successfully from $($LatestCSV.Name)"
Write-Host "Output saved to lockout_report.html"

This will output a index.html file that will be later used on the IIS sever.

IIS Configuration

We now need to get the IIS configuration setup, so first we need to create a new virtual directory, therefore browser to this folder if default, if not lookup where you default document root is located for IIS:

C:\inetpub\wwwroot


Then we want to create a folder here called Lockout as below:



Then when you start IIS manager and expand Sites>Default Website you will notice that your folder is now shown, if not press F5 and it will appear:


Then we need to right click that folder and choose Convert to Application:


Then you need to ensure you choose the Select button then choose the .Net 4/0 application pool which in this case is DefaultAppPool as below:



You should then notice is has a global next to the folder rather that the normal folder, this means its is now an application and can run the code required.



Now we need to ensure the authentication for that virtual directory is setup correctly so when you click on the "Lockout" application to the right you should see the authentication option as below:



Double click on the Authentication option which should be set to "anonymous authentication" for this to work, this means anyone can access the website but this will be fixed later with IP address restrictions.


We now need to check the Application Pool permissions assigned to the folder from earlier, so from IIS locate the "Application Pools" as below, then find the DefaultAppPool (as assigned earlier)


Right click on the DefaultAppPool can choose Advanced Settings as below:


Finally under Advanced settings ensure that under Process model>Identity is set to "ApplicationPoolIdentity" as below, if not update this setting and confirm that update with the OK button.


One more place to update permissions then we can continue, now to need to in Explorer view the ACL (or security tab) for that folder, ensure you are in the Advanced options as below:


We need to add some permission for the account which will enable it to write files to this directory only, so the account we need to add is:

IIS APPPOOL\DefaultAppPool

Click on Add, ensure the location is "local computer" not your domain and then into the security addition box enter that name from above:


Then when you click OK that will add the Application Pool to the ACL, ensure you place a tick in the "Modify" box and then confirm with the OK as below:


That now concludes the IIS configuration for the website to work as it should, now we can move right along.

Copy file to Lockout folder

When you ran the script GenerateBase.ps1 from earlier this would have created a file called index.html, this file not need to be copied to the Lockout folder we have just set the permissions on, now you also need the aspx file to actually the samAccountName to the text file for later processing, this is below:

Script : WriteUnlock.aspx

<%@ Page Language="C#" %>
<%@ Import Namespace="System.IO" %>
<%
    Response.ContentType = "text/plain";
    try {
        // Get the physical path of the website root
        string rootPath = Server.MapPath("~/");
        
        // Define file paths
        string debugLogPath = Path.Combine(rootPath, "debug_log.txt");
        string unlockRequestsPath = Path.Combine(rootPath, "unlock_requests.txt");

        // Create the debug log file if it doesn't exist
        if (!File.Exists(debugLogPath)) {
            File.WriteAllText(debugLogPath, "Log file created: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\n");
        }

        // Create the unlock requests file if it doesn't exist
        if (!File.Exists(unlockRequestsPath)) {
            File.WriteAllText(unlockRequestsPath, "Unlock requests log created: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\n");
        }

        // Log debug start
        File.AppendAllText(debugLogPath, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " - Debug start\n");

        // Check the request method
        if (Request.HttpMethod != "POST") {
            Response.StatusCode = 400;
            Response.Write("Only POST method is allowed");
            return;
        }

        string username = Request.Form["username"];
        if (string.IsNullOrEmpty(username)) {
            Response.StatusCode = 400;
            Response.Write("Username is required");
            return;
        }

        // Log the received username
        File.AppendAllText(debugLogPath, "Username received: " + username + "\n");

        // Create the unlock request entry
        string timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
        string content = timestamp + " - " + username + "\n";
        
        // Write to the unlock requests file
        File.AppendAllText(unlockRequestsPath, content);
        
        // Log success
        File.AppendAllText(debugLogPath, "Write successful\n");

        Response.Write("Success");
    }
    catch (Exception ex) {
        Response.StatusCode = 500;
        Response.Write("Error: " + ex.Message);
        
        // Try to log the error if possible
        try {
            File.WriteAllText(
                Path.Combine(Server.MapPath("~/"), "error_log.txt"),
                DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " - Error: " + ex.ToString() + "\n"
            );
        }
        catch {}
    }
%>

Review Lockout Folder

The Lockout folder should now have 2 files in contained within as below, when you then click on the unlock button it will call WriteUnlock.aspx and execute that script:


Now when you see a lockout account on the website with the red unlock button as below:


When you click the unlock button and confirm you action with an OK, that then calls WriteUnlock.aspx that in turn records the saAccountName of the user to a text file and the website reports "Requested" as below:


This will then give a new file called unlock_requests.txt as below this concludes the job of the website clicking:



Finally when this script runs you will also get a debug log file, which is highlighted below that will log every request made from the website:

This file called unlock_requests.txt will record all the requested unlocks that have been requested from the website, that looks like this:

Unlock requests log created: 2024-12-24 13:20:18
2024-12-24 15:22:43 - locked.bear
2024-12-24 15:22:45 - bob.smith
2024-12-25 07:42:58 - locked.bear

This then shows you the samAccountName and on it own this is as far as the website goes without further monitoring processes to act on this data.

Monitoring unlock_requests.txt and Unlocking Account

This is where the actual unlocking is done, the website up to this point is simply writing the unlock request to a text file, so now we need a script to monitor this unlock_requests.txt file for updates or new samAccountNames and then unlock those accounts.

This cannot be a scheduled task as that will take to long, the shortest runtime there is every 15 minutes so we need a script to always monitor the file and when additional samAccountName are added we need the script to unlock then with:

Unlock-ADAccount <samAccountName>

Then it needs to remove those entries from unlock_requests.txt to a different log file so the script does not unlock the same accounts over and over again on a loop - which would not be a good idea.

Ensure you define the variables for $requestFilePath and $logFilePath - the first vairable is for the unlock_requests.txt file and the second variable is for the log file of all account that have been unlocked.

Script : ContinualLogging.ps1

# Define paths
$requestFilePath = "C:\inetpub\wwwroot\Lockout\unlock_requests.txt"
$logFilePath = "looping_actions.log"

function Read-FileWithoutLock {
    param ([string]$filePath)
    try {
        $fileStream = [System.IO.File]::Open($filePath, 'Open', 'Read', 'ReadWrite')
        $streamReader = New-Object System.IO.StreamReader($fileStream)
        $content = $streamReader.ReadToEnd()
        return $content
    }
    finally {
        if ($streamReader) { $streamReader.Close() }
        if ($fileStream) { $fileStream.Close() }
    }
}

function Process-UnlockRequests {
    $content = Read-FileWithoutLock -filePath $requestFilePath
    Write-Host "Processing file content at $(Get-Date)"
    $lines = $content -split "\r?\n" | Where-Object { $_ -match "^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+-\s+\w+" }

    if ($lines.Count -gt 0) {
        foreach ($request in $lines) {
            if ($request -match "-\s*(\w+)$") {
                $username = $matches[1]
                try {
                    Unlock-ADAccount -Identity $username
                    Add-Content -Path $logFilePath -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Successfully unlocked account for user: $username"
                    Write-Host "Successfully processed unlock request for $username"
                }
                catch {
                    Add-Content -Path $logFilePath -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Error unlocking account for user: $username - Error: $($_.Exception.Message)"
                    Write-Host "Error processing unlock request for $username : $_" -ForegroundColor Red
                }
            }
        }
        $header = Get-Content $requestFilePath | Select-Object -First 1
        Set-Content -Path $requestFilePath -Value $header
    }
}

# Main monitoring loop with hash check
$lastHash = ""

while ($true) {
    try {
        $currentContent = Read-FileWithoutLock -filePath $requestFilePath
        $currentHash = [System.Security.Cryptography.MD5]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes($currentContent))
        $currentHashString = [System.BitConverter]::ToString($currentHash)

        if ($currentHashString -ne $lastHash) {
            Process-UnlockRequests
            $lastHash = $currentHashString
        }
        Start-Sleep -Seconds 2
    }
    catch {
        Write-Host "Error: $_" -ForegroundColor Red
        Add-Content -Path $logFilePath -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Error: $($_.Exception.Message)"
        Start-Sleep -Seconds 10
    }
}

This will then continually monitor the unlock requests file for updates and then will process the unlock command when a username is detected as you can see below:


The file is processed so that the raw data in the unlock request file, it ignores all the data excluding the samAccountName which is shown in bold:

Unlock requests log created: 2024-12-24 13:20:18
2024-12-24 15:22:43 - locked.bear
2024-12-24 15:22:45 - bob.smith
2024-12-25 07:42:58 - locked.bear

That will then be used to complete the unlock request as you can see in the log file from ContinualLogging.ps1:

2024-12-24 15:22:46 - Successfully unlocked account for user: locked.bear
2024-12-24 15:22:47 - Successfully unlocked account for user: bob.smith
2024-12-25 07:42:51 - Successfully unlocked account for user: locked.bear

Restrictions for Unlock website

We now need to restrict who can use this website to authorised IP addresses, so first we need to ensure we have the pre-requisites installed and this is not installed by default we need the IP security plugin for IIS with the command:

Add-WindowsFeature Web-IP-Security

This will then start the installation as below:


This should then install and complete, you may require a reboot at this stage as well:


You then need to navigate to your folder in IIS manager called Lockout and then on the right look for the IP Address and Domain Restrictions - if you do not see this option after installing it, then you need a reboot.


Double click that option and it will look blank as nothing has been setup we need the "Add Allow Entry" as below:


Then we need to enter either a single IP or a range of addresses:


Then you will see this has been added to the table of allowed addresses:


However this will not stop the access from unauthorised addresses as the "feature settings" have not been setup yet, we now need the option for "edit feature settings" as below:



Then we need to update the "access for unspecified clients" to Deny and then choose the Deny action which in this case id a Forbidden page as below:



Now when you try to visit the website from any other address than the one in the allow list we will now get the Forbidden error as below:


Unless you have allowed detailed errors to remote clients in which case it will look like this:

Previous Post Next Post

نموذج الاتصال