Secure Active Directory Password Reset Tool : Why not?


I've been working on implementing a secure self-service password reset solution for our Active Directory environment. While this might seem like a straightforward task at first glance, it quickly became a journey filled with unexpected challenges, permissions nuances, and security considerations that weren't immediately obvious - this is the overview from the trenches.

In this post, I'll share my experience building this solution, focusing on the technical hurdles encountered and how they were overcome. For those looking to implement something similar, it is a very strong learning experience.

Requirements

The organization needed a simple, secure way for users to reset their Active Directory passwords without requiring helpdesk intervention. The requirements were straightforward:

  1. A clean, modern web interface
  2. Password strength validation
  3. Ability to validate current password before allowing a reset
  4. Detailed logging for security auditing
  5. Secure integration with Active Directory
  6. Password policy enforcement

The solution also needed to work with our specific environment where our domain name (bear.local) differs from our UPN suffix (bythepowerofgreyskull.com)

Choosing the Technology Stack

I opted for ASP.NET with C# for the backend and standard HTML/CSS/JavaScript for the frontend. ASP.NET provides excellent integration with Active Directory through the System.DirectoryServices and System.DirectoryServices.AccountManagement namespaces, while giving us the ability to run under a service account identity.

The Initial Implementation - And Why It Failed

My first approach was to use the higher-level PrincipalContext and UserPrincipal classes, which provide an object-oriented wrapper around Active Directory operations:

private bool VerifyCurrentPassword(string userPrincipalName, string password)
{
    try
    {
        // Create a principal context for the domain
        using (PrincipalContext context = new PrincipalContext(ContextType.Domain, Domain))
        {
            // Validate the credentials
            return context.ValidateCredentials(userPrincipalName, password);
        }
    }
    catch (Exception ex)
    {
        throw new Exception("Error verifying credentials: " + ex.Message);
    }
}

And for password resets:

private bool ResetADPassword(string userPrincipalName, string newPassword)
{
    try
    {
        // This uses the application pool identity credentials automatically
        using (PrincipalContext context = new PrincipalContext
        (ContextType.Domain, Domain))
        {
            // Find the user by UPN
            UserPrincipal user = UserPrincipal.FindByIdentity(context, 
           IdentityType.
           UserPrincipalName, userPrincipalName);
            
            if (user != null)
            {
                // Set the new password
                user.SetPassword(newPassword);
                user.Save();
                return true;
            }
            
            return false;
        }
    }
    catch (Exception ex)
    {
        throw new Exception("Error resetting password: " + ex.Message);
    }
}

This looked elegant and straightforward, but when I deployed it to our environment,
I hit the first major roadblock: the cryptic error message "The server cannot handle directory requests."

Pitfall #1: The Mysterious "Server Cannot Handle Directory Requests" Error

This error is frustratingly vague. After extensive troubleshooting and research, I discovered several potential causes:

  1. Insufficient permissions for the service account
  2. Domain controller configuration issues
  3. LDAP referral handling problems
  4. Issues with the higher-level AD abstraction classes

The real issue in our case was that the higher-level PrincipalContext class was trying to do too much behind the scenes, and our service account didn't have all the necessary permissions for these extra operations.

The Solution: Going Lower Level with DirectoryEntry

The breakthrough came when I switched to using the lower-level LDAP API directly through DirectoryEntry and DirectorySearcher:

private bool VerifyCurrentPassword(string userPrincipalName, string password)
{
    try
    {
        // Extract username from UPN if it contains @
        string username = userPrincipalName;
        if (userPrincipalName.Contains("@"))
        {
            username = userPrincipalName.Split('@')[0];
        }
        
        // Log the authentication attempt
        WriteToLog(string.Format("Attempting to authenticate user: {0} 
        against domain: {1}", 
                               username, Domain));
        
        // Use a direct LDAP bind to authenticate the user
        try
        {
            // Try to bind with the user's credentials to verify them
            string ldapPath = string.Format("LDAP://{0}", Domain);
            using (DirectoryEntry entry = new DirectoryEntry(ldapPath, username
            , password))
            {
                // Just accessing the NativeObject property will force authentication
                object obj = entry.NativeObject;
                WriteToLog(string.Format("Authentication successful for user: {0}", 
                username));
                return true;
            }
        }
        catch (Exception ex)
        {
            WriteToLog(string.Format("LDAP authentication failed: {0}", ex.Message));
            return false;
        }
    }
    catch (Exception ex)
    {
        throw new Exception("Error verifying credentials: " + ex.Message);
    }
}

And similarly for password reset:

private bool ResetADPassword(string userPrincipalName, string newPassword)
{
    try
    {
        // Extract username from UPN if it contains @
        string username = userPrincipalName;
        if (userPrincipalName.Contains("@"))
        {
            username = userPrincipalName.Split('@')[0];
        }
        
        // Use DirectoryEntry to find and update the user
        try
        {
            // Create a DirectorySearcher to find the user
            string ldapPath = string.Format("LDAP://{0}", Domain);
            using (DirectoryEntry directoryEntry = new DirectoryEntry(ldapPath))
            {
                using (DirectorySearcher searcher = 
                new DirectorySearcher(directoryEntry))
                {
                    // Search for the user by sAMAccountName
                    searcher.Filter = string.Format("(&(objectCategory=person)
                    (objectClass=user)(sAMAccountName={0}))", 
                                                 username);
                    searcher.SearchScope = SearchScope.Subtree;
                    
                    SearchResult result = searcher.FindOne();
                    if (result != null)
                    {
                        // Get the user's DirectoryEntry
                        using (DirectoryEntry userEntry = result.GetDirectoryEntry())
                        {
                            // Set the password using the low-level ADSI method
                            userEntry.Invoke("SetPassword", new object[] { newPassword });
                            userEntry.CommitChanges();
                            return true;
                        }
                    }
                    else
                    {
                        return false;
                    }
                }
            }
        }
        catch (Exception ex)
        {
            throw;
        }
    }
    catch (Exception ex)
    {
        throw new Exception("Error resetting password: " + ex.Message);
    }
}

This approach gave us much more precise control over what was happening and eliminated the mysterious "server cannot handle directory requests" error.

Pitfall #2: Domain Names vs. UPN Suffixes

Another challenge arose from thr environment having different domain names and UPN suffixes. Users would enter their email-style UPN (username@bythepowerofgreyskull.com), but authentication needed to happen against the bear.local domain.

The solution was to extract just the username part and use that for LDAP operations:

// Extract username from UPN if it contains @
string username = userPrincipalName;
if (userPrincipalName.Contains("@"))
{
    username = userPrincipalName.Split('@')[0];
}

Pitfall #3: ViewState MAC Validation Errors

I also encountered an unusual error when using the service account: "Validation of viewstate MAC failed." This happens when ASP.NET can't validate the ViewState data, typically when:

  1. The application runs in a web farm or cluster
  2. The process identity changes between postbacks
  3. The application pool recycles between requests

The solution was to add a fixed machine key to the Web.config:

<machineKey 
  validationKey="CB2721ABDAF8E9DC516D0046BD8D5553D2EAD23EE15EBD6FCA2B86BA0E
  DCC892EA51CAD30674550598067FF46753004F481DEB587BFE31A1A878855753358A77" 
  decryptionKey="7F55225D73EB636C99D523D276C22E281EE2F64BD3130109" 
  validation="SHA1" 
  decryption="AES" />

Pitfall #4: Password Complexity Validation

Ensuring that passwords met our organization's complexity requirements was another challenge. This needed to be implemented both client-side (for immediate user feedback) and server-side (for security).

Server-side validation:

private bool ValidatePasswordComplexity(string password)
{
    // Length check
    if (password.Length < 15)
        return false;

    // Uppercase check
    if (!System.Text.RegularExpressions.Regex.IsMatch(password, "[A-Z]"))
        return false;

    // Lowercase check
    if (!System.Text.RegularExpressions.Regex.IsMatch(password, "[a-z]"))
        return false;

    // Number check
    if (!System.Text.RegularExpressions.Regex.IsMatch(password, "[0-9]"))
        return false;

    return true;
}

Client-side validation with visual feedback:

function checkPasswordStrength() {
    var password = document.getElementById('<%=newPassword.ClientID%>').value;
    var meter = document.getElementById('passwordStrengthMeter');
    var label = document.getElementById('strengthLabel');
    
    var strength = 0;
    var color = '#e1dfdd';
    
    // Check requirements
    var hasLength = password.length >= 15;
    var hasUpperCase = /[A-Z]/.test(password);
    var hasLowerCase = /[a-z]/.test(password);
    var hasNumbers = /\d/.test(password);
    var hasSpecialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
    
    // Update requirements UI
    document.getElementById('req-length').className = hasLength ? 'met' : '';
    document.getElementById('req-uppercase').className = hasUpperCase ? 'met' : '';
    document.getElementById('req-lowercase').className = hasLowerCase ? 'met' : '';
    document.getElementById('req-number').className = hasNumbers ? 'met' : '';
    
    // Calculate strength
    if (hasLength) strength += 20;
    if (hasUpperCase) strength += 20;
    if (hasLowerCase) strength += 20;
    if (hasNumbers) strength += 20;
    if (hasSpecialChars) strength += 20;
    
    // Set color based on strength
    if (strength <= 20) {
        color = '#d13438'; // Red (Very Weak)
        label.textContent = 'Very Weak';
    } else if (strength <= 40) {
        color = '#ffb900'; // Yellow (Weak)
        label.textContent = 'Weak';
    } else if (strength <= 60) {
        color = '#ffc83d'; // Light Yellow (Moderate)
        label.textContent = 'Moderate';
    } else if (strength <= 80) {
        color = '#92c353'; // Light Green (Strong)
        label.textContent = 'Strong';
    } else {
        color = '#107c10'; // Green (Very Strong)
        label.textContent = 'Very Strong';
    }
    
    // Update meter
    meter.style.width = strength + '%';
    meter.style.backgroundColor = color;
}

Pitfall #5: Service Account Permissions

Getting the service account permissions right was critical. Initially, I made the mistake of giving the service account too many permissions (Account Operator), which violated the principle of least privilege and created unnecessary security risk.

The correct approach was to use delegated permissions, specifically granting only:

  • "Reset Password" rights on the specific OUs containing user accounts
  • Read access to user properties

This was configured in Active Directory by:

  1. Opening "Active Directory Users and Computers"
  2. Right-clicking on the OU containing users
  3. Selecting "Delegate Control"
  4. Adding the service account
  5. Creating a custom task delegation with just "Reset Password" and "Read all properties" for User objects

The Importance of Comprehensive Logging

Throughout this project, detailed logging proved invaluable for troubleshooting. I implemented a logging system that captured:

  • Page access (with IP address)
  • Authentication attempts
  • Password reset operations
  • Errors and exceptions
  • Windows authenticated user for audit purposes
private void WriteToLog(string message)
{
    try
    {
        // Create a timestamped log entry
        string logEntry = string.Format("{0} - {1}{2}", 
                                     DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
                                     message,
                                     Environment.NewLine);

        // Create the log directory if it doesn't exist
        string logPath = Path.Combine(Server.MapPath("~/"), LogFolder);
        if (!Directory.Exists(logPath))
        {
            Directory.CreateDirectory(logPath);
        }

        // Append to the log file
        string logFilePath = Path.Combine(logPath, LogFileName);
        File.AppendAllText(logFilePath, logEntry);
    }
    catch (Exception ex)
    {
        // If logging fails, don't throw an exception
        System.Diagnostics.Debug.WriteLine("Error writing to log file: " + ex.Message);
    }
}

And later, I enhanced this to include the identity of the authenticated user performing the action:

// Get Windows authenticated user (if available)
string authenticatedUser = "Anonymous";
if (Request.LogonUserIdentity != null && !string.IsNullOrEmpty
(Request.LogonUserIdentity.Name))
{
    authenticatedUser = Request.LogonUserIdentity.Name;
}
else if (!string.IsNullOrEmpty(User.Identity.Name))
{
    authenticatedUser = User.Identity.Name;
}

// Log with authenticated user information
WriteToLog(string.Format("Password reset attempt initiated for user '{0}' 
from IP {1} by authenticated user '{2}'", 
                        userPrincipalName, userIpAddress, authenticatedUser));

Implementing Blocked Words Functionality

One of our key security enhancements was implementing a blocked words feature that prevents users from including common, predictable, or organization-specific terms in their passwords.

// Blocked words list (can be expanded)
private List<string> BlockedWords = new List<string> {
    "password", "welcome", "admin", "123456", "qwerty", 
    "letmein", "monkey", "abc123", "star", "login"
};

// File path for the blocked words list
private string BlockedWordsFile = "App_Data/BlockedWords.txt";

// Load blocked words from file
private void LoadBlockedWords()
{
    try
    {
        string filePath = Path.Combine(Server.MapPath("~/"), BlockedWordsFile);
        
        // If the file exists, load words from it
        if (File.Exists(filePath))
        {
            // Read all lines and add non-empty lines to the blocked words list
            string[] fileWords = File.ReadAllLines(filePath);
            foreach (string word in fileWords)
            {
                string trimmedWord = word.Trim().ToLower();
                if (!string.IsNullOrEmpty(trimmedWord) && !BlockedWords.Contains(trimmedWord))
                {
                    BlockedWords.Add(trimmedWord);
                }
            }
            
            WriteToLog(string.Format("Loaded {0} blocked words from file", fileWords.Length));
        }
        else
        {
            // Create the directory if it doesn't exist
            string dirPath = Path.GetDirectoryName(filePath);
            if (!Directory.Exists(dirPath))
            {
                Directory.CreateDirectory(dirPath);
            }
            
            // Create the file with the default blocked words
            File.WriteAllLines(filePath, BlockedWords);
            WriteToLog("Created default blocked words file");
        }
    }
    catch (Exception ex)
    {
        WriteToLog(string.Format("Error loading blocked words: {0}", ex.Message));
    }
}

// Check if password contains any blocked words
private string ContainsBlockedWord(string password)
{
    string lowerPassword = password.ToLower();
    
    foreach (string word in BlockedWords)
    {
        if (lowerPassword.Contains(word.ToLower()))
        {
            return word;
        }
    }
    
    return null;
}

This approach offers several advantages:

  1. Maintainable: Administrators can update the blocked word list without changing code
  2. Transparent: Users are informed when their password contains a blocked word
  3. Logged: All blocked word violations are logged for security monitoring
  4. Customizable: Organization-specific terms can be easily added

Enforcing Password History

To prevent users from cycling through the same passwords, we implemented password history enforcement that leverages Active Directory's built-in password history policy.

private bool CheckPasswordHistory(string username, string newPassword)
{
    try
    {
        // Try to change the password using PrincipalContext to check if it violates 
        password history
        WriteToLog(string.Format("Checking if password violates history policy for 
        user: {0}", username));
        
        using (PrincipalContext context = new PrincipalContext(ContextType.Domain, Domain))
        {
            // Find the user
            using (UserPrincipal user = UserPrincipal.FindByIdentity(context, 
            IdentityType.SamAccountName, username))
            {
                if (user != null)
                {
                    try
                    {
                        // Temporarily try to change password to verify it doesn't 
                        violate history policy
                        // This will throw an exception if the password violates 
                        the history policy
                        user.ChangePassword(newPassword, newPassword + "Temp1234!");
                        
                        // If we get here, it means the password was changed successfully
                        // Now change it back to the original password
                        user.ChangePassword(newPassword + "Temp1234!", newPassword);
                        
                        // If we reach this point, the password does not violate history 
                        policy
                        WriteToLog(string.Format("Password does not violate history 
                        policy for user: {0}", username));
                        return false;
                    }
                    catch (Exception ex)
                    {
                        // Check if the exception message indicates a password history 
                        violation
                        if (ex.Message.Contains("password history") || 
                            ex.Message.Contains("recently used") || 
                            ex.Message.Contains("last used"))
                        {
                            WriteToLog(string.Format("Password violates history policy 
                            for user: {0}. Error: {1}", 
                                username, ex.Message));
                            return true;
                        }
                        
                        // If it's some other error, log it but don't consider it a 
                        history violation
                        WriteToLog(string.Format("Error checking password history 
                        (not a history violation): {0}", ex.Message));
                        return false;
                    }
                }
                else
                {
                    WriteToLog(string.Format("User not found during history check: 
                    {0}", username));
                    return false;
                }
            }
        }
    }
    catch (Exception ex)
    {
        // Log the error but don't block the password change
        WriteToLog(string.Format("Error checking password history: {0}", ex.Message));
        return false;
    }
}

This technique:

  1. Leverages AD's built-in policy: Uses the domain's existing password history settings
  2. Avoids storing password hashes: No need to maintain a separate password history database
  3. Provides clear feedback: Users receive a specific error message when trying to reuse a password
  4. Maintains security: No password data is stored in the application

Security Considerations

Throughout this project, several security considerations became apparent:

  1. Using HTTPS: Always ensure the application is only accessible via HTTPS to protect credentials in transit.
  2. Principle of Least Privilege: The service account should have only the minimum permissions necessary.
  3. Authentication for Access: We added Windows Authentication to ensure only authorized personnel could access the tool.
  4. Comprehensive Logging: All actions should be logged for audit purposes, including who performed them.
  5. Password Complexity Validation: Both client-side and server-side validation is essential.
  6. Error Handling: Detailed error messages should be logged but not exposed to users to prevent information leakage.
  7. Blocked Words Detection: Prevention of common or organization-specific terms in passwords
  8. Password History Enforcement: Prevention of password reuse

The Final Solution

After overcoming these challenges, we now have a secure, functional password reset tool that:

  1. Allows users to reset their Active Directory passwords
  2. Validates current passwords before allowing resets
  3. Enforces password complexity requirements
  4. Provides visual feedback on password strength
  5. Logs all actions for security auditing
  6. Uses direct LDAP operations for reliability
  7. Runs under a service account with minimal permissions
  8. Tracks which authenticated users perform which actions

Lessons Learned

Throughout this project, I learned several valuable lessons:

  1. Start Simple: The lower-level DirectoryEntry API ended up being more reliable than the "simpler" higher-level classes.
  2. Log Everything: Detailed logging saved countless hours of troubleshooting.
  3. Test with Real Service Accounts: The behavior with test accounts often differed from production service accounts.
  4. Security First: Always apply the principle of least privilege, even during development.
  5. Understand Your Environment: Domain structure, UPN suffixes, and LDAP configuration all matter.
  6. Be Persistent: Some of the errors were cryptic, but with persistence and systematic troubleshooting, solutions were found.
The Final Result (visuals)

This is the visual of the final results 



When you enter the password you get a strength meter and a "password policy" checklist as you can see below this is a non-compliant password:



Then when you comply for the password it looks like this:


This means you can then reset your password and when you do this is logged and the user that requested the reset is also logged in the main log file as below, here you can see the requestor the IP and the results.

2025-05-13 11:28:17 - Password reset attempt initiated for user 'mr.forgetful@bythepowerofgreyskull.com' from IP 10.84.11.306 by authenticated user 'BEAR\Bear.User'
2025-05-13 11:28:17 - Verifying current credentials for user 'mr.forgetful@bythepowerofgreyskull.com'
2025-05-13 11:28:17 - Attempting to authenticate user: mr.forgetful against domain: bear.local
2025-05-13 11:28:17 - Authentication successful for user: mr.forgetful
2025-05-13 11:28:17 - Attempting to reset password for user 'mr.forgetful@bythepowerofgreyskull.com'
2025-05-13 11:28:17 - Attempting to reset password for user: mr.forgetful in domain: bear.local by authenticated user: BEAR\Bear.User
2025-05-13 11:28:17 - Password reset successful for user: mr.forgetful
2025-05-13 11:28:17 - Password reset SUCCESSFUL for user 'mr.forgetful@bythepowerofgreyskull.com'

Conclusion

Building a secure Active Directory password reset tool presented more challenges than initially expected, but the end result is a robust, secure solution that meets all our requirements. By sharing these pitfalls and solutions, I hope to save others from experiencing the same frustrations and help them build more secure, reliable tools for their organizations.

Previous Post Next Post

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