wangzhibo
2026-05-09 53418655c061ac26f66058253f39200aced1ab1d
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
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);
    }
}