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);
}
}