透过 ASP.NET Core 写一个让使用者可以修改自己 AD 密码的 Web API

  继完成 验证 AD 密码的 API 后来,接着尝试写修改密码的部份,跌跌撞撞的也总算完成。这次针对三个不同套件的写法做分享,而标题会提到 “修改自己 AD 密码” 是因为在写的过程发现,某些方法需要有网域管理者的权限才能做到,所以这篇的范围会限缩在只要用使用者自己的帐号密码,便可完成修改密码的动作。


《套件》
  一般来说,要修改 AD 会用到的套件可能会有以下三种:

  • 系统.目录服务.协议 (DSP)
  • 系统目录服务 (十二烷基硫酸钠)
  • 系统.目录服务.帐户管理 (调幅调制)

三种套件比较:

  • 操作层级: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 (活动目录)。

服务/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;
            }
        }
    }
}

控制器/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("密碼修改失敗。");
        }
    }
}

模型/AuthRequests.cs

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

模型/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;
    }
}

应用程序设置.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"
    }
}

程序.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 工具来验证会比较准确。另外,记得要确认测试帐号的「使用者不能变更密码」没有打勾,我卡在这边好几天,最后才发现是我忘了取消勾选..。

《参考连结》

发表评论

请注意:: 评论审核已启用,可能会耽误您的评论. 有没有必要重新提交您的评论.