fix(security): resolve Security-012..015 — fail login on partial LDAP outage, escape-aware DN parsing, idle check on refresh, username normalization
This commit is contained in:
@@ -152,6 +152,14 @@ public class JwtTokenService
|
||||
/// otherwise the documented 30-minute idle timeout could never fire for a client
|
||||
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
|
||||
/// anchor when handling a genuine user request.
|
||||
/// <para>
|
||||
/// A principal that is already past the idle timeout cannot be refreshed: this
|
||||
/// method returns <c>null</c> (the same "cannot refresh" signal it uses for missing
|
||||
/// claims). Enforcing the idle check here — rather than relying on the caller to
|
||||
/// invoke <see cref="IsIdleTimedOut"/> first — guarantees the documented 30-minute
|
||||
/// idle policy holds regardless of caller discipline; otherwise an idle-expired
|
||||
/// session could be kept alive indefinitely by background refreshes (Security-014).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
@@ -164,6 +172,14 @@ public class JwtTokenService
|
||||
return null;
|
||||
}
|
||||
|
||||
// An idle-expired session must not be renewed — the user must re-login.
|
||||
if (IsIdleTimedOut(currentPrincipal))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cannot refresh token for {Username}: session is past the idle timeout", username);
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||
ReadLastActivity(currentPrincipal));
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ public class LdapAuthService
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new LdapAuthResult(false, null, null, null, "Password is required.");
|
||||
|
||||
// Trim once, up front: a username with leading/trailing whitespace (copy-paste
|
||||
// artefacts, mobile keyboards) is otherwise passed verbatim into the LDAP filter,
|
||||
// the fallback bind DN, and — most consequentially — the JWT Username claim and
|
||||
// audit trail, producing two distinct identities for the same person
|
||||
// (Security-015). The IsNullOrWhiteSpace guard above already rejects an
|
||||
// all-whitespace value, so the trimmed result here is always non-empty.
|
||||
username = NormalizeUsername(username);
|
||||
|
||||
// Enforce TLS unless explicitly allowed for dev/test
|
||||
if (_options.LdapTransport == LdapTransport.None && !_options.AllowInsecureLdap)
|
||||
{
|
||||
@@ -74,6 +82,7 @@ public class LdapAuthService
|
||||
// Query for user attributes and group memberships
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
var groupLookupSucceeded = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -86,40 +95,45 @@ public class LdapAuthService
|
||||
new[] { _options.LdapDisplayNameAttribute, _options.LdapGroupAttribute },
|
||||
false), ct);
|
||||
|
||||
// `HasMore()` is the loop guard for end-of-results; it returns false
|
||||
// when the enumeration is exhausted. An LdapException thrown by
|
||||
// `Next()` inside a HasMore()-guarded loop is therefore NOT a benign
|
||||
// "no more results" sentinel — it is a genuine error (referral failure,
|
||||
// server-side limit, transport drop mid-enumeration). The previous
|
||||
// `catch (LdapException) { break; }` silently truncated the group list
|
||||
// and masked a partial outage (Security-012); such an exception now
|
||||
// propagates to the outer catch and fails the login.
|
||||
while (searchResults.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = searchResults.Next();
|
||||
var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute);
|
||||
if (dnAttr != null)
|
||||
displayName = dnAttr.StringValue;
|
||||
var entry = searchResults.Next();
|
||||
|
||||
var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute);
|
||||
if (groupAttr != null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
{
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (LdapException)
|
||||
var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute);
|
||||
if (dnAttr != null)
|
||||
displayName = dnAttr.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute);
|
||||
if (groupAttr != null)
|
||||
{
|
||||
// No more results
|
||||
break;
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
{
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query LDAP attributes for user {Username}; authentication succeeded but group lookup failed", username);
|
||||
// Auth succeeded even if attribute lookup failed
|
||||
// A failed group/attribute lookup on initial login means the directory
|
||||
// is partially unavailable. The design's LDAP-failure rule requires new
|
||||
// logins to FAIL when LDAP is unavailable — admitting the user here
|
||||
// would yield an authenticated session with zero roles (Security-012).
|
||||
_logger.LogWarning(ex, "LDAP group/attribute lookup failed for user {Username}; failing the login per the LDAP-failure rule", username);
|
||||
groupLookupSucceeded = false;
|
||||
}
|
||||
|
||||
connection.Disconnect();
|
||||
|
||||
return new LdapAuthResult(true, displayName, username, groups, null);
|
||||
return BuildAuthResultFromGroupLookup(username, displayName, groups, groupLookupSucceeded);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
@@ -255,16 +269,92 @@ public class LdapAuthService
|
||||
.Replace("\0", "\\00");
|
||||
}
|
||||
|
||||
private static string ExtractFirstRdnValue(string dn)
|
||||
/// <summary>
|
||||
/// Normalises a username by trimming leading and trailing whitespace. Applied once
|
||||
/// at the top of <see cref="AuthenticateAsync"/> so the same canonical value flows
|
||||
/// into the LDAP filter, the fallback bind DN, and the JWT <c>Username</c> claim —
|
||||
/// avoiding two distinct identities for the same person (Security-015).
|
||||
/// </summary>
|
||||
public static string NormalizeUsername(string username)
|
||||
=> username?.Trim() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the final <see cref="LdapAuthResult"/> for a login attempt once the user
|
||||
/// bind has succeeded. When the group/attribute lookup failed
|
||||
/// (<paramref name="groupLookupSucceeded"/> is false) the directory is partially
|
||||
/// unavailable, so the login is FAILED per the design's LDAP-failure rule rather
|
||||
/// than returning an authenticated session with zero roles (Security-012). When the
|
||||
/// lookup succeeded, an empty <paramref name="groups"/> list is a genuine
|
||||
/// "no mapped groups" outcome and the login succeeds.
|
||||
/// </summary>
|
||||
public static LdapAuthResult BuildAuthResultFromGroupLookup(
|
||||
string username,
|
||||
string displayName,
|
||||
IReadOnlyList<string> groups,
|
||||
bool groupLookupSucceeded)
|
||||
{
|
||||
// Extract the value of the first RDN from a DN.
|
||||
// Handles cn=, ou=, or any attribute: "ou=SCADA-Admins,ou=groups,dc=..." → "SCADA-Admins"
|
||||
if (!groupLookupSucceeded)
|
||||
{
|
||||
return new LdapAuthResult(false, null, username, null,
|
||||
"The directory is temporarily unavailable. Please try again.");
|
||||
}
|
||||
|
||||
return new LdapAuthResult(true, displayName, username, groups, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the value of the first RDN from a DN, e.g.
|
||||
/// <c>ou=SCADA-Admins,ou=groups,dc=...</c> → <c>SCADA-Admins</c>. The scan is
|
||||
/// RFC 4514 escape-aware: a backslash-escaped <c>,</c> inside the RDN value does
|
||||
/// not terminate it, and recognised escape sequences are unescaped, so a group CN
|
||||
/// that legitimately contains a comma is returned intact (Security-013).
|
||||
/// </summary>
|
||||
public static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dn))
|
||||
return dn;
|
||||
|
||||
var equalsIndex = dn.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
return dn;
|
||||
|
||||
var valueStart = equalsIndex + 1;
|
||||
var commaIndex = dn.IndexOf(',', valueStart);
|
||||
return commaIndex > valueStart ? dn[valueStart..commaIndex] : dn[valueStart..];
|
||||
var sb = new System.Text.StringBuilder(dn.Length - valueStart);
|
||||
|
||||
for (var i = valueStart; i < dn.Length; i++)
|
||||
{
|
||||
var c = dn[i];
|
||||
if (c == '\\' && i + 1 < dn.Length)
|
||||
{
|
||||
var next = dn[i + 1];
|
||||
// RFC 4514 hex escape: \XX (two hex digits).
|
||||
if (i + 2 < dn.Length && IsHexDigit(next) && IsHexDigit(dn[i + 2]))
|
||||
{
|
||||
sb.Append((char)Convert.ToInt32(dn.Substring(i + 1, 2), 16));
|
||||
i += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single-character escape (e.g. \, \+ \\ \" \; etc.) — emit the
|
||||
// escaped character literally and skip the backslash.
|
||||
sb.Append(next);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ',')
|
||||
{
|
||||
// Unescaped comma terminates the first RDN.
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append(c);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool IsHexDigit(char c)
|
||||
=> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user