using BoardApp.Session; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Negotiate; using Microsoft.AspNetCore.Server.IISIntegration; using Microsoft.Extensions.Logging; using System.Security.Claims; namespace BoardApp.Services; /// /// 在已通过 Windows / Negotiate 身份(HttpContext.User)后,做 LDAP 校验并写入 Board Session。 /// public sealed class AdAuthenticationService { private readonly LdapAuthService _ldap; private readonly ILogger _log; public AdAuthenticationService( LdapAuthService ldap, ILogger log) { _ldap = ldap; _log = log; } public sealed record AdLoginResult(bool Ok, string? Error, string? Redirect, string? Username); public static string? NormalizeWindowsIdentityName(string? raw) { var s = (raw ?? "").Trim(); if (s.Length == 0) return null; var idx = s.IndexOf('\\'); if (idx >= 0 && idx + 1 < s.Length) return s[(idx + 1)..]; var at = s.IndexOf('@'); if (at > 0) return s[..at]; return s; } /// /// 从已认证的 Windows 主体解析 sAMAccountName(用于 LDAP 过滤 {{username}})。 /// 部分环境下 为空,需读 WindowsAccountName / UPN 声明。 /// public static string? TryGetWindowsSamAccountName(ClaimsPrincipal login) { if (login.Identity?.IsAuthenticated != true) return null; var fromId = NormalizeWindowsIdentityName(login.Identity?.Name); if (!string.IsNullOrWhiteSpace(fromId)) return fromId; var winAcc = login.FindFirst(ClaimTypes.WindowsAccountName)?.Value; var fromWin = NormalizeWindowsIdentityName(winAcc); if (!string.IsNullOrWhiteSpace(fromWin)) return fromWin; var upn = login.FindFirst(ClaimTypes.Upn)?.Value; return NormalizeWindowsIdentityName(upn); } /// /// 在 Kestrel 上通常只注册了 Negotiate,未注册 IIS scheme; /// 若直接调用 会抛 /// (无对应处理器),导致一键登录 HTTP 500。 /// private static async Task TryAuthenticateSchemeAsync(HttpContext httpContext, string scheme) { try { var r = await httpContext.AuthenticateAsync(scheme); if (r.Succeeded && r.Principal?.Identity?.IsAuthenticated == true) return r.Principal; } catch (InvalidOperationException) { // 未注册该认证 scheme(例如本机 Kestrel 无 IIS 集成处理器) } return null; } /// /// 依次尝试当前 User、IIS Windows、Negotiate。 /// IIS / Kestrel 均可:未注册的 scheme 会被跳过,不会抛错。 /// public static async Task ResolveWindowsPrincipalAsync( HttpContext httpContext, ClaimsPrincipal? userFromContext) { if (userFromContext?.Identity?.IsAuthenticated == true) return userFromContext; var fromIis = await TryAuthenticateSchemeAsync(httpContext, IISDefaults.AuthenticationScheme); if (fromIis is not null) return fromIis; var fromNeg = await TryAuthenticateSchemeAsync(httpContext, NegotiateDefaults.AuthenticationScheme); if (fromNeg is not null) return fromNeg; return null; } /// /// login 为已通过认证主体(IIS Windows 或 Negotiate AuthenticateAsync 成功)。 /// public async Task AdLoginAsync( HttpContext httpContext, ClaimsPrincipal login, string? next, string pathBase) { if (login?.Identity?.IsAuthenticated != true) { return new AdLoginResult(false, "未通过 Windows 集成身份验证", null, null); } var winName = TryGetWindowsSamAccountName(login!); if (string.IsNullOrWhiteSpace(winName)) { return new AdLoginResult(false, "无法解析域账号", null, null); } var appCfg = httpContext.Items["BoardAppConfig"] as Config.AppConfig; var skipAd = appCfg?.SkipLdapAuthForDebug == true; if (!skipAd) { var r = await _ldap.VerifyUserForIntegratedLogin(winName); if (!r.Ok) { var msg = r.Reason switch { LdapAuthService.LdapVerifyFailReason.UserNotFound => "域账号在目录中不存在或无权访问", LdapAuthService.LdapVerifyFailReason.NotInAllowedGroup => "该域账号不在允许访问的组内", LdapAuthService.LdapVerifyFailReason.ConnectionError => "目录服务不可用,请稍后重试", _ => "无法完成目录校验" }; _log.LogWarning("Negotiate 用户 {User} LDAP 校验失败: {Reason}", winName, r.Reason); return new AdLoginResult(false, msg, null, null); } } httpContext.Session.SetJson("user", new SessionUser { Username = winName }); await httpContext.Session.CommitAsync(); var safeNext = string.IsNullOrWhiteSpace(next) ? pathBase + "/" : (next.StartsWith(pathBase + "/", StringComparison.Ordinal) && !next.Contains("//", StringComparison.Ordinal) ? next : pathBase + "/"); return new AdLoginResult(true, null, safeNext, winName); } }