繼完成 驗證 AD 密碼的 API 後,接著嘗試寫修改密碼的部份,跌跌撞撞的也總算完成。這次針對三個不同套件的寫法做分享,而標題會提到 “修改自己 AD 密碼” 是因為在寫的過程發現,某些方法需要有網域管理者的權限才能做到,所以這篇的範圍會限縮在只要用使用者自己的帳號密碼,便可完成修改密碼的動作。
《專案範本》
- ASP .NET Core Web API
《套件》
一般來說,要修改 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 工具來驗證會比較準確。另外,記得要確認測試帳號的「使用者不能變更密碼」沒有打勾,我卡在這邊好幾天,最後才發現是我忘了取消勾選..。
《參考連結》
[…] 密碼驗證》與《AD 密碼修改》兩個 API 後,接下來用 .NET MAUI Blazor 來寫桌面端的程式。稍微瞭解了 Blazor […]