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;
|
|
/// <summary>
|
/// 在已通过 Windows / Negotiate 身份(HttpContext.User)后,做 LDAP 校验并写入 Board Session。
|
/// </summary>
|
public sealed class AdAuthenticationService
|
{
|
private readonly LdapAuthService _ldap;
|
private readonly ILogger<AdAuthenticationService> _log;
|
|
public AdAuthenticationService(
|
LdapAuthService ldap,
|
ILogger<AdAuthenticationService> 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;
|
}
|
|
/// <summary>
|
/// 从已认证的 Windows 主体解析 sAMAccountName(用于 LDAP 过滤 {{username}})。
|
/// 部分环境下 <see cref="IIdentity.Name"/> 为空,需读 WindowsAccountName / UPN 声明。
|
/// </summary>
|
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);
|
}
|
|
/// <summary>
|
/// 在 Kestrel 上通常只注册了 Negotiate,未注册 IIS scheme;
|
/// 若直接调用 <see cref="AuthenticationHttpContextExtensions.AuthenticateAsync(HttpContext, string)"/> 会抛
|
/// <see cref="InvalidOperationException"/>(无对应处理器),导致一键登录 HTTP 500。
|
/// </summary>
|
private static async Task<ClaimsPrincipal?> 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;
|
}
|
|
/// <summary>
|
/// 依次尝试当前 User、IIS Windows、Negotiate。
|
/// IIS / Kestrel 均可:未注册的 scheme 会被跳过,不会抛错。
|
/// </summary>
|
public static async Task<ClaimsPrincipal?> 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;
|
}
|
|
/// <summary>
|
/// login 为已通过认证主体(IIS Windows 或 Negotiate AuthenticateAsync 成功)。
|
/// </summary>
|
public async Task<AdLoginResult> 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);
|
}
|
}
|