using BoardApp.Config;
|
using Novell.Directory.Ldap;
|
|
namespace BoardApp.Services;
|
|
public sealed class LdapAuthService
|
{
|
private readonly BoardConfigLoader _cfg;
|
|
public LdapAuthService(BoardConfigLoader cfg)
|
{
|
_cfg = cfg;
|
}
|
|
public enum LdapVerifyFailReason
|
{
|
InvalidCredentials,
|
UserNotFound,
|
ConnectionError,
|
NotInAllowedGroup
|
}
|
|
public sealed record LdapVerifyResult(bool Ok, LdapVerifyFailReason? Reason);
|
|
private static string EscapeLdapFilterValue(string value)
|
{
|
// RFC4515 subset used by Node implementation.
|
return (value ?? "").Replace(@"\", @"\5c")
|
.Replace("*", @"\2a")
|
.Replace("(", @"\28")
|
.Replace(")", @"\29")
|
.Replace("\0", @"\00");
|
}
|
|
private static LdapConnection CreateConnection(LdapConfig cfg)
|
{
|
var uri = new Uri(cfg.Url);
|
var conn = new LdapConnection { SecureSocketLayer = uri.Scheme.Equals("ldaps", StringComparison.OrdinalIgnoreCase) };
|
conn.Connect(uri.Host, uri.Port > 0 ? uri.Port : (conn.SecureSocketLayer ? 636 : 389));
|
|
if (conn.SecureSocketLayer && cfg.TlsOptions?.RejectUnauthorized == false)
|
{
|
conn.UserDefinedServerCertValidationDelegate += (_, _, _, _) => true;
|
}
|
|
return conn;
|
}
|
|
private static string? SearchUserDn(LdapConnection conn, string searchBase, string filter)
|
{
|
var lc = conn.Search(
|
searchBase,
|
LdapConnection.ScopeSub,
|
filter,
|
new[] { "dn" },
|
false
|
);
|
|
while (lc.HasMore())
|
{
|
try
|
{
|
var entry = lc.Next();
|
return entry.Dn;
|
}
|
catch (LdapException)
|
{
|
return null;
|
}
|
}
|
return null;
|
}
|
|
private static string[] GetAllowedGroupDns(LdapConfig cfg)
|
{
|
var list = new List<string>();
|
if (!string.IsNullOrWhiteSpace(cfg.AllowedGroupDN))
|
{
|
list.Add(cfg.AllowedGroupDN.Trim());
|
}
|
if (cfg.AllowedGroupDNs is not null)
|
{
|
list.AddRange(cfg.AllowedGroupDNs.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()));
|
}
|
return list.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
}
|
|
private static bool IsMemberOfAllowedGroups(LdapConnection conn, string userDn, string searchBase, string[] allowedGroupDns)
|
{
|
if (allowedGroupDns.Length == 0) return true;
|
|
// Read memberOf from the user entry (fast path).
|
var filter = "(distinguishedName=" + EscapeLdapFilterValue(userDn) + ")";
|
var res = conn.Search(searchBase, LdapConnection.ScopeSub, filter, new[] { "memberOf" }, false);
|
while (res.HasMore())
|
{
|
try
|
{
|
var entry = res.Next();
|
var memberOf = entry.GetAttribute("memberOf")?.StringValueArray ?? Array.Empty<string>();
|
foreach (var g in memberOf)
|
{
|
if (allowedGroupDns.Any(agd => string.Equals(agd, g, StringComparison.OrdinalIgnoreCase)))
|
{
|
return true;
|
}
|
}
|
return false;
|
}
|
catch (LdapException)
|
{
|
return false;
|
}
|
}
|
|
// If user entry not found or no memberOf, treat as not allowed when restriction exists.
|
return false;
|
}
|
|
public async Task<LdapVerifyResult> VerifyCredentials(string username, string password)
|
{
|
var user = (username ?? "").Trim();
|
if (user.Length == 0 || string.IsNullOrEmpty(password))
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.InvalidCredentials);
|
}
|
|
var cfg = _cfg.LoadLdapConfig();
|
var filter = (cfg.SearchFilter ?? "").Replace("{{username}}", EscapeLdapFilterValue(user));
|
var allowedGroupDns = GetAllowedGroupDns(cfg);
|
|
return await Task.Run(() =>
|
{
|
try
|
{
|
using var searchConn = CreateConnection(cfg);
|
searchConn.Bind(cfg.BindDN, cfg.BindPassword);
|
|
var userDn = SearchUserDn(searchConn, cfg.SearchBase, filter);
|
if (string.IsNullOrWhiteSpace(userDn))
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.UserNotFound);
|
}
|
if (!IsMemberOfAllowedGroups(searchConn, userDn, cfg.SearchBase, allowedGroupDns))
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.NotInAllowedGroup);
|
}
|
|
using var verifyConn = CreateConnection(cfg);
|
try
|
{
|
verifyConn.Bind(userDn, password);
|
return new LdapVerifyResult(true, null);
|
}
|
catch
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.InvalidCredentials);
|
}
|
}
|
catch
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.ConnectionError);
|
}
|
});
|
}
|
|
/// <summary>
|
/// 用于 Windows 集成登录:不验证密码,只校验“用户是否存在 + 是否属于允许组(若配置)”。
|
/// </summary>
|
public async Task<LdapVerifyResult> VerifyUserForIntegratedLogin(string username)
|
{
|
var user = (username ?? "").Trim();
|
if (user.Length == 0)
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.InvalidCredentials);
|
}
|
|
var cfg = _cfg.LoadLdapConfig();
|
var filter = (cfg.SearchFilter ?? "").Replace("{{username}}", EscapeLdapFilterValue(user));
|
var allowedGroupDns = GetAllowedGroupDns(cfg);
|
|
return await Task.Run(() =>
|
{
|
try
|
{
|
using var searchConn = CreateConnection(cfg);
|
searchConn.Bind(cfg.BindDN, cfg.BindPassword);
|
var userDn = SearchUserDn(searchConn, cfg.SearchBase, filter);
|
if (string.IsNullOrWhiteSpace(userDn))
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.UserNotFound);
|
}
|
if (!IsMemberOfAllowedGroups(searchConn, userDn, cfg.SearchBase, allowedGroupDns))
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.NotInAllowedGroup);
|
}
|
return new LdapVerifyResult(true, null);
|
}
|
catch
|
{
|
return new LdapVerifyResult(false, LdapVerifyFailReason.ConnectionError);
|
}
|
});
|
}
|
}
|
|