Skip to content

Commit 5211b4c

Browse files
committed
Add LDAP/Active Directory authentication support
Adds native LDAP SSO authentication allowing users to sign in with their Active Directory or LDAP directory credentials. Supports plain LDAP, StartTLS (port 389), and LDAPS (port 636). Includes group-to- role mapping, configurable auto-provisioning, and full environment variable support for Docker deployments. Uses System.DirectoryServices.Protocols (Microsoft built-in library). On Linux, requires libldap2 to be installed.
1 parent 7720686 commit 5211b4c

8 files changed

Lines changed: 1280 additions & 27 deletions

File tree

DnsServerCore/Auth/AuthManager.cs

Lines changed: 449 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/*
2+
Technitium DNS Server
3+
Copyright (C) 2026 Shreyas Zare (shreyas@technitium.com)
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU General Public License as published by
7+
the Free Software Foundation, either version 3 of the License, or
8+
(at your option) any later version.
9+
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU General Public License for more details.
14+
15+
You should have received a copy of the GNU General Public License
16+
along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
18+
*/
19+
20+
using System;
21+
using System.Collections.Generic;
22+
using System.DirectoryServices.Protocols;
23+
using System.Net;
24+
using System.Text;
25+
using System.Threading.Tasks;
26+
27+
namespace DnsServerCore.Auth
28+
{
29+
sealed class LdapAuthResult
30+
{
31+
public bool Success { get; init; }
32+
public string LdapIdentifier { get; init; }
33+
public string DisplayName { get; init; }
34+
public IReadOnlyList<string> Groups { get; init; }
35+
public string ErrorMessage { get; init; }
36+
37+
public static LdapAuthResult Failed(string message) =>
38+
new LdapAuthResult { Success = false, ErrorMessage = message };
39+
}
40+
41+
sealed class LdapAuthProvider
42+
{
43+
#region variables
44+
45+
readonly string _server;
46+
readonly int _port;
47+
readonly bool _useSsl;
48+
readonly bool _ignoreSslErrors;
49+
readonly string _bindDn;
50+
readonly string _bindPassword;
51+
readonly string _searchBase;
52+
readonly string _userFilter;
53+
readonly string _groupAttribute;
54+
55+
#endregion
56+
57+
#region constructor
58+
59+
public LdapAuthProvider(string server, int port, bool useSsl, bool ignoreSslErrors, string bindDn, string bindPassword, string searchBase, string userFilter, string groupAttribute)
60+
{
61+
_server = server;
62+
_port = port;
63+
_useSsl = useSsl;
64+
_ignoreSslErrors = ignoreSslErrors;
65+
_bindDn = bindDn;
66+
_bindPassword = bindPassword;
67+
_searchBase = searchBase;
68+
_userFilter = string.IsNullOrWhiteSpace(userFilter) ? "(sAMAccountName={0})" : userFilter;
69+
_groupAttribute = string.IsNullOrWhiteSpace(groupAttribute) ? "memberOf" : groupAttribute;
70+
}
71+
72+
#endregion
73+
74+
#region private
75+
76+
private LdapConnection CreateBoundConnection(string bindDn, string bindPassword)
77+
{
78+
var identifier = new LdapDirectoryIdentifier(_server, _port);
79+
var conn = new LdapConnection(identifier);
80+
conn.SessionOptions.ProtocolVersion = 3;
81+
conn.SessionOptions.ReferralChasing = ReferralChasingOptions.None;
82+
conn.Timeout = TimeSpan.FromSeconds(15);
83+
conn.AuthType = AuthType.Basic;
84+
85+
if (_ignoreSslErrors)
86+
conn.SessionOptions.VerifyServerCertificate = (connection, certificate) => true;
87+
88+
// Port 636 = LDAPS (SSL-wrapped from the start); all other ports use StartTLS
89+
bool useLdaps = _useSsl && _port == 636;
90+
if (useLdaps)
91+
conn.SessionOptions.SecureSocketLayer = true;
92+
93+
if (_useSsl && !useLdaps)
94+
conn.SessionOptions.StartTransportLayerSecurity(null);
95+
96+
conn.Credential = new NetworkCredential(bindDn, bindPassword);
97+
conn.Bind();
98+
return conn;
99+
}
100+
101+
private static string LdapFilterEscape(string value)
102+
{
103+
// RFC 4515 escape special filter characters
104+
return new StringBuilder(value)
105+
.Replace("\\", "\\5c")
106+
.Replace("*", "\\2a")
107+
.Replace("(", "\\28")
108+
.Replace(")", "\\29")
109+
.Replace("\0", "\\00")
110+
.ToString();
111+
}
112+
113+
private static string GetCnFromDn(string dn)
114+
{
115+
if (string.IsNullOrEmpty(dn))
116+
return dn;
117+
118+
int eq = dn.IndexOf('=');
119+
int comma = dn.IndexOf(',');
120+
121+
if (eq < 0)
122+
return dn;
123+
124+
int end = comma > eq ? comma : dn.Length;
125+
return dn.Substring(eq + 1, end - eq - 1).Trim();
126+
}
127+
128+
private static string GetAttributeValue(SearchResultEntry entry, string attributeName)
129+
{
130+
DirectoryAttribute attr = entry.Attributes[attributeName];
131+
if (attr == null || attr.Count == 0)
132+
return null;
133+
return attr[0] as string;
134+
}
135+
136+
#endregion
137+
138+
#region public
139+
140+
public Task<LdapAuthResult> AuthenticateAsync(string username, string password)
141+
{
142+
return Task.Run(() =>
143+
{
144+
// Step 1: bind service account and search for the user
145+
string userDn;
146+
string displayName;
147+
string userPrincipalName;
148+
List<string> groups;
149+
150+
try
151+
{
152+
using LdapConnection searchConn = CreateBoundConnection(_bindDn, _bindPassword);
153+
154+
string filter = string.Format(_userFilter, LdapFilterEscape(username));
155+
string[] attrs = new[] { "distinguishedName", "cn", "displayName", "userPrincipalName", _groupAttribute };
156+
157+
var request = new SearchRequest(_searchBase, filter, SearchScope.Subtree, attrs);
158+
request.TimeLimit = TimeSpan.FromSeconds(15);
159+
160+
var response = (SearchResponse)searchConn.SendRequest(request);
161+
162+
SearchResultEntry entry = response.Entries.Count > 0 ? response.Entries[0] : null;
163+
164+
if (entry is null)
165+
return LdapAuthResult.Failed("User not found in directory.");
166+
167+
userDn = entry.DistinguishedName;
168+
169+
displayName = GetAttributeValue(entry, "displayName");
170+
if (string.IsNullOrEmpty(displayName))
171+
displayName = GetAttributeValue(entry, "cn");
172+
if (string.IsNullOrEmpty(displayName))
173+
displayName = username;
174+
175+
userPrincipalName = GetAttributeValue(entry, "userPrincipalName");
176+
177+
groups = new List<string>();
178+
DirectoryAttribute groupAttr = entry.Attributes[_groupAttribute];
179+
if (groupAttr != null)
180+
{
181+
foreach (string groupDn in (string[])groupAttr.GetValues(typeof(string)))
182+
{
183+
string cn = GetCnFromDn(groupDn);
184+
if (!string.IsNullOrEmpty(cn))
185+
groups.Add(cn);
186+
}
187+
}
188+
}
189+
catch (LdapException ex) when (ex.ErrorCode == 49)
190+
{
191+
return LdapAuthResult.Failed("Service account credentials are invalid.");
192+
}
193+
catch (Exception ex)
194+
{
195+
return LdapAuthResult.Failed($"Service account bind/search failed: {ex.Message}");
196+
}
197+
198+
// Step 2: re-bind as the user to validate their password
199+
// Prefer UPN (user@domain) over full DN — more reliable with AD
200+
string bindUsername = !string.IsNullOrEmpty(userPrincipalName) ? userPrincipalName : userDn;
201+
202+
try
203+
{
204+
using LdapConnection userConn = CreateBoundConnection(bindUsername, password);
205+
}
206+
catch (LdapException ex) when (ex.ErrorCode == 49)
207+
{
208+
return LdapAuthResult.Failed("Invalid credentials.");
209+
}
210+
catch (Exception ex)
211+
{
212+
return LdapAuthResult.Failed($"User bind failed: {ex.Message}");
213+
}
214+
215+
return new LdapAuthResult
216+
{
217+
Success = true,
218+
LdapIdentifier = userDn,
219+
DisplayName = displayName,
220+
Groups = groups
221+
};
222+
});
223+
}
224+
225+
public Task<string> TestConnectionAsync()
226+
{
227+
return Task.Run(() =>
228+
{
229+
try
230+
{
231+
using LdapConnection conn = CreateBoundConnection(_bindDn, _bindPassword);
232+
return (string)null; // null = success
233+
}
234+
catch (Exception ex)
235+
{
236+
Exception inner = ex;
237+
while (inner.InnerException != null) inner = inner.InnerException;
238+
return inner == ex ? ex.Message : $"{ex.Message}{inner.GetType().Name}: {inner.Message}";
239+
}
240+
});
241+
}
242+
243+
#endregion
244+
}
245+
}

0 commit comments

Comments
 (0)