Files
Roy Hagland 448c78637c Add LDAP/Active Directory authentication support (#1869)
* 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.

* Fix OpenLDAP group mapping gap and ldapGroupMap parse error

LdapAuthProvider: fall back to a reverse group-membership search
(member/uniqueMember/memberUid) when the user entry has no values for
the configured group attribute. The default "memberOf" is an
AD-native reverse-linked attribute; stock OpenLDAP (without the
memberof overlay) doesn't populate it, so LDAP group-based role
mapping was silently doing nothing on that directory even though
login itself worked. Fallback is best-effort and only runs when the
primary lookup found no groups, so directories that already populate
memberOf (AD, OpenLDAP with the overlay) are unaffected.

Extensions.cs: TryQueryOrFormArray's colspan overload threw a raw
"Offset and length were out of bounds" exception when the submitted
value wasn't an exact multiple of colspan entries (e.g. ldapGroupMap
without the '|' delimiter). Now throws a clear DnsWebServiceException
instead, surfaced through the existing API error handling. This is
shared parsing code also used by ssoGroupMap and others, not LDAP
specific, but was found while testing the LDAP config endpoint.

Verified against a live OpenLDAP instance: both groupOfNames (member)
and posixGroup (memberUid) group mappings now correctly elevate
permissions without requiring the memberof overlay.
2026-09-05 19:04:16 +05:30

423 lines
14 KiB
C#

/*
Technitium DNS Server
Copyright (C) 2026 Shreyas Zare ([email protected])
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using DnsServerCore.Auth;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using System;
using System.Net;
using System.Text.Json;
using TechnitiumLibrary;
namespace DnsServerCore
{
static class Extensions
{
static readonly string[] HTTP_METHODS = new string[] { "GET", "POST" };
static readonly char[] COMMA_SEPARATOR = new char[] { ',' };
public static IPAddress GetLocalIpAddress(this HttpContext context)
{
try
{
IPAddress localIP = context.Connection.LocalIpAddress;
if (localIP is null)
return IPAddress.Any;
if (localIP.IsIPv4MappedToIPv6)
localIP = localIP.MapToIPv4();
return localIP;
}
catch
{
return IPAddress.Any;
}
}
public static UserSession GetCurrentSession(this HttpContext context)
{
if (context.Items["session"] is UserSession userSession)
return userSession;
throw new InvalidOperationException();
}
public static Utf8JsonWriter GetCurrentJsonWriter(this HttpContext context)
{
if (context.Items["jsonWriter"] is Utf8JsonWriter jsonWriter)
return jsonWriter;
throw new InvalidOperationException();
}
public static IEndpointConventionBuilder MapGetAndPost(this IEndpointRouteBuilder endpoints, string pattern, RequestDelegate requestDelegate)
{
return endpoints.MapMethods(pattern, HTTP_METHODS, requestDelegate);
}
public static IEndpointConventionBuilder MapGetAndPost(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler)
{
return endpoints.MapMethods(pattern, HTTP_METHODS, handler);
}
public static string QueryOrForm(this HttpRequest request, string parameter)
{
if (request.HttpContext.Items.TryGetValue("jsonContent", out object jsonObject))
{
JsonDocument json = (JsonDocument)jsonObject;
if (!json.RootElement.TryGetProperty(parameter, out JsonElement jsonValue))
return null;
switch (jsonValue.ValueKind)
{
case JsonValueKind.String:
return jsonValue.GetString();
case JsonValueKind.Number:
case JsonValueKind.True:
case JsonValueKind.False:
return jsonValue.ToString();
case JsonValueKind.Null:
return null;
default:
throw new InvalidOperationException();
}
}
string value = request.Query[parameter];
if ((value is null) && request.HasFormContentType)
value = request.Form[parameter];
return value;
}
public static bool TryQueryOrForm(this HttpRequest request, string parameter, out string value)
{
value = request.QueryOrForm(parameter);
if (value is null)
return false;
return true;
}
public static bool TryQueryOrForm<T>(this HttpRequest request, string parameter, Func<string, T> parse, out T value)
{
string strValue = request.QueryOrForm(parameter);
if (strValue is null)
{
value = default;
return false;
}
value = parse(strValue);
return true;
}
public static string GetQueryOrForm(this HttpRequest request, string parameter)
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
throw new DnsWebServiceException("Parameter '" + parameter + "' missing.");
return value;
}
public static string GetQueryOrForm(this HttpRequest request, string parameter, string defaultValue)
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
return defaultValue;
return value;
}
public static T GetQueryOrForm<T>(this HttpRequest request, string parameter, Func<string, T> parse)
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
throw new DnsWebServiceException("Parameter '" + parameter + "' missing.");
return parse(value);
}
public static T GetQueryOrFormEnum<T>(this HttpRequest request, string parameter) where T : struct
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
throw new DnsWebServiceException("Parameter '" + parameter + "' missing.");
return Enum.Parse<T>(value, true);
}
public static T GetQueryOrForm<T>(this HttpRequest request, string parameter, Func<string, T> parse, T defaultValue)
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
return defaultValue;
return parse(value);
}
public static T GetQueryOrFormEnum<T>(this HttpRequest request, string parameter, T defaultValue) where T : struct
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
return defaultValue;
return Enum.Parse<T>(value, true);
}
public static bool TryGetQueryOrForm(this HttpRequest request, string parameter, out string value)
{
value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
return false;
return true;
}
public static bool TryGetQueryOrForm<T>(this HttpRequest request, string parameter, Func<string, T> parse, out T value)
{
string strValue = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(strValue))
{
value = default;
return false;
}
value = parse(strValue);
return true;
}
public static bool TryGetQueryOrFormEnum<T>(this HttpRequest request, string parameter, out T value) where T : struct
{
string strValue = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(strValue))
{
value = default;
return false;
}
return Enum.TryParse(strValue, true, out value);
}
public static string GetQueryOrFormAlt(this HttpRequest request, string parameter, string alternateParameter)
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
{
value = request.QueryOrForm(alternateParameter);
if (string.IsNullOrEmpty(value))
throw new DnsWebServiceException("Parameter '" + parameter + "' missing.");
}
return value;
}
public static string GetQueryOrFormAlt(this HttpRequest request, string parameter, string alternateParameter, string defaultValue)
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
{
value = request.QueryOrForm(alternateParameter);
if (string.IsNullOrEmpty(value))
return defaultValue;
}
return value;
}
public static T GetQueryOrFormAlt<T>(this HttpRequest request, string parameter, string alternateParameter, Func<string, T> parse)
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
{
value = request.QueryOrForm(alternateParameter);
if (string.IsNullOrEmpty(value))
throw new DnsWebServiceException("Parameter '" + parameter + "' missing.");
}
return parse(value);
}
public static T GetQueryOrFormAlt<T>(this HttpRequest request, string parameter, string alternateParameter, Func<string, T> parse, T defaultValue)
{
string value = request.QueryOrForm(parameter);
if (string.IsNullOrEmpty(value))
{
value = request.QueryOrForm(alternateParameter);
if (string.IsNullOrEmpty(value))
return defaultValue;
}
return parse(value);
}
public static bool TryQueryOrFormArray(this HttpRequest request, string parameter, out string[] array, params char[] separator)
{
if (request.HttpContext.Items.TryGetValue("jsonContent", out object jsonObject))
{
JsonDocument json = (JsonDocument)jsonObject;
if (!json.RootElement.TryReadArray(parameter, out array))
return false;
if (array is null)
array = [];
return true;
}
string value = request.QueryOrForm(parameter);
if (value is null)
{
array = null;
return false;
}
if ((value.Length == 0) || value.Equals("false", StringComparison.OrdinalIgnoreCase))
{
array = [];
return true;
}
if ((separator is null) || (separator.Length == 0))
separator = COMMA_SEPARATOR;
array = value.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return true;
}
public static bool TryQueryOrFormArray<T>(this HttpRequest request, string parameter, Func<string, T> parse, out T[] array, params char[] separator)
{
if (request.HttpContext.Items.TryGetValue("jsonContent", out object jsonObject))
{
JsonDocument json = (JsonDocument)jsonObject;
if (!json.RootElement.TryReadArray(parameter, parse, out array))
return false;
if (array is null)
array = [];
return true;
}
string value = request.QueryOrForm(parameter);
if (value is null)
{
array = null;
return false;
}
if ((value.Length == 0) || value.Equals("false", StringComparison.OrdinalIgnoreCase))
{
array = [];
return true;
}
if ((separator is null) || (separator.Length == 0))
separator = COMMA_SEPARATOR;
array = value.Split(parse, separator);
return true;
}
public static bool TryQueryOrFormArray<T>(this HttpRequest request, string parameter, Func<ArraySegment<string>, T> parse, int colspan, out T[] array, params char[] separator)
{
string value = request.QueryOrForm(parameter);
if (value is null)
{
array = null;
return false;
}
if ((value.Length == 0) || value.Equals("false", StringComparison.OrdinalIgnoreCase))
{
array = [];
return true;
}
if ((separator is null) || (separator.Length == 0))
separator = COMMA_SEPARATOR;
string[] cells = value.Split(separator);
if ((cells.Length % colspan) != 0)
throw new DnsWebServiceException("Invalid value for parameter '" + parameter + "': expected each entry to have " + colspan + " values separated by '" + new string(separator) + "'.");
array = new T[cells.Length / colspan];
for (int i = 0, j = 0; i < cells.Length; i += colspan)
array[j++] = parse(new ArraySegment<string>(cells, i, colspan));
return true;
}
public static bool TryQueryOrFormArray<T>(this HttpRequest request, string parameter, Func<JsonElement, T> getObject, Func<ArraySegment<string>, T> parse, int colspan, out T[] array, params char[] separator)
{
if (request.HttpContext.Items.TryGetValue("jsonContent", out object jsonObject))
{
JsonDocument json = (JsonDocument)jsonObject;
if (!json.RootElement.TryReadArray(parameter, getObject, out array))
return false;
if (array is null)
array = [];
return true;
}
string value = request.QueryOrForm(parameter);
if (value is null)
{
array = null;
return false;
}
if ((value.Length == 0) || value.Equals("false", StringComparison.OrdinalIgnoreCase))
{
array = [];
return true;
}
if ((separator is null) || (separator.Length == 0))
separator = COMMA_SEPARATOR;
string[] cells = value.Split(separator);
if ((cells.Length % colspan) != 0)
throw new DnsWebServiceException("Invalid value for parameter '" + parameter + "': expected each entry to have " + colspan + " values separated by '" + new string(separator) + "'.");
array = new T[cells.Length / colspan];
for (int i = 0, j = 0; i < cells.Length; i += colspan)
array[j++] = parse(new ArraySegment<string>(cells, i, colspan));
return true;
}
}
}