透過 ASP.NET Core 寫一個讓使用者可以修改自己 AD 密碼的 Web API

  繼完成 驗證 AD 密碼的 API 後,接著嘗試寫修改密碼的部份,跌跌撞撞的也總算完成。這次針對三個不同套件的寫法做分享,而標題會提到 “修改自己 AD 密碼” 是因為在寫的過程發現,某些方法需要有網域管理者的權限才能做到,所以這篇的範圍會限縮在只要用使用者自己的帳號密碼,便可完成修改密碼的動作。


《套件》
  一般來說,要修改 AD 會用到的套件可能會有以下三種:

  • System.DirectoryServices.Protocols (S.DS.P)
  • System.DirectoryServices (S.DS)
  • System.DirectoryServices.AccountManagement (S.DS.AM)

三種套件比較:

  • 操作層級:S.DS.AM > S.DS > S.DS.P
  • 複雜度:S.DS.P > S.DS > S.DS.AM
  • 靈活度:S.DS.P > S.DS > S.DS.AM
  • 速度:S.DS.P > S.DS > S.DS.AM

另外,由於 S.DS.P 是基於 LDAP 協定的低層級操作,所以相容性也高,對於 OpenLDAP、AD 的支援都沒有問題,但 S.DS 跟 S.DS.AM 可能就會比較受限,適用於微軟的 AD (Active Directory)。

Services/PasswordManagementService.cs

using AD.Models;
using Microsoft.Extensions.Options;
using System.DirectoryServices;
using System.DirectoryServices.Protocols;
using System.Net;
using System.Text;
using System.DirectoryServices.AccountManagement;
using System.Runtime.Versioning;


namespace AD.Services
{
    public class PasswordManagementService(IOptions<LdapSettings> ldapSettings)
    {
        private readonly string _ldapServer = ldapSettings.Value.Server;
        private readonly string _domain = ldapSettings.Value.Domain;
        private readonly string _baseDn = ldapSettings.Value.BaseDn;

        // 修改密碼 (透過 System.DirectoryServices.Protocols)
        public bool ChangePasswordSdsP(string username, string oldPassword, string newPassword)
        {
            try
            {
                using var connection = new LdapConnection(new LdapDirectoryIdentifier(_ldapServer, 636)); // 修改密碼必須使用 LDAPS 636 port。
                connection.Credential = new NetworkCredential(username, oldPassword, _domain);
                connection.SessionOptions.SecureSocketLayer = true; // 修改密碼必須使用 LDAPS。
                connection.AuthType = AuthType.Kerberos; // 如果使用 Negotiate 會先嘗試 Kerberos,失敗再改試 NTLM。
                connection.Bind(); // 嘗試綁定,成功表示驗證通過

                // 以 sAMAccountName 查詢使用者的 DN
                SearchRequest searchRequest = new(
                    _baseDn, // 根目錄
                    $"(sAMAccountName={username})", // 根據 sAMAccountName 查詢
                    System.DirectoryServices.Protocols.SearchScope.Subtree,
                    "distinguishedName" // 只獲取 DN 屬性
                );

                SearchResponse searchResponse = (SearchResponse)connection.SendRequest(searchRequest);

                if (searchResponse.Entries.Count == 0)
                {
                    Console.WriteLine("User not found.");
                    return false;
                }

                string userDn = searchResponse.Entries[0].DistinguishedName;

                // 使用 LDAP 修改密碼屬性 (unicodePwd) 時,
                // 只有高權限帳號 (如 Domain Admins) 可以對 unicodePwd 屬性執行修改 (DirectoryAttributeOperation.Replace)。
                // 若要讓一般使用者更改自己的密碼,必須透過 LDAPS (安全通道),且同時送出 delete 與 add 操作來替換密碼。
                var deleteOldPassword = new DirectoryAttributeModification
                {
                    Operation = DirectoryAttributeOperation.Delete,
                    Name = "unicodePwd"
                };
                deleteOldPassword.Add(Encoding.Unicode.GetBytes($"\"{oldPassword}\""));

                var addNewPassword = new DirectoryAttributeModification
                {
                    Operation = DirectoryAttributeOperation.Add,
                    Name = "unicodePwd"
                };
                addNewPassword.Add(Encoding.Unicode.GetBytes($"\"{newPassword}\""));

                // 組合 ModifyRequest,執行 Delete + Add 操作
                var request = new ModifyRequest(
                    userDn,
                    deleteOldPassword,
                    addNewPassword
                );

                connection.SendRequest(request);
                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Error: {ex.Message}");
                return false;
            }
        }

        // 修改密碼 (透過 System.DirectoryServices)
        [SupportedOSPlatform("windows")] // 宣告以下方法僅適用於 Windows,避免 PrincipalContext 等 API 被提示要留意跨平臺問題。
        public bool ChangePasswordSds(string username, string oldPassword, string newPassword)
        {
            try
            {
                // 使用 DirectorySearcher 以 sAMAccountName 查詢使用者的 DN
                DirectorySearcher searcher = new(new DirectoryEntry($"LDAP://{_baseDn}"));
                searcher.Filter = $"(sAMAccountName={username})";
                searcher.PropertiesToLoad.Add("distinguishedName");

                SearchResult result = searcher.FindOne();

                if (result != null)
                {
                    string userDn = result.Properties["distinguishedName"][0].ToString();

                    using DirectoryEntry user = new($"LDAP://{userDn}", username, oldPassword);
                    user.Invoke("ChangePassword", [oldPassword, newPassword]);
                    user.CommitChanges();
                    return true;
                }
                else
                {
                    Console.WriteLine("User not found.");
                    return false;
                }

            }
            catch (Exception ex)
            {
                Console.WriteLine($"Error: {ex.Message}");
                return false;
            }
        }

        // 修改密碼 (透過 System.DirectoryServices.AccountManagement)
        [SupportedOSPlatform("windows")] // 宣告以下方法僅適用於 Windows,避免 PrincipalContext 等 API 被提示要留意跨平臺問題。
        public bool ChangePasswordSdsAm(string username, string oldPassword, string newPassword)
        {
            try
            {
                using var context = new PrincipalContext(ContextType.Domain, _domain);

                // 驗證舊密碼是否正確
                if (!context.ValidateCredentials(username, oldPassword))
                {
                    throw new UnauthorizedAccessException("用戶名或舊密碼不正確!");
                }

                // 使用 UserPrincipal 修改密碼
                using var user = UserPrincipal.FindByIdentity(context, username) ?? throw new Exception("找不到指定的使用者!");
                user.ChangePassword(oldPassword, newPassword);
                return true;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"修改密碼時發生錯誤: {ex.Message}");
                return false;
            }
        }
    }
}

Controllers/PasswordManagementController.cs

using AD.Models;
using AD.Services;
using Microsoft.AspNetCore.Mvc;
using System.Runtime.Versioning;

namespace AD.Controllers
{
    [Route("Password")]
    [ApiController]

    public class PasswordManagementController(PasswordManagementService passwordManagement) : ControllerBase
    {
        // 修改密碼 (透過 System.DirectoryServices.Protocols)
        [HttpPost("ChangeSdsP")]
        public IActionResult ChangeSdsP([FromBody] PasswordChangeRequest request)
        {
            if (passwordManagement.ChangePasswordSdsP(request.Username, request.OldPassword, request.NewPassword))
            {
                return Ok("密碼修改成功。");
            }
            return BadRequest("密碼修改失敗。");
        }

        // 修改密碼 (透過 System.DirectoryServices)
        [HttpPost("ChangeSds")]
        [SupportedOSPlatform("windows")] // 宣告以下用到的方法僅適用於 Windows,避免 ChangePassword 方法被提示要留意跨平臺問題。
        public IActionResult ChangeSds([FromBody] PasswordChangeRequest request)
        {
            if (passwordManagement.ChangePasswordSds(request.Username, request.OldPassword, request.NewPassword))
            {
                return Ok("密碼修改成功。");
            }
            return BadRequest("密碼修改失敗。");
        }

        // 修改密碼 (透過 System.DirectoryServices.AccountManagement)
        [HttpPost("ChangeSdsAm")]
        [SupportedOSPlatform("windows")] // 宣告以下用到的方法僅適用於 Windows,避免 ChangePassword 方法被提示要留意跨平臺問題。
        public IActionResult ChangeSdsAm([FromBody] PasswordChangeRequest request)
        {
            if (passwordManagement.ChangePasswordSdsAm(request.Username, request.OldPassword, request.NewPassword))
            {
                return Ok("密碼修改成功。");
            }
            return BadRequest("密碼修改失敗。");
        }
    }
}

Models/AuthRequests.cs

namespace AD.Models
{
    public class PasswordChangeRequest
    {
        public string Username { get; set; }        // 使用者帳戶
        public string OldPassword { get; set; }   // 舊密碼
        public string NewPassword { get; set; }   // 新密碼
    }
}

Models/LdapSettings.cs

namespace AD.Models
{
    public class LdapSettings
    {
        public string Server { get; set; } = string.Empty;
        public string Domain { get; set; } = string.Empty;
        public string BaseDn { get; set; } = string.Empty;
    }
}

appsettings.json

{
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    },

    "AllowedHosts": "*",

    "LdapSettings": {
        "Server": "dc1.abc.com.tw", // 如果是用 Kerberos 驗證,AD 的伺服器不可以使用 IP。
        "Domain": "abc.com.tw",
        "BaseDn": "DC=abc,DC=com,DC=tw"
    }
}

Program.cs

using AD.Models;
using AD.Services;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
builder.Services.Configure<LdapSettings>(builder.Configuration.GetSection("LdapSettings")); // 讀取 appsettings.json 的 LdapSettings 資料。
builder.Services.AddScoped<PasswordManagementService>();

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.

// 讓 Swagger 只在開發環境時使用。
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

  測試時,建議透過 Swagger,會方便許多。

  最後,DC 伺服器要確認有支援 LDAPS,除了 636 要 ping 的通,還要幫 DC 裝憑證,可以用 DC 上的 ldp.exe 工具來驗證會比較準確。另外,記得要確認測試帳號的「使用者不能變更密碼」沒有打勾,我卡在這邊好幾天,最後才發現是我忘了取消勾選..。

《參考連結》

Leave a Comment

Please note: Comment moderation is enabled and may delay your comment. There is no need to resubmit your comment.