From 3372cf17189b8a8ff286ba4f006f5b2285242245 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Nov 2026 21:49:00 +0000 Subject: [PATCH 1/5] Offer a legacy NDS password replace when no Universal Password policy applies. eDirectory trees that only have NDS password rules reject RFC 3062 Password Modify. Read nspmPasswordPolicyDN (user, parent container, partition root, Login Policy) and, when nothing is assigned, default the change-password dialog to a userPassword replace over LDAPS. Password Modify failures 53, 19, and NMAS can retry that way. Existing NMAS error text is unchanged. Co-authored-by: Jerry Combs --- .../arborj/service/LDAPService.java | 224 ++++++++++- .../arborj/util/EdirPasswordPolicy.java | 360 +++++++++++++++++ .../arborj/view/ChangePasswordDialog.java | 196 +++++++++- .../service/LDAPServicePasswordErrorTest.java | 149 +++++++ .../arborj/util/EdirPasswordPolicyTest.java | 362 ++++++++++++++++++ 5 files changed, 1266 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java create mode 100644 src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java create mode 100644 src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java diff --git a/src/main/java/com/pointbluetech/arborj/service/LDAPService.java b/src/main/java/com/pointbluetech/arborj/service/LDAPService.java index d0cadaa..ece38d0 100644 --- a/src/main/java/com/pointbluetech/arborj/service/LDAPService.java +++ b/src/main/java/com/pointbluetech/arborj/service/LDAPService.java @@ -4,6 +4,7 @@ import com.pointbluetech.arborj.model.EffectiveRights; import com.pointbluetech.arborj.model.LDAPConnectionConfig; import com.pointbluetech.arborj.model.LDAPEntry; +import com.pointbluetech.arborj.util.EdirPasswordPolicy; import com.unboundid.ldap.sdk.*; import com.unboundid.ldap.sdk.controls.SimplePagedResultsControl; import com.unboundid.ldap.sdk.extensions.PasswordModifyExtendedRequest; @@ -493,9 +494,12 @@ public void changePasswordRFC3062(String dn, String oldPassword, String newPassw * leaves the actual reason in the LDAP result code or in the matched-DN * field. We surface all available pieces so the user can see the real * cause (too short, unique-policy violation, password expired, etc.). + * + *

Package-visible so unit tests can lock this wording. The dialog + * shows the string as-is. */ - private static String formatPasswordError(ResultCode rc, String diagnostic, - String matchedDN, Control[] controls) { + static String formatPasswordError(ResultCode rc, String diagnostic, + String matchedDN, Control[] controls) { StringBuilder sb = new StringBuilder("Password change failed: "); // Try to find a more specific reason than the generic NMAS message. @@ -548,7 +552,7 @@ private static String nmasReasonFromControls(Control[] controls) { // 2.16.840.1.113719.1.27.103.* — the value typically encodes a // 32-bit signed NMAS error code as the first 4 BE bytes. for (Control c : controls) { - if (c.getOID() != null && c.getOID().startsWith("2.16.840.1.113719.1.27.103.")) { + if (c.getOID() != null && c.getOID().startsWith(EdirPasswordPolicy.NMAS_CONTROL_PREFIX)) { ASN1OctetString val = c.getValue(); if (val == null) continue; byte[] bytes = val.getValue(); @@ -567,7 +571,8 @@ private static String nmasReasonFromControls(Control[] controls) { * Map well-known NMAS / NDS error codes to human-readable text. Codes are * defined in eDirectory's nmasapi.h (negative values). */ - private static String decodeNmasCode(int code) { + /** Package-visible so unit tests can lock the NMAS code map. */ + static String decodeNmasCode(int code) { return switch (code) { case -215 -> "Password unique violation (matches a previous password)"; case -216 -> "Password too short"; @@ -613,14 +618,221 @@ public void changePasswordAD(String dn, String newPassword) throws LDAPException } /** - * eDirectory DirXML password change (universal password). + * eDirectory Universal Password change. This is the RFC 3062 Password + * Modify extended operation (NMAS). Trees with only legacy NDS password + * rules reject it; use {@link #changePasswordLegacyNDS} there. */ public void changePasswordEDir(String dn, String oldPassword, String newPassword) throws LDAPException { - // eDirectory uses RFC 3062 but may also support DirXML extended op changePasswordRFC3062(dn, oldPassword, newPassword); } + /** + * Legacy NDS password set: REPLACE {@code userPassword}. This is the + * modify Apache Directory Studio sends, and it is what succeeds on an + * eDirectory tree that has no Universal Password policy. eDirectory + * accepts a cleartext {@code userPassword} only on a secure connection, + * so a plaintext socket is refused before the modify is sent. + */ + public void changePasswordLegacyNDS(String dn, String newPassword) throws LDAPException { + if (newPassword == null || newPassword.isEmpty()) { + throw new LDAPException(ResultCode.CONSTRAINT_VIOLATION, + "New password must not be empty."); + } + if (!isSecureConnection()) { + throw new LDAPException(ResultCode.CONFIDENTIALITY_REQUIRED, + "Legacy NDS password changes replace userPassword and require " + + "a secure connection (LDAPS)."); + } + modifyAttribute(dn, "userPassword", LDAPModifyOperation.REPLACE, List.of(newPassword)); + } + + /** + * Read-only: which Universal Password policy applies to {@code userDN}, + * or null when nothing is assigned. Reads {@code nspmPasswordPolicyDN} + * on the user, the parent container, the partition root, and the Login + * Policy object. Does not modify the directory and does not require a + * writable connection. + */ + public EdirPasswordPolicy.Assignment findUniversalPasswordPolicy(String userDN) + throws LDAPException { + ensureConnected(); + List contexts = fetchNamingContexts(); + + Map policies = new LinkedHashMap<>(); + SearchResultEntry user = readEntryRequired(userDN, EdirPasswordPolicy.POLICY_ATTRIBUTE); + policies.put(userDN, attributeOrNull(user, EdirPasswordPolicy.POLICY_ATTRIBUTE)); + // The user's own assignment wins. Don't let an unreadable container + // or Login Policy hide a policy we already have. + if (policies.get(userDN) != null) { + return EdirPasswordPolicy.resolve(userDN, policies, contexts, null); + } + + List toRead = EdirPasswordPolicy.policySearchDns(userDN, contexts); + LDAPException accessDenied = null; + // Index 0 is the user, already read above. An entry we cannot read + // is skipped so a later hit (the partition root, the Login Policy) + // is not hidden, and is reported only when nothing was found. + for (int i = 1; i < toRead.size(); i++) { + String dn = toRead.get(i); + try { + SearchResultEntry entry = readEntryIfPresent(dn, EdirPasswordPolicy.POLICY_ATTRIBUTE); + policies.put(dn, entry == null + ? null + : attributeOrNull(entry, EdirPasswordPolicy.POLICY_ATTRIBUTE)); + } catch (LDAPException e) { + if (e.getResultCode() != ResultCode.INSUFFICIENT_ACCESS_RIGHTS) throw e; + accessDenied = e; + } + } + + String loginDN = null; + try { + SearchResultEntry login = findLoginPolicyEntry(contexts); + loginDN = login == null ? null : login.getDN(); + if (login != null) { + policies.put(loginDN, attributeOrNull(login, EdirPasswordPolicy.POLICY_ATTRIBUTE)); + } + } catch (LDAPException e) { + if (e.getResultCode() != ResultCode.INSUFFICIENT_ACCESS_RIGHTS) throw e; + accessDenied = e; + } + + EdirPasswordPolicy.Assignment found = + EdirPasswordPolicy.resolve(userDN, policies, contexts, loginDN); + if (found == null && accessDenied != null) throw accessDenied; + return found; + } + + /** + * True when the live socket is TLS. A connected cleartext socket is not + * treated as secure just because the configured port is 636. + */ + public boolean isSecureConnection() { + boolean connected = connection != null && connection.isConnected(); + boolean ssl = false; + if (connection != null) { + try { + ssl = connection.getSSLSession() != null; + } catch (RuntimeException ignored) { + ssl = false; + } + } + boolean useTls = currentConfig != null && currentConfig.isUseTLS(); + int port = currentConfig != null ? currentConfig.getPort() : 0; + return EdirPasswordPolicy.connectionIsSecure(connected, ssl, useTls, port); + } + + private SearchResultEntry readEntryRequired(String dn, String attribute) throws LDAPException { + try { + SearchResultEntry entry = connection.getEntry(dn, attribute); + if (entry == null) { + throw new LDAPException(ResultCode.NO_SUCH_OBJECT, "Entry not found: " + dn); + } + return entry; + } catch (LDAPException e) { + if (e.getResultCode() == ResultCode.NO_SUCH_OBJECT) { + throw new LDAPException(ResultCode.NO_SUCH_OBJECT, "Entry not found: " + dn, e); + } + throw e; + } + } + + /** + * Base-level read. Missing entries, referrals, and unparsable DNs are + * "not present". Any other failure (including insufficient rights) is + * raised so a policy we could not see is not reported as absent. + */ + private SearchResultEntry readEntryIfPresent(String dn, String attribute) throws LDAPException { + try { + return connection.getEntry(dn, attribute); + } catch (LDAPException e) { + ResultCode rc = e.getResultCode(); + if (rc == ResultCode.NO_SUCH_OBJECT + || rc == ResultCode.INVALID_DN_SYNTAX + || rc == ResultCode.REFERRAL) { + return null; + } + throw e; + } + } + + private static String attributeOrNull(SearchResultEntry entry, String attribute) { + if (entry == null) return null; + String value = entry.getAttributeValue(attribute); + if (value == null || value.isBlank()) return null; + return value; + } + + /** + * Locate {@code cn=Login Policy} under the Security container. Tries the + * well-known DNs first, then a one-level search. An access failure is + * remembered and rethrown only when no Login Policy entry was found, so + * a missing {@code cn=Security} at the root does not hide the real one + * under a naming context — and a Security container we cannot read is + * not treated as "no policy". + */ + private SearchResultEntry findLoginPolicyEntry(List namingContexts) throws LDAPException { + LDAPException accessDenied = null; + for (String dn : EdirPasswordPolicy.loginPolicyCandidates(namingContexts)) { + try { + SearchResultEntry entry = connection.getEntry(dn, EdirPasswordPolicy.POLICY_ATTRIBUTE); + if (entry != null) return entry; + } catch (LDAPException e) { + if (e.getResultCode() == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { + accessDenied = e; + continue; + } + if (e.getResultCode() == ResultCode.NO_SUCH_OBJECT + || e.getResultCode() == ResultCode.INVALID_DN_SYNTAX + || e.getResultCode() == ResultCode.REFERRAL) { + continue; + } + throw e; + } + } + for (String base : EdirPasswordPolicy.securityContainerCandidates(namingContexts)) { + try { + SearchResultEntry entry = searchLoginPolicy(base); + if (entry != null) return entry; + } catch (LDAPException e) { + if (e.getResultCode() == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { + accessDenied = e; + continue; + } + throw e; + } + } + if (accessDenied != null) throw accessDenied; + return null; + } + + private SearchResultEntry searchLoginPolicy(String base) throws LDAPException { + SearchRequest request = new SearchRequest(base, + com.unboundid.ldap.sdk.SearchScope.ONE, + "(cn=Login Policy)", + EdirPasswordPolicy.POLICY_ATTRIBUTE); + request.setSizeLimit(1); + request.setTimeLimitSeconds(15); + try { + SearchResult result = connection.search(request); + if (result.getSearchEntries().isEmpty()) return null; + return result.getSearchEntries().getFirst(); + } catch (LDAPSearchException e) { + if (e.getResultCode() == ResultCode.SIZE_LIMIT_EXCEEDED + && e.getSearchResult() != null + && !e.getSearchResult().getSearchEntries().isEmpty()) { + return e.getSearchResult().getSearchEntries().getFirst(); + } + if (e.getResultCode() == ResultCode.NO_SUCH_OBJECT + || e.getResultCode() == ResultCode.REFERRAL + || e.getResultCode() == ResultCode.INVALID_DN_SYNTAX) { + return null; + } + throw e; + } + } + // --- Active Directory Helpers --- public int fetchADUserAccountControl(String dn) throws LDAPException { diff --git a/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java new file mode 100644 index 0000000..64035f7 --- /dev/null +++ b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java @@ -0,0 +1,360 @@ +package com.pointbluetech.arborj.util; + +import com.unboundid.ldap.sdk.Control; +import com.unboundid.ldap.sdk.DN; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.RDN; +import com.unboundid.ldap.sdk.ResultCode; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Which Universal Password policy, if any, applies to an eDirectory user. + * + *

NMAS looks at {@code nspmPasswordPolicyDN} and keeps the first hit, in + * this order (OpenText eDirectory administration guide, "Assigning Password + * Policies"): + *

    + *
  1. the user
  2. + *
  3. the container that holds the user — only that container, not every + * ancestor. A policy on a container that is not a partition root is + * not inherited by users in subcontainers
  4. + *
  5. the partition root of the partition the user is in (the nearest + * naming context above the user). A policy on a parent partition does + * not apply inside a child partition
  6. + *
  7. the Login Policy object in the Security container, the tree-wide + * default
  8. + *
+ * No assignment means the tree is still on legacy NDS password rules. + * Password Modify (RFC 3062) is an NMAS operation and fails there; a + * {@code userPassword} replace over LDAPS is what works. + * + *

This class only decides. {@code LDAPService} does the reads, and those + * reads are not writes. + */ +public final class EdirPasswordPolicy { + + public static final String POLICY_ATTRIBUTE = "nspmPasswordPolicyDN"; + + /** eDirectory NMAS response controls: {@code 2.16.840.1.113719.1.27.103.*}. */ + public static final String NMAS_CONTROL_PREFIX = "2.16.840.1.113719.1.27.103."; + + /** LDAP result codes that mean "Password Modify cannot do this". */ + public static final int CONSTRAINT_VIOLATION = 19; + public static final int UNWILLING_TO_PERFORM = 53; + + private EdirPasswordPolicy() { + } + + public enum Source { + USER, + CONTAINER, + PARTITION_ROOT, + LOGIN_POLICY + } + + public enum Method { + UNIVERSAL_PASSWORD, + LEGACY_NDS + } + + /** + * A policy that applies, where it was found, and the entry that holds + * the {@code nspmPasswordPolicyDN} value. + */ + public record Assignment(String policyDN, Source source, String assignedOnDN) { + public String sourceText() { + return switch (source) { + case USER -> "Assigned to this user."; + case CONTAINER -> "Inherited from container " + assignedOnDN + "."; + case PARTITION_ROOT -> "Inherited from partition root " + assignedOnDN + "."; + case LOGIN_POLICY -> "Inherited from the Login Policy (" + assignedOnDN + ")."; + }; + } + } + + /** + * Status line for the change-password dialog. The policy DN, when there + * is one, is {@link Assignment#policyDN()} and is shown separately. + */ + public static String describeStatus(Assignment assignment, String failureMessage) { + if (failureMessage != null && !failureMessage.isBlank()) { + return "Could not check whether a Universal Password policy applies: " + + failureMessage; + } + if (assignment == null) { + return "No Universal Password policy applies to this user " + + "(none on the user, the parent container, the partition root, " + + "or the Login Policy)."; + } + return "Universal Password policy applies. " + assignment.sourceText(); + } + + /** + * Show the Universal Password / Legacy NDS choice when we did not find + * a policy. A failed lookup also has no assignment; the choice stays + * available and {@link #defaultMethod} keeps Password Modify selected. + */ + public static boolean offerMethodChoice(Assignment assignment) { + return assignment == null; + } + + /** + * Legacy NDS when the lookup succeeded and found nothing, and the + * connection can carry a {@code userPassword} replace. A policy, a + * failed lookup, or a cleartext connection keeps Password Modify — + * the operation the dialog already used. + */ + public static Method defaultMethod(Assignment assignment, boolean lookupFailed, + boolean secureConnection) { + if (assignment != null || lookupFailed || !secureConnection) { + return Method.UNIVERSAL_PASSWORD; + } + return Method.LEGACY_NDS; + } + + /** + * True when a failed Password Modify is the case the legacy replace + * can cover: result 53 (unwillingToPerform), 19 (constraintViolation), + * an NMAS response control, an error that says NMAS, or a matched DN + * carrying an NMAS code. Other failures (bad current password, no + * rights) are not retried this way. + */ + public static boolean passwordModifySuggestsLegacy(Throwable error) { + Throwable current = error; + for (int depth = 0; current != null && depth < 5; depth++) { + if (current instanceof LDAPException ldap) { + ResultCode rc = ldap.getResultCode(); + int code = rc == null ? -1 : rc.intValue(); + if (code == CONSTRAINT_VIOLATION || code == UNWILLING_TO_PERFORM) return true; + if (hasNmasControl(ldap.getResponseControls())) return true; + if (mentionsNmas(ldap.getDiagnosticMessage())) return true; + // eDirectory sometimes puts the NMAS code in the matched DN + // ("-216") and the formatted dialog text no longer says NMAS. + if (matchedDnIsNmas(ldap.getMatchedDN())) return true; + } + if (mentionsNmas(current.getMessage())) return true; + Throwable next = current.getCause(); + current = next == current ? null : next; + } + return false; + } + + /** + * A connected socket is secure only when it is actually TLS. Config + * flags apply before a socket exists. + */ + public static boolean connectionIsSecure(boolean connected, boolean sslSessionActive, + boolean useTls, int port) { + if (connected) return sslSessionActive; + return sslSessionActive || useTls || port == 636; + } + + /** + * DNs to read, in NMAS order, excluding the Login Policy (that object + * is located separately). The user is always first. The partition root + * is omitted when it is the parent already listed. + */ + public static List policySearchDns(String userDN, Collection namingContexts) { + if (userDN == null || userDN.isBlank()) return List.of(); + List dns = new ArrayList<>(); + dns.add(userDN); + String parent = parentDn(userDN); + if (parent != null) dns.add(parent); + String partition = nearestPartitionRoot(userDN, namingContexts); + if (partition != null && (parent == null || !sameDn(partition, parent))) { + dns.add(partition); + } + return List.copyOf(dns); + } + + /** + * Pick the policy from values already read. {@code policyByDn} maps an + * entry DN to its {@code nspmPasswordPolicyDN} (missing or blank means + * that entry assigns nothing). Keys are matched case-insensitively. + * An ancestor that is neither the parent nor the partition root is + * ignored even if the map contains it. + */ + public static Assignment resolve(String userDN, Map policyByDn, + Collection namingContexts, String loginPolicyDN) { + if (userDN == null || userDN.isBlank()) return null; + + Hit user = find(policyByDn, userDN); + if (user != null) return new Assignment(user.policyDN, Source.USER, user.dn); + + String parent = parentDn(userDN); + if (parent != null) { + Hit container = find(policyByDn, parent); + if (container != null) { + return new Assignment(container.policyDN, Source.CONTAINER, container.dn); + } + } + + String partition = nearestPartitionRoot(userDN, namingContexts); + if (partition != null && (parent == null || !sameDn(partition, parent))) { + Hit part = find(policyByDn, partition); + if (part != null) { + return new Assignment(part.policyDN, Source.PARTITION_ROOT, part.dn); + } + } + + if (loginPolicyDN != null && !loginPolicyDN.isBlank()) { + Hit login = find(policyByDn, loginPolicyDN); + if (login != null) { + return new Assignment(login.policyDN, Source.LOGIN_POLICY, login.dn); + } + } + return null; + } + + /** Parent DN, or null for a top-level or empty DN. */ + public static String parentDn(String dn) { + if (dn == null || dn.isBlank()) return null; + try { + DN parsed = new DN(dn); + DN parent = parsed.getParent(); + if (parent == null || parent.isNullDN()) return null; + return parent.toString(); + } catch (LDAPException e) { + return null; + } + } + + /** + * Naming context that is the partition root for {@code userDN}: the + * longest naming context the user is strictly inside. The user DN + * itself is not a partition root for this purpose. + */ + public static String nearestPartitionRoot(String userDN, Collection namingContexts) { + if (userDN == null || userDN.isBlank() || namingContexts == null) return null; + DN user; + try { + user = new DN(userDN); + } catch (LDAPException e) { + return null; + } + String best = null; + int bestRdns = -1; + for (String ctx : namingContexts) { + if (ctx == null || ctx.isBlank()) continue; + try { + DN ctxDn = new DN(ctx); + if (ctxDn.isNullDN()) continue; + if (user.isDescendantOf(ctxDn, false) && ctxDn.getRDNs().length > bestRdns) { + bestRdns = ctxDn.getRDNs().length; + best = ctx; + } + } catch (LDAPException ignored) { + // A naming context that is not a DN cannot be a partition root. + } + } + return best; + } + + /** {@code cn=Security} and {@code cn=Security} under each naming context. */ + public static List securityContainerCandidates(Collection namingContexts) { + LinkedHashSet out = new LinkedHashSet<>(); + out.add("cn=Security"); + if (namingContexts != null) { + for (String ctx : namingContexts) { + if (ctx == null || ctx.isBlank()) continue; + if (isSecurityContainer(ctx)) out.add(ctx); + else out.add("cn=Security," + ctx); + } + } + return List.copyOf(out); + } + + /** {@code cn=Login Policy} directly under each security-container candidate. */ + public static List loginPolicyCandidates(Collection namingContexts) { + LinkedHashSet out = new LinkedHashSet<>(); + for (String security : securityContainerCandidates(namingContexts)) { + out.add("cn=Login Policy," + security); + } + return List.copyOf(out); + } + + public static boolean sameDn(String a, String b) { + if (a == null || b == null) return false; + return normalize(a).equals(normalize(b)); + } + + public static String normalize(String dn) { + if (dn == null) return ""; + try { + return new DN(dn).toNormalizedString(); + } catch (LDAPException e) { + return dn.trim().toLowerCase(Locale.ROOT); + } + } + + private static boolean isSecurityContainer(String dn) { + try { + DN parsed = new DN(dn); + if (parsed.isNullDN()) return false; + RDN rdn = parsed.getRDN(); + String[] names = rdn.getAttributeNames(); + String[] values = rdn.getAttributeValues(); + for (int i = 0; i < names.length && i < values.length; i++) { + if ("cn".equalsIgnoreCase(names[i]) && "security".equalsIgnoreCase(values[i])) { + return true; + } + } + return false; + } catch (LDAPException e) { + return false; + } + } + + private record Hit(String dn, String policyDN) {} + + private static Hit find(Map policyByDn, String dn) { + if (policyByDn == null || dn == null) return null; + if (policyByDn.containsKey(dn)) { + String value = blankToNull(policyByDn.get(dn)); + return value == null ? null : new Hit(dn, value); + } + String want = normalize(dn); + for (var entry : policyByDn.entrySet()) { + if (want.equals(normalize(entry.getKey()))) { + String value = blankToNull(entry.getValue()); + return value == null ? null : new Hit(entry.getKey(), value); + } + } + return null; + } + + private static String blankToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static boolean mentionsNmas(String text) { + return text != null && text.toLowerCase(Locale.ROOT).contains("nmas"); + } + + private static boolean matchedDnIsNmas(String matchedDN) { + if (matchedDN == null || matchedDN.isBlank()) return false; + String trimmed = matchedDN.trim(); + if (trimmed.matches("-\\d+")) return true; + if (trimmed.startsWith("NDS error:")) return true; + return mentionsNmas(trimmed); + } + + private static boolean hasNmasControl(Control[] controls) { + if (controls == null) return false; + for (Control control : controls) { + if (control != null && control.getOID() != null + && control.getOID().startsWith(NMAS_CONTROL_PREFIX)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java b/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java index 5a928e2..30940c3 100644 --- a/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java +++ b/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java @@ -2,7 +2,9 @@ import com.pointbluetech.arborj.controller.MainController; import com.pointbluetech.arborj.model.DirectoryType; +import com.pointbluetech.arborj.util.EdirPasswordPolicy; import javafx.application.Platform; +import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.*; @@ -14,9 +16,15 @@ * based on the connected directory type. * * - Active Directory: new password only, plus UAC flag checkboxes. - * - eDirectory / Generic / OpenLDAP: RFC 3062 Password Modify. Supports - * self-service (current + new password) and administrative reset - * (new password only — uses the connected admin's rights). + * - eDirectory: shows the Universal Password policy that applies (read from + * {@code nspmPasswordPolicyDN} on the user, the parent container, the + * partition root, or the Login Policy). When none applies, the user + * chooses Universal Password (RFC 3062) or Legacy NDS (replace + * {@code userPassword} over LDAPS). Password Modify failures 53 / 19 / + * NMAS offer a legacy retry. + * - Generic / OpenLDAP: RFC 3062 Password Modify. Supports self-service + * (current + new password) and administrative reset (new password only — + * uses the connected admin's rights). */ public class ChangePasswordDialog extends Dialog { @@ -34,6 +42,20 @@ public class ChangePasswordDialog extends Dialog { private final CheckBox adminResetCheckbox = new CheckBox("Administrative reset (don't require current password)"); + // eDirectory: policy status and, when no Universal Password policy + // applies, which write to send. + private final Label policyStatusLabel = + new Label("Checking whether a Universal Password policy applies…"); + private final Label policyDnLabel = new Label(); + private final RadioButton universalRadio = + new RadioButton("Universal Password (Password Modify)"); + private final RadioButton legacyRadio = + new RadioButton("Legacy NDS (replace userPassword)"); + private final ToggleGroup methodGroup = new ToggleGroup(); + private final VBox methodBox = new VBox(4); + private final Label legacySecureHint = new Label( + "Legacy NDS replaces userPassword and needs an LDAPS connection."); + // AD-specific controls private final CheckBox mustChangeCheckbox = new CheckBox("User must change password at next logon"); private final CheckBox neverExpiresCheckbox = new CheckBox("Password never expires"); @@ -66,7 +88,7 @@ public ChangePasswordDialog(MainController controller, String targetDN, VBox content = buildContent(dirType); content.setPadding(new Insets(12)); - content.setPrefWidth(450); + content.setPrefWidth(520); getDialogPane().setContent(content); @@ -91,6 +113,13 @@ public ChangePasswordDialog(MainController controller, String targetDN, okBtn.setDisable(true); fetchADFlags(okBtn); } + + // For eDirectory, read the Universal Password policy before OK is + // enabled so the default method matches what the tree actually has. + if (dirType == DirectoryType.EDIRECTORY) { + okBtn.setDisable(true); + loadEdirPolicy(okBtn); + } } private VBox buildContent(DirectoryType dirType) { @@ -116,8 +145,35 @@ private VBox buildContent(DirectoryType dirType) { form.add(new Separator(), 0, row++, 2, 1); + if (dirType == DirectoryType.EDIRECTORY) { + policyStatusLabel.setWrapText(true); + policyDnLabel.setFont(Font.font("monospaced", 11)); + policyDnLabel.setWrapText(true); + policyDnLabel.setVisible(false); + policyDnLabel.setManaged(false); + VBox policyBox = new VBox(2, policyStatusLabel, policyDnLabel); + form.add(policyBox, 0, row++, 2, 1); + + universalRadio.setToggleGroup(methodGroup); + legacyRadio.setToggleGroup(methodGroup); + universalRadio.setSelected(true); + Label methodLabel = new Label("Method"); + methodLabel.setStyle("-fx-font-weight: bold;"); + legacySecureHint.setWrapText(true); + legacySecureHint.setStyle("-fx-font-size: 11px;"); + legacySecureHint.setVisible(false); + legacySecureHint.setManaged(false); + methodBox.getChildren().addAll(methodLabel, universalRadio, legacyRadio, legacySecureHint); + // Hidden until the policy read finishes. Shown only when no + // Universal Password policy applies. + methodBox.setVisible(false); + methodBox.setManaged(false); + form.add(methodBox, 0, row++, 2, 1); + } + // Current password -- shown for non-AD directories, and only when - // an administrative reset is NOT being performed. + // an administrative reset is NOT being performed. Legacy NDS is a + // userPassword replace and does not send the current password. boolean showOldPassword = dirType != DirectoryType.ACTIVE_DIRECTORY; Label currentLabel = new Label("Current Password:"); if (showOldPassword) { @@ -127,12 +183,15 @@ private VBox buildContent(DirectoryType dirType) { // Empty placeholder cell + checkbox in the value column form.add(adminResetCheckbox, 1, row++); - // Hide current-password row when admin-reset is checked. Keeps the - // form compact and makes the active mode obvious at a glance. - currentLabel.visibleProperty().bind(adminResetCheckbox.selectedProperty().not()); + var showCurrent = Bindings.and( + adminResetCheckbox.selectedProperty().not(), + legacyRadio.selectedProperty().not()); + currentLabel.visibleProperty().bind(showCurrent); currentLabel.managedProperty().bind(currentLabel.visibleProperty()); - currentPasswordField.visibleProperty().bind(adminResetCheckbox.selectedProperty().not()); + currentPasswordField.visibleProperty().bind(showCurrent); currentPasswordField.managedProperty().bind(currentPasswordField.visibleProperty()); + adminResetCheckbox.visibleProperty().bind(legacyRadio.selectedProperty().not()); + adminResetCheckbox.managedProperty().bind(adminResetCheckbox.visibleProperty()); // Clear the field when switching to admin-reset so a stale value // can't accidentally be sent. adminResetCheckbox.selectedProperty().addListener((obs, was, now) -> { @@ -219,8 +278,10 @@ private void doChangePassword(DirectoryType dirType) { confirmPasswordField.requestFocus(); return; } - // Self-service (non-admin) on non-AD requires the current password. + // Self-service (non-admin) Password Modify requires the current + // password. A legacy NDS replace does not send one. if (dirType != DirectoryType.ACTIVE_DIRECTORY + && !legacyRadio.isSelected() && !adminResetCheckbox.isSelected() && currentPasswordField.getText().isEmpty()) { showError("Current password is required.\n\n" @@ -246,8 +307,12 @@ private void doChangePassword(DirectoryType dirType) { } } case EDIRECTORY -> { - String oldPassword = effectiveOldPassword(); - controller.getLdapService().changePasswordEDir(targetDN, oldPassword, newPassword); + if (legacyRadio.isSelected()) { + controller.getLdapService().changePasswordLegacyNDS(targetDN, newPassword); + } else { + controller.getLdapService().changePasswordEDir( + targetDN, effectiveOldPassword(), newPassword); + } } default -> { // Generic / OpenLDAP -- RFC 3062 @@ -259,12 +324,101 @@ private void doChangePassword(DirectoryType dirType) { } catch (Exception ex) { // The service already prefixes its message with "Password change // failed:" and includes the LDAP result code; don't double-prefix. - String msg = ex.getMessage(); - if (msg == null || msg.isBlank()) msg = "Password change failed."; + String msg = messageOf(ex); + if (dirType == DirectoryType.EDIRECTORY + && !legacyRadio.isSelected() + && EdirPasswordPolicy.passwordModifySuggestsLegacy(ex)) { + offerLegacyRetry(msg); + return; + } showError(msg); } } + /** + * Password Modify failed in the way a legacy-only tree fails (53, 19, + * or an NMAS error). Ask before replacing {@code userPassword}. A + * cleartext connection cannot do that replace, so the error stands. + */ + private void offerLegacyRetry(String message) { + if (!controller.getLdapService().isSecureConnection()) { + showError(message + + "\n\nA legacy NDS replace was not attempted because this " + + "connection is not LDAPS."); + return; + } + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("Retry with Legacy NDS Password"); + alert.setHeaderText("Retry by replacing userPassword?"); + alert.setContentText(message + + "\n\nPassword Modify was rejected. Retry with Legacy NDS " + + "(replace userPassword over this LDAPS connection)? " + + "That is the modify Apache Directory Studio sends, and it " + + "does not go through Universal Password."); + alert.getButtonTypes().setAll(ButtonType.YES, ButtonType.NO); + var window = ownerWindow(); + if (window != null) alert.initOwner(window); + var choice = alert.showAndWait(); + if (choice.isEmpty() || choice.get() != ButtonType.YES) return; + try { + controller.getLdapService().changePasswordLegacyNDS(targetDN, newPasswordField.getText()); + close(); + } catch (Exception retryEx) { + showError(messageOf(retryEx)); + } + } + + private void loadEdirPolicy(Button okBtn) { + Thread.ofVirtual().start(() -> { + EdirPasswordPolicy.Assignment assignment = null; + String failure = null; + try { + assignment = controller.getLdapService().findUniversalPasswordPolicy(targetDN); + } catch (Exception e) { + failure = e.getMessage() == null || e.getMessage().isBlank() + ? e.toString() : e.getMessage(); + } + EdirPasswordPolicy.Assignment found = assignment; + String error = failure; + boolean secure = false; + try { + secure = controller.getLdapService().isSecureConnection(); + } catch (Exception ignored) { + secure = false; + } + boolean secureConnection = secure; + Platform.runLater(() -> { + policyStatusLabel.setText(EdirPasswordPolicy.describeStatus(found, error)); + boolean hasPolicy = found != null; + policyDnLabel.setText(hasPolicy ? found.policyDN() : ""); + policyDnLabel.setVisible(hasPolicy); + policyDnLabel.setManaged(hasPolicy); + + boolean offer = EdirPasswordPolicy.offerMethodChoice(found); + methodBox.setVisible(offer); + methodBox.setManaged(offer); + legacyRadio.setDisable(!secureConnection); + legacySecureHint.setVisible(offer && !secureConnection); + legacySecureHint.setManaged(offer && !secureConnection); + + EdirPasswordPolicy.Method method = EdirPasswordPolicy.defaultMethod( + found, error != null, secureConnection); + if (method == EdirPasswordPolicy.Method.LEGACY_NDS) { + legacyRadio.setSelected(true); + } else { + universalRadio.setSelected(true); + } + okBtn.setDisable(false); + }); + }); + } + + private static String messageOf(Exception ex) { + String msg = ex.getMessage(); + if (msg == null || msg.isBlank()) return "Password change failed."; + return msg; + } + /** * Returns the old-password value to send with RFC 3062, or null when the * user has selected an administrative reset. UnboundID's @@ -282,13 +436,17 @@ private void showError(String message) { alert.setTitle("Error"); alert.setHeaderText(null); alert.setContentText(message); + var window = ownerWindow(); + if (window != null) alert.initOwner(window); + alert.showAndWait(); + } + + private javafx.stage.Window ownerWindow() { // Belt-and-suspenders: even with the addEventFilter fix, an error from - // the constructor's async fetchADFlags can fire before the dialog has - // a scene, so guard the owner lookup. + // the constructor's async policy / UAC read can fire before the dialog + // has a scene, so guard the owner lookup. var pane = getDialogPane(); var scene = (pane != null) ? pane.getScene() : null; - var window = (scene != null) ? scene.getWindow() : null; - if (window != null) alert.initOwner(window); - alert.showAndWait(); + return (scene != null) ? scene.getWindow() : null; } } diff --git a/src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java b/src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java new file mode 100644 index 0000000..b9eb1a4 --- /dev/null +++ b/src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java @@ -0,0 +1,149 @@ +package com.pointbluetech.arborj.service; + +import com.pointbluetech.arborj.util.EdirPasswordPolicy; +import com.unboundid.asn1.ASN1OctetString; +import com.unboundid.ldap.sdk.Control; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.ResultCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Locks {@link LDAPService#formatPasswordError} and {@link LDAPService#decodeNmasCode}. + * The change-password dialog shows this text as-is. + */ +class LDAPServicePasswordErrorTest { + + @Test + @DisplayName("a matched-DN NMAS code replaces the generic diagnostic") + void matchedDnCode() { + String msg = LDAPService.formatPasswordError( + ResultCode.OTHER, "NMAS Change Password extension failed", " -216 ", null); + + assertEquals(""" + Password change failed: Password too short + + [result: other (80)]""", msg); + // The dialog sees the formatted text (no longer says "NMAS") wrapped + // around the server exception, which still carries the matched DN. + LDAPException cause = new LDAPException(ResultCode.OTHER, + "NMAS Change Password extension failed", "-216", null); + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.OTHER, msg, cause))); + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy(cause)); + } + + @Test + @DisplayName("an NMAS response control is decoded and not repeated") + void controlCode() { + byte[] code = {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x29}; // -215 + Control control = new Control(EdirPasswordPolicy.NMAS_CONTROL_PREFIX + "1", false, + new ASN1OctetString(code)); + + String msg = LDAPService.formatPasswordError( + ResultCode.CONSTRAINT_VIOLATION, + "NMAS Change Password extension failed", + null, + new Control[] {control}); + + assertEquals(""" + Password change failed: Password unique violation (matches a previous password) + + [result: constraint violation (19)]""", msg); + assertFalse(msg.substring(msg.indexOf('[')).contains("Password unique violation"), + "the NMAS reason is not appended a second time in the result line"); + } + + @Test + @DisplayName("a real diagnostic is kept and the result-code hint is added beside it") + void realDiagnosticPlusResultHint() { + String msg = LDAPService.formatPasswordError( + ResultCode.UNWILLING_TO_PERFORM, "password is fine but no", null, null); + + assertEquals(""" + Password change failed: password is fine but no + + [result: unwilling to perform (53); Server is unwilling to change the password (usually a policy or authentication-method restriction).]""", + msg); + } + + @Test + @DisplayName("result 19, 53, 49, and 50 keep their password-specific sentences") + void resultCodeSentences() { + assertEquals(""" + Password change failed: Password rejected by the server's password policy (may be too short, in history, or contain disallowed text). + + [result: constraint violation (19)]""", + LDAPService.formatPasswordError(ResultCode.CONSTRAINT_VIOLATION, null, null, null)); + assertEquals(""" + Password change failed: Server is unwilling to change the password (usually a policy or authentication-method restriction). + + [result: unwilling to perform (53)]""", + LDAPService.formatPasswordError(ResultCode.UNWILLING_TO_PERFORM, " ", null, null)); + assertEquals(""" + Password change failed: Current password is incorrect. + + [result: invalid credentials (49)]""", + LDAPService.formatPasswordError(ResultCode.INVALID_CREDENTIALS, null, null, null)); + assertEquals(""" + Password change failed: You don't have rights to change this password. + + [result: insufficient access rights (50)]""", + LDAPService.formatPasswordError(ResultCode.INSUFFICIENT_ACCESS_RIGHTS, null, null, null)); + } + + @Test + @DisplayName("NDS error text in the matched DN is shown verbatim") + void ndsErrorMatchedDn() { + String msg = LDAPService.formatPasswordError( + ResultCode.OTHER, null, "NDS error: password too short (-216)", null); + assertTrue(msg.startsWith("Password change failed: NDS error: password too short (-216)")); + } + + @Test + @DisplayName("a short or unrelated control is ignored") + void ignoredControls() { + Control shortValue = new Control(EdirPasswordPolicy.NMAS_CONTROL_PREFIX + "2", false, + new ASN1OctetString(new byte[] {1, 2})); + Control otherOid = new Control("1.2.3.4", false, + new ASN1OctetString(new byte[] {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x28})); + + String msg = LDAPService.formatPasswordError( + ResultCode.OTHER, null, "42", new Control[] {shortValue, otherOid}); + + assertEquals(""" + Password change failed: server returned 80 (other) + + [result: other (80)]""", msg); + } + + @Test + @DisplayName("known NMAS codes and the unknown-code fallback") + void decodeNmasCodes() { + assertEquals("Password unique violation (matches a previous password)", + LDAPService.decodeNmasCode(-215)); + assertEquals("Password too short", LDAPService.decodeNmasCode(-216)); + assertEquals("Password too long", LDAPService.decodeNmasCode(-217)); + assertEquals("Password too simple (does not meet complexity requirements)", + LDAPService.decodeNmasCode(-218)); + assertEquals("Password in history", LDAPService.decodeNmasCode(-219)); + assertEquals("Password expired", LDAPService.decodeNmasCode(-222)); + assertEquals("Insufficient rights to change this password", + LDAPService.decodeNmasCode(-224)); + assertEquals("Password is the user's name or otherwise disallowed", + LDAPService.decodeNmasCode(-226)); + assertEquals("Password policy violation", LDAPService.decodeNmasCode(-1697)); + assertEquals("NMAS: insufficient rights for password operation", + LDAPService.decodeNmasCode(-16049)); + assertEquals("NMAS: password policy disallows this password", + LDAPService.decodeNmasCode(-16066)); + assertEquals("NMAS error -1", LDAPService.decodeNmasCode(-1)); + assertNull(LDAPService.decodeNmasCode(0)); + assertNull(LDAPService.decodeNmasCode(42)); + } +} diff --git a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java new file mode 100644 index 0000000..76859fd --- /dev/null +++ b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java @@ -0,0 +1,362 @@ +package com.pointbluetech.arborj.util; + +import com.pointbluetech.arborj.util.EdirPasswordPolicy.Assignment; +import com.pointbluetech.arborj.util.EdirPasswordPolicy.Method; +import com.pointbluetech.arborj.util.EdirPasswordPolicy.Source; +import com.unboundid.asn1.ASN1OctetString; +import com.unboundid.ldap.sdk.Control; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.ResultCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EdirPasswordPolicyTest { + + private static final String USER = "cn=alice,ou=eng,ou=users,o=acme"; + private static final String PARENT = "ou=eng,ou=users,o=acme"; + private static final String MIDDLE = "ou=users,o=acme"; + private static final String PARTITION = "o=acme"; + private static final String POLICY = "cn=Sample Password Policy,cn=Password Policies,cn=Security"; + private static final String OTHER = "cn=Other Policy,cn=Password Policies,cn=Security"; + private static final String LOGIN = "cn=Login Policy,cn=Security"; + + private static Map policies(String... kv) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + map.put(kv[i], kv[i + 1]); + } + return map; + } + + @Nested + @DisplayName("resolve") + class Resolve { + + @Test + @DisplayName("a policy on the user wins over the container, partition, and Login Policy") + void userAssignmentWins() { + Map map = policies( + USER, POLICY, + PARENT, OTHER, + PARTITION, OTHER, + LOGIN, OTHER); + + Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN); + + assertEquals(POLICY, found.policyDN()); + assertEquals(Source.USER, found.source()); + assertEquals(USER, found.assignedOnDN()); + } + + @Test + @DisplayName("the parent container wins over the partition root and the Login Policy") + void containerBeatsPartitionAndLoginPolicy() { + Map map = policies( + USER, null, + PARENT, POLICY, + PARTITION, OTHER, + LOGIN, OTHER); + + Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN); + + assertEquals(Source.CONTAINER, found.source()); + assertEquals(POLICY, found.policyDN()); + assertTrue(found.sourceText().contains(PARENT)); + } + + @Test + @DisplayName("a policy on a container that is neither the parent nor the partition root is ignored") + void intermediateContainerDoesNotApply() { + // ou=users is between the parent and the partition root, and it is + // not itself a partition. NMAS does not inherit that assignment. + Map map = policies( + USER, null, + PARENT, null, + MIDDLE, POLICY, + PARTITION, null, + LOGIN, null); + + assertNull(EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN)); + } + + @Test + @DisplayName("the partition root applies when the parent container has no policy") + void partitionRootWhenParentHasNone() { + Map map = policies( + USER, "", + PARENT, " ", + PARTITION, POLICY, + LOGIN, OTHER); + + Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN); + + assertEquals(Source.PARTITION_ROOT, found.source()); + assertEquals(POLICY, found.policyDN()); + assertEquals(PARTITION, found.assignedOnDN()); + } + + @Test + @DisplayName("a child partition does not inherit the parent partition's policy") + void childPartitionDoesNotUseParentPartition() { + String user = "cn=alice,ou=secret,o=acme"; + String childPartition = "ou=secret,o=acme"; + Map map = policies( + user, null, + "ou=secret,o=acme", null, + PARTITION, POLICY, + LOGIN, null); + + assertNull(EdirPasswordPolicy.resolve( + user, map, List.of(PARTITION, childPartition), LOGIN)); + } + + @Test + @DisplayName("the Login Policy is the default when nothing closer is assigned") + void loginPolicyIsTheDefault() { + Map map = policies( + USER, null, + PARENT, null, + PARTITION, null, + LOGIN, POLICY); + + Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN); + + assertEquals(Source.LOGIN_POLICY, found.source()); + assertEquals(POLICY, found.policyDN()); + assertEquals(LOGIN, found.assignedOnDN()); + } + + @Test + @DisplayName("no assignment anywhere means legacy NDS rules") + void nothingAssigned() { + Map map = policies(USER, null, PARENT, null, PARTITION, null, LOGIN, null); + assertNull(EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN)); + assertNull(EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), null)); + } + + @Test + @DisplayName("DN spelling differences still match") + void dnMatchIsCaseInsensitive() { + Map map = new LinkedHashMap<>(); + map.put("CN=Alice,OU=Eng,OU=Users,O=ACME", POLICY); + + Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of("O=Acme"), null); + + assertEquals(Source.USER, found.source()); + assertEquals("CN=Alice,OU=Eng,OU=Users,O=ACME", found.assignedOnDN()); + } + + @Test + @DisplayName("a blank user DN does not resolve") + void blankUser() { + assertNull(EdirPasswordPolicy.resolve(" ", Map.of(USER, POLICY), List.of(PARTITION), LOGIN)); + assertNull(EdirPasswordPolicy.resolve(null, Map.of(), List.of(), null)); + } + } + + @Nested + @DisplayName("which entries are consulted") + class SearchList { + + @Test + @DisplayName("user directly in a partition is not read twice") + void parentThatIsThePartitionIsListedOnce() { + String user = "cn=alice,o=acme"; + assertEquals(List.of(user, "o=acme"), + EdirPasswordPolicy.policySearchDns(user, List.of("o=acme", ""))); + } + + @Test + @DisplayName("parent and a different partition root are both listed; intermediate containers are not") + void parentAndPartitionOnly() { + List dns = EdirPasswordPolicy.policySearchDns(USER, List.of(PARTITION, "cn=Security")); + assertEquals(List.of(USER, PARENT, PARTITION), dns); + assertFalse(dns.contains(MIDDLE)); + } + + @Test + @DisplayName("escaped comma in the RDN stays in the user DN") + void escapedCommaParent() { + String user = "cn=a\\,b,ou=c,o=d"; + assertEquals("ou=c,o=d", EdirPasswordPolicy.parentDn(user)); + assertNull(EdirPasswordPolicy.parentDn("cn=only")); + assertNull(EdirPasswordPolicy.parentDn(null)); + } + + @Test + @DisplayName("the nearest naming context is the partition, ignoring spacing") + void nearestNamingContext() { + assertEquals("ou=users, o=acme", + EdirPasswordPolicy.nearestPartitionRoot( + "cn=alice,ou=eng,ou=users,o=acme", + List.of("", "o=acme", "ou=users, o=acme", "o=other"))); + // The user entry is not its own partition root. + assertNull(EdirPasswordPolicy.nearestPartitionRoot("o=acme", List.of("o=acme"))); + } + } + + @Nested + @DisplayName("Login Policy location") + class LoginPolicyDn { + + @Test + @DisplayName("candidates cover the root Security container and each naming context") + void candidates() { + List contexts = List.of("", "o=acme", "cn=Security,o=acme"); + assertEquals(List.of( + "cn=Security", + "cn=Security,o=acme"), + EdirPasswordPolicy.securityContainerCandidates(contexts)); + assertEquals(List.of( + "cn=Login Policy,cn=Security", + "cn=Login Policy,cn=Security,o=acme"), + EdirPasswordPolicy.loginPolicyCandidates(contexts)); + } + } + + @Nested + @DisplayName("dialog decisions") + class DialogDecisions { + + private final Assignment policy = new Assignment(POLICY, Source.USER, USER); + + @Test + @DisplayName("a found policy is shown and Password Modify stays selected") + void policyFound() { + String status = EdirPasswordPolicy.describeStatus(policy, null); + assertTrue(status.startsWith("Universal Password policy applies. ")); + assertTrue(status.contains("Assigned to this user.")); + assertFalse(EdirPasswordPolicy.offerMethodChoice(policy)); + assertEquals(Method.UNIVERSAL_PASSWORD, + EdirPasswordPolicy.defaultMethod(policy, false, true)); + } + + @Test + @DisplayName("no policy offers the choice and defaults to legacy on LDAPS") + void noPolicyDefaultsToLegacyWhenSecure() { + String status = EdirPasswordPolicy.describeStatus(null, null); + assertTrue(status.contains("No Universal Password policy")); + assertTrue(status.contains("Login Policy")); + assertTrue(EdirPasswordPolicy.offerMethodChoice(null)); + assertEquals(Method.LEGACY_NDS, + EdirPasswordPolicy.defaultMethod(null, false, true)); + } + + @Test + @DisplayName("no policy on a cleartext connection keeps Password Modify") + void noPolicyOnCleartextStaysUniversal() { + assertEquals(Method.UNIVERSAL_PASSWORD, + EdirPasswordPolicy.defaultMethod(null, false, false)); + } + + @Test + @DisplayName("a failed lookup keeps Password Modify and still offers the choice") + void lookupFailed() { + String status = EdirPasswordPolicy.describeStatus(null, "insufficient access"); + assertTrue(status.startsWith("Could not check whether a Universal Password policy applies: ")); + assertTrue(status.contains("insufficient access")); + assertTrue(EdirPasswordPolicy.offerMethodChoice(null)); + assertEquals(Method.UNIVERSAL_PASSWORD, + EdirPasswordPolicy.defaultMethod(null, true, true)); + } + + @Test + @DisplayName("partition and Login Policy source text names the entry") + void sourceText() { + assertEquals("Inherited from partition root " + PARTITION + ".", + new Assignment(POLICY, Source.PARTITION_ROOT, PARTITION).sourceText()); + assertEquals("Inherited from the Login Policy (" + LOGIN + ").", + new Assignment(POLICY, Source.LOGIN_POLICY, LOGIN).sourceText()); + } + } + + @Nested + @DisplayName("when to offer a legacy retry") + class LegacyRetry { + + @Test + @DisplayName("result 53 and 19 offer a retry") + void unwillingAndConstraint() { + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "no"))); + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.CONSTRAINT_VIOLATION, "too short"))); + } + + @Test + @DisplayName("an NMAS diagnostic or response control offers a retry") + void nmas() { + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.OTHER, "NMAS Change Password extension failed"))); + Control control = new Control(EdirPasswordPolicy.NMAS_CONTROL_PREFIX + "7", false, + new ASN1OctetString(new byte[] {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x28})); + LDAPException withControl = new LDAPException(ResultCode.OTHER, "Password too short", + null, null, new Control[] {control}, null); + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy(withControl)); + } + + @Test + @DisplayName("NMAS on the cause is enough when the wrapper text dropped the word") + void nmasOnTheCause() { + LDAPException cause = new LDAPException(ResultCode.OTHER, + "NMAS Change Password extension failed"); + LDAPException wrapper = new LDAPException(ResultCode.OTHER, "Password too short", cause); + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy(wrapper)); + } + + @Test + @DisplayName("a negative matched DN is an NMAS failure even when the text is not") + void matchedDnCode() { + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.OTHER, "Password too short", "-216", null))); + assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.OTHER, "Password too short", "NDS error: too short", null))); + assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.OTHER, "Password too short", "42", null))); + } + + @Test + @DisplayName("a wrong current password or missing rights is not a legacy retry") + void otherFailures() { + assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.INVALID_CREDENTIALS, "Current password is incorrect."))); + assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.INSUFFICIENT_ACCESS_RIGHTS, "no rights"))); + assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy( + new LDAPException(ResultCode.OTHER, "server returned other"))); + assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy(null)); + assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy(new IllegalStateException("no"))); + } + } + + @Nested + @DisplayName("secure connection") + class SecureConnection { + + @Test + @DisplayName("a connected socket counts only when TLS is actually on") + void liveSocket() { + assertTrue(EdirPasswordPolicy.connectionIsSecure(true, true, false, 389)); + assertFalse(EdirPasswordPolicy.connectionIsSecure(true, false, true, 636)); + assertFalse(EdirPasswordPolicy.connectionIsSecure(true, false, false, 389)); + } + + @Test + @DisplayName("before connect, LDAPS config counts") + void configOnly() { + assertTrue(EdirPasswordPolicy.connectionIsSecure(false, false, true, 389)); + assertTrue(EdirPasswordPolicy.connectionIsSecure(false, false, false, 636)); + assertFalse(EdirPasswordPolicy.connectionIsSecure(false, false, false, 389)); + } + } +} From ef6b001435e865acd168763a86ca44d6c371861a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Nov 2026 21:55:46 +0000 Subject: [PATCH 2/5] Limit legacy password retry and stop claiming no policy when the partition is unseen. Offer a userPassword retry only after a conclusive lookup that found no Universal Password policy. Treat a missing local replica as unknown, keep Password Modify selected, and say so. Always mask userPassword and unicodePwd in the activity log, run password writes off the JavaFX thread, and re-check LDAPS after the connection is up. Co-authored-by: Jerry Combs --- .../arborj/service/ActivityLogger.java | 17 +- .../arborj/service/LDAPService.java | 120 ++++++++++--- .../arborj/util/EdirPasswordPolicy.java | 160 +++++++++++++---- .../arborj/view/ChangePasswordDialog.java | 162 +++++++++++------- .../arborj/util/EdirPasswordPolicyTest.java | 132 +++++++++++++- 5 files changed, 459 insertions(+), 132 deletions(-) diff --git a/src/main/java/com/pointbluetech/arborj/service/ActivityLogger.java b/src/main/java/com/pointbluetech/arborj/service/ActivityLogger.java index 91c95f7..10dd5ff 100644 --- a/src/main/java/com/pointbluetech/arborj/service/ActivityLogger.java +++ b/src/main/java/com/pointbluetech/arborj/service/ActivityLogger.java @@ -15,6 +15,7 @@ import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.prefs.Preferences; @@ -265,14 +266,28 @@ private static void publish(StringProperty prop, String value) { } } + /** Password values are never written to the log, even if the mask list is cleared. */ + private static final Set ALWAYS_MASKED = Set.of("userpassword", "unicodepwd"); + private String maskValue(String attributeName, String value) { if (attributeName == null || value == null) return value; - if (maskedAttributesLower.contains(attributeName.toLowerCase())) { + String base = attributeBaseName(attributeName); + String full = attributeName.trim().toLowerCase(Locale.ROOT); + if (ALWAYS_MASKED.contains(base) + || maskedAttributesLower.contains(base) + || maskedAttributesLower.contains(full)) { return "****"; } return value; } + /** Attribute type without options ({@code userPassword;binary} → {@code userpassword}). */ + static String attributeBaseName(String attributeName) { + int semi = attributeName.indexOf(';'); + String base = semi >= 0 ? attributeName.substring(0, semi) : attributeName; + return base.trim().toLowerCase(Locale.ROOT); + } + private static Set parseMasked(String raw) { if (raw == null || raw.isBlank()) return Set.of(); Set out = new HashSet<>(); diff --git a/src/main/java/com/pointbluetech/arborj/service/LDAPService.java b/src/main/java/com/pointbluetech/arborj/service/LDAPService.java index ece38d0..541e3aa 100644 --- a/src/main/java/com/pointbluetech/arborj/service/LDAPService.java +++ b/src/main/java/com/pointbluetech/arborj/service/LDAPService.java @@ -639,6 +639,9 @@ public void changePasswordLegacyNDS(String dn, String newPassword) throws LDAPEx throw new LDAPException(ResultCode.CONSTRAINT_VIOLATION, "New password must not be empty."); } + // Connect first, then judge the live socket. A disconnected config + // with useTLS or port 636 must not count as a secure channel. + ensureWritable(); if (!isSecureConnection()) { throw new LDAPException(ResultCode.CONFIDENTIALITY_REQUIRED, "Legacy NDS password changes replace userPassword and require " @@ -648,13 +651,15 @@ public void changePasswordLegacyNDS(String dn, String newPassword) throws LDAPEx } /** - * Read-only: which Universal Password policy applies to {@code userDN}, - * or null when nothing is assigned. Reads {@code nspmPasswordPolicyDN} - * on the user, the parent container, the partition root, and the Login - * Policy object. Does not modify the directory and does not require a - * writable connection. + * Read-only: which Universal Password policy applies to {@code userDN}. + * Reads {@code nspmPasswordPolicyDN} on the user, the parent container, + * the partition root, and the Login Policy object. The partition root is + * the nearest ancestor that is a naming context or carries a + * {@code replica} attribute. When that root is not visible on this + * server, the lookup is not a conclusive "no policy". Does not modify + * the directory and does not require a writable connection. */ - public EdirPasswordPolicy.Assignment findUniversalPasswordPolicy(String userDN) + public EdirPasswordPolicy.PolicyLookup findUniversalPasswordPolicy(String userDN) throws LDAPException { ensureConnected(); List contexts = fetchNamingContexts(); @@ -662,27 +667,41 @@ public EdirPasswordPolicy.Assignment findUniversalPasswordPolicy(String userDN) Map policies = new LinkedHashMap<>(); SearchResultEntry user = readEntryRequired(userDN, EdirPasswordPolicy.POLICY_ATTRIBUTE); policies.put(userDN, attributeOrNull(user, EdirPasswordPolicy.POLICY_ATTRIBUTE)); - // The user's own assignment wins. Don't let an unreadable container - // or Login Policy hide a policy we already have. + // The user's own assignment wins. Don't walk containers we may not + // be allowed to read before reporting it. if (policies.get(userDN) != null) { - return EdirPasswordPolicy.resolve(userDN, policies, contexts, null); + return EdirPasswordPolicy.lookup(userDN, policies, null, false, null); } - List toRead = EdirPasswordPolicy.policySearchDns(userDN, contexts); - LDAPException accessDenied = null; - // Index 0 is the user, already read above. An entry we cannot read - // is skipped so a later hit (the partition root, the Login Policy) - // is not hidden, and is reported only when nothing was found. - for (int i = 1; i < toRead.size(); i++) { - String dn = toRead.get(i); + String parent = EdirPasswordPolicy.parentDn(userDN); + if (parent != null) { + SearchResultEntry parentEntry = readEntryIfPresent(parent, EdirPasswordPolicy.POLICY_ATTRIBUTE); + policies.put(parent, parentEntry == null + ? null + : attributeOrNull(parentEntry, EdirPasswordPolicy.POLICY_ATTRIBUTE)); + if (policies.get(parent) != null) { + return EdirPasswordPolicy.lookup(userDN, policies, null, false, null); + } + } + + EdirPasswordPolicy.PartitionAssessment partition = + EdirPasswordPolicy.assessPartition(probePartitionAncestors(userDN, contexts)); + if (!partition.known()) { + return new EdirPasswordPolicy.PolicyLookup(null, false); + } + + String root = partition.partitionRootDn(); + if (root != null && (parent == null || !EdirPasswordPolicy.sameDn(root, parent))) { try { - SearchResultEntry entry = readEntryIfPresent(dn, EdirPasswordPolicy.POLICY_ATTRIBUTE); - policies.put(dn, entry == null + SearchResultEntry rootEntry = readEntryIfPresent(root, EdirPasswordPolicy.POLICY_ATTRIBUTE); + policies.put(root, rootEntry == null ? null - : attributeOrNull(entry, EdirPasswordPolicy.POLICY_ATTRIBUTE)); + : attributeOrNull(rootEntry, EdirPasswordPolicy.POLICY_ATTRIBUTE)); } catch (LDAPException e) { - if (e.getResultCode() != ResultCode.INSUFFICIENT_ACCESS_RIGHTS) throw e; - accessDenied = e; + if (e.getResultCode() == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { + return new EdirPasswordPolicy.PolicyLookup(null, false); + } + throw e; } } @@ -694,14 +713,61 @@ public EdirPasswordPolicy.Assignment findUniversalPasswordPolicy(String userDN) policies.put(loginDN, attributeOrNull(login, EdirPasswordPolicy.POLICY_ATTRIBUTE)); } } catch (LDAPException e) { - if (e.getResultCode() != ResultCode.INSUFFICIENT_ACCESS_RIGHTS) throw e; - accessDenied = e; + if (e.getResultCode() == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { + // A policy already read on the partition root still applies. + // Not being able to read the Login Policy only matters when + // nothing closer was assigned. + EdirPasswordPolicy.PolicyLookup soFar = + EdirPasswordPolicy.lookup(userDN, policies, root, true, null); + if (soFar.assignment() != null) return soFar; + return new EdirPasswordPolicy.PolicyLookup(null, false); + } + throw e; } + return EdirPasswordPolicy.lookup(userDN, policies, root, true, loginDN); + } - EdirPasswordPolicy.Assignment found = - EdirPasswordPolicy.resolve(userDN, policies, contexts, loginDN); - if (found == null && accessDenied != null) throw accessDenied; - return found; + /** + * Ancestors from the parent upward. A naming context is a partition root + * this server holds. Any closer container is checked for the operational + * {@code replica} attribute, which marks a partition root even when + * namingContexts omitted it. An unreadable ancestor ends the walk. + */ + private List probePartitionAncestors( + String userDN, List namingContexts) throws LDAPException { + List probes = new ArrayList<>(); + String current = EdirPasswordPolicy.parentDn(userDN); + while (current != null) { + if (EdirPasswordPolicy.isNamingContext(current, namingContexts)) { + probes.add(new EdirPasswordPolicy.AncestorProbe(current, true, true)); + break; + } + try { + SearchResultEntry entry = connection.getEntry(current, "replica"); + if (entry == null) { + probes.add(new EdirPasswordPolicy.AncestorProbe(current, false, false)); + break; + } + boolean replica = entry.hasAttribute("replica"); + probes.add(new EdirPasswordPolicy.AncestorProbe(current, replica, true)); + if (replica) break; + } catch (LDAPException e) { + ResultCode rc = e.getResultCode(); + if (rc == ResultCode.NO_SUCH_ATTRIBUTE) { + probes.add(new EdirPasswordPolicy.AncestorProbe(current, false, true)); + } else if (rc == ResultCode.NO_SUCH_OBJECT + || rc == ResultCode.REFERRAL + || rc == ResultCode.INSUFFICIENT_ACCESS_RIGHTS + || rc == ResultCode.INVALID_DN_SYNTAX) { + probes.add(new EdirPasswordPolicy.AncestorProbe(current, false, false)); + break; + } else { + throw e; + } + } + current = EdirPasswordPolicy.parentDn(current); + } + return probes; } /** diff --git a/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java index 64035f7..24bf45f 100644 --- a/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java +++ b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java @@ -78,46 +78,89 @@ public String sourceText() { } } + /** + * Result of a policy check. {@code conclusive} is false when the user's + * partition root could not be seen on this server: a missing assignment + * is then not "no policy". An assignment on the user or the parent + * container is still conclusive, because those are checked before the + * partition root. + */ + public record PolicyLookup(Assignment assignment, boolean conclusive) { + public boolean foundNone() { + return conclusive && assignment == null; + } + } + + /** + * One container above the user, closest first. + * {@code partitionRoot} means this entry has a {@code replica} attribute + * or is a naming context this server holds. {@code readable} is false + * when the read failed or was referred — the walk stops there, because a + * closer partition root may have been missed. + */ + public record AncestorProbe(String dn, boolean partitionRoot, boolean readable) {} + + /** The user's partition root, or {@code known == false} when it isn't visible here. */ + public record PartitionAssessment(String partitionRootDn, boolean known) {} + /** * Status line for the change-password dialog. The policy DN, when there * is one, is {@link Assignment#policyDN()} and is shown separately. + * A failed lookup ({@code failureMessage}) and an unseen partition root + * both refuse to say that no policy applies. */ - public static String describeStatus(Assignment assignment, String failureMessage) { + public static String describeStatus(PolicyLookup lookup, String failureMessage) { if (failureMessage != null && !failureMessage.isBlank()) { return "Could not check whether a Universal Password policy applies: " + failureMessage; } - if (assignment == null) { - return "No Universal Password policy applies to this user " - + "(none on the user, the parent container, the partition root, " - + "or the Login Policy)."; + if (lookup != null && lookup.assignment() != null) { + return "Universal Password policy applies. " + lookup.assignment().sourceText(); } - return "Universal Password policy applies. " + assignment.sourceText(); + if (lookup == null || !lookup.conclusive()) { + return "This server does not show the user's partition root. " + + "namingContexts lists only partitions replicated here, and no " + + "closer replica attribute could be read, so a Universal Password " + + "policy on that partition could not be ruled out. " + + "Password Modify stays selected."; + } + return "No Universal Password policy applies to this user " + + "(none on the user, the parent container, the partition root, " + + "or the Login Policy)."; } /** - * Show the Universal Password / Legacy NDS choice when we did not find - * a policy. A failed lookup also has no assignment; the choice stays - * available and {@link #defaultMethod} keeps Password Modify selected. + * Show the Universal Password / Legacy NDS choice when no policy was + * found. That includes a failed or inconclusive lookup; {@link #defaultMethod} + * keeps Password Modify selected in those cases. */ - public static boolean offerMethodChoice(Assignment assignment) { - return assignment == null; + public static boolean offerMethodChoice(PolicyLookup lookup) { + return lookup == null || lookup.assignment() == null; } /** - * Legacy NDS when the lookup succeeded and found nothing, and the - * connection can carry a {@code userPassword} replace. A policy, a - * failed lookup, or a cleartext connection keeps Password Modify — - * the operation the dialog already used. + * Legacy NDS only when the lookup succeeded, the partition root was + * visible, and nothing was assigned — and the connection can carry a + * {@code userPassword} replace. A policy, a failed lookup, an unseen + * partition, or a cleartext connection keeps Password Modify. */ - public static Method defaultMethod(Assignment assignment, boolean lookupFailed, - boolean secureConnection) { - if (assignment != null || lookupFailed || !secureConnection) { + public static Method defaultMethod(PolicyLookup lookup, boolean secureConnection) { + if (lookup == null || !lookup.foundNone() || !secureConnection) { return Method.UNIVERSAL_PASSWORD; } return Method.LEGACY_NDS; } + /** + * Offer a {@code userPassword} retry only when the lookup succeeded and + * found no Universal Password policy, and Password Modify failed in the + * way a legacy-only tree fails. A policy, a failed lookup, or an unseen + * partition root just shows the Password Modify error. + */ + public static boolean offerLegacyRetry(PolicyLookup lookup, Throwable error) { + return lookup != null && lookup.foundNone() && passwordModifySuggestsLegacy(error); + } + /** * True when a failed Password Modify is the case the legacy replace * can cover: result 53 (unwillingToPerform), 19 (constraintViolation), @@ -174,42 +217,89 @@ public static List policySearchDns(String userDN, Collection nam } /** - * Pick the policy from values already read. {@code policyByDn} maps an - * entry DN to its {@code nspmPasswordPolicyDN} (missing or blank means - * that entry assigns nothing). Keys are matched case-insensitively. - * An ancestor that is neither the parent nor the partition root is - * ignored even if the map contains it. + * Pick the policy from values already read, treating the nearest + * naming context as the partition root. When the user is not under any + * naming context, the partition root is unknown: a user or parent + * assignment is still returned, and anything else is not "no policy". + * {@link #lookup} is the same decision with an explicit partition root + * from {@link #assessPartition}. */ public static Assignment resolve(String userDN, Map policyByDn, Collection namingContexts, String loginPolicyDN) { if (userDN == null || userDN.isBlank()) return null; + String root = nearestPartitionRoot(userDN, namingContexts); + return lookup(userDN, policyByDn, root, root != null, loginPolicyDN).assignment(); + } + + /** + * NMAS order. {@code partitionRootKnown} is false when this server could + * not see the user's partition root. The user and the parent container + * are still decisive. The partition root and the Login Policy are not + * consulted then, and the result is not a conclusive "none". + */ + public static PolicyLookup lookup(String userDN, Map policyByDn, + String partitionRootDn, boolean partitionRootKnown, + String loginPolicyDN) { + if (userDN == null || userDN.isBlank()) return new PolicyLookup(null, false); Hit user = find(policyByDn, userDN); - if (user != null) return new Assignment(user.policyDN, Source.USER, user.dn); + if (user != null) { + return new PolicyLookup(new Assignment(user.policyDN, Source.USER, user.dn), true); + } String parent = parentDn(userDN); if (parent != null) { Hit container = find(policyByDn, parent); if (container != null) { - return new Assignment(container.policyDN, Source.CONTAINER, container.dn); + return new PolicyLookup( + new Assignment(container.policyDN, Source.CONTAINER, container.dn), true); } } - String partition = nearestPartitionRoot(userDN, namingContexts); - if (partition != null && (parent == null || !sameDn(partition, parent))) { - Hit part = find(policyByDn, partition); + if (!partitionRootKnown) return new PolicyLookup(null, false); + + if (partitionRootDn != null && (parent == null || !sameDn(partitionRootDn, parent))) { + Hit part = find(policyByDn, partitionRootDn); if (part != null) { - return new Assignment(part.policyDN, Source.PARTITION_ROOT, part.dn); + return new PolicyLookup( + new Assignment(part.policyDN, Source.PARTITION_ROOT, part.dn), true); } } if (loginPolicyDN != null && !loginPolicyDN.isBlank()) { Hit login = find(policyByDn, loginPolicyDN); if (login != null) { - return new Assignment(login.policyDN, Source.LOGIN_POLICY, login.dn); + return new PolicyLookup( + new Assignment(login.policyDN, Source.LOGIN_POLICY, login.dn), true); } } - return null; + return new PolicyLookup(null, true); + } + + /** + * Walk ancestors from the parent upward. The first readable partition + * root wins. An unreadable ancestor stops the walk: a naming context + * further up is not the user's partition, because a closer one may have + * been missed. Finishing the walk without a partition root means this + * server does not show it. + */ + public static PartitionAssessment assessPartition(List fromParentUpward) { + if (fromParentUpward == null) return new PartitionAssessment(null, false); + for (AncestorProbe probe : fromParentUpward) { + if (probe == null || !probe.readable()) return new PartitionAssessment(null, false); + if (probe.partitionRoot()) return new PartitionAssessment(probe.dn(), true); + } + return new PartitionAssessment(null, false); + } + + /** True when {@code dn} is one of the naming contexts, ignoring case and spacing. */ + public static boolean isNamingContext(String dn, Collection namingContexts) { + if (dn == null || dn.isBlank() || namingContexts == null) return false; + for (String ctx : namingContexts) { + if (ctx == null || ctx.isBlank()) continue; + if (sameDn(dn, ctx)) return true; + } + return false; } /** Parent DN, or null for a top-level or empty DN. */ @@ -226,9 +316,11 @@ public static String parentDn(String dn) { } /** - * Naming context that is the partition root for {@code userDN}: the - * longest naming context the user is strictly inside. The user DN - * itself is not a partition root for this purpose. + * Longest naming context the user is strictly inside. eDirectory's + * root DSE lists only partitions replicated on this server, so a hit + * here can still be a parent partition with a closer partition root + * in between. {@link #assessPartition} is what decides that. The user + * DN itself is not a partition root for this purpose. */ public static String nearestPartitionRoot(String userDN, Collection namingContexts) { if (userDN == null || userDN.isBlank() || namingContexts == null) return null; diff --git a/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java b/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java index 30940c3..5d54e7e 100644 --- a/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java +++ b/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java @@ -20,8 +20,9 @@ * {@code nspmPasswordPolicyDN} on the user, the parent container, the * partition root, or the Login Policy). When none applies, the user * chooses Universal Password (RFC 3062) or Legacy NDS (replace - * {@code userPassword} over LDAPS). Password Modify failures 53 / 19 / - * NMAS offer a legacy retry. + * {@code userPassword} over LDAPS). A Password Modify failure offers + * that legacy replace only when the policy lookup succeeded and found + * no policy. The writes run off the JavaFX thread. * - Generic / OpenLDAP: RFC 3062 Password Modify. Supports self-service * (current + new password) and administrative reset (new password only — * uses the connected admin's rights). @@ -55,6 +56,12 @@ public class ChangePasswordDialog extends Dialog { private final VBox methodBox = new VBox(4); private final Label legacySecureHint = new Label( "Legacy NDS replaces userPassword and needs an LDAPS connection."); + private final Label legacyNoVerifyHint = new Label( + "Legacy NDS does not verify the current password. It replaces userPassword using the bound account's rights."); + + /** Set on the JavaFX thread when the policy read finishes. Null if it failed. */ + private EdirPasswordPolicy.PolicyLookup policyLookup; + private Button okButton; // AD-specific controls private final CheckBox mustChangeCheckbox = new CheckBox("User must change password at next logon"); @@ -97,7 +104,8 @@ public ChangePasswordDialog(MainController controller, String targetDN, ButtonType cancelType = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); getDialogPane().getButtonTypes().addAll(okType, cancelType); - Button okBtn = (Button) getDialogPane().lookupButton(okType); + okButton = (Button) getDialogPane().lookupButton(okType); + Button okBtn = okButton; // addEventFilter (not setOnAction): JavaFX runs filters before the // Dialog's internal action that closes the pane, so consuming here // keeps the dialog open. setOnAction runs after the close filter, @@ -163,7 +171,10 @@ private VBox buildContent(DirectoryType dirType) { legacySecureHint.setStyle("-fx-font-size: 11px;"); legacySecureHint.setVisible(false); legacySecureHint.setManaged(false); - methodBox.getChildren().addAll(methodLabel, universalRadio, legacyRadio, legacySecureHint); + legacyNoVerifyHint.setWrapText(true); + legacyNoVerifyHint.setStyle("-fx-font-size: 11px;"); + methodBox.getChildren().addAll( + methodLabel, universalRadio, legacyRadio, legacyNoVerifyHint, legacySecureHint); // Hidden until the policy read finishes. Shown only when no // Universal Password policy applies. methodBox.setVisible(false); @@ -291,56 +302,72 @@ private void doChangePassword(DirectoryType dirType) { return; } - try { - switch (dirType) { - case ACTIVE_DIRECTORY -> { - controller.getLdapService().changePasswordAD(targetDN, newPassword); - // Only touch userAccountControl when we successfully read - // the existing value first — otherwise a REPLACE based on - // currentUAC=0 would wipe account-type bits and ACCOUNTDISABLE. - if (uacFetched) { - controller.getLdapService().updateADPasswordFlags( - targetDN, - mustChangeCheckbox.isSelected(), - neverExpiresCheckbox.isSelected(), - currentUAC); - } - } - case EDIRECTORY -> { - if (legacyRadio.isSelected()) { - controller.getLdapService().changePasswordLegacyNDS(targetDN, newPassword); - } else { - controller.getLdapService().changePasswordEDir( - targetDN, effectiveOldPassword(), newPassword); + // Read every control here. The write itself runs off the JavaFX thread. + boolean legacy = legacyRadio.isSelected(); + String oldPassword = effectiveOldPassword(); + boolean mustChange = mustChangeCheckbox.isSelected(); + boolean neverExpires = neverExpiresCheckbox.isSelected(); + boolean applyUac = uacFetched; + int uac = currentUAC; + EdirPasswordPolicy.PolicyLookup lookup = policyLookup; + + okButton.setDisable(true); + Thread.ofVirtual().start(() -> { + try { + writePassword(dirType, newPassword, oldPassword, legacy, + mustChange, neverExpires, applyUac, uac); + Platform.runLater(this::close); + } catch (Exception ex) { + Platform.runLater(() -> { + okButton.setDisable(false); + // formatPasswordError's text is the exception message. + // A legacy retry is only for a conclusive "no policy". + String msg = messageOf(ex); + if (dirType == DirectoryType.EDIRECTORY + && !legacy + && EdirPasswordPolicy.offerLegacyRetry(lookup, ex)) { + offerLegacyRetry(newPassword, msg); + return; } - } - default -> { - // Generic / OpenLDAP -- RFC 3062 - controller.getLdapService().changePasswordRFC3062( - targetDN, effectiveOldPassword(), newPassword); + showError(msg); + }); + } + }); + } + + private void writePassword(DirectoryType dirType, String newPassword, String oldPassword, + boolean legacy, boolean mustChange, boolean neverExpires, + boolean applyUac, int uac) throws Exception { + switch (dirType) { + case ACTIVE_DIRECTORY -> { + controller.getLdapService().changePasswordAD(targetDN, newPassword); + // Only touch userAccountControl when we successfully read + // the existing value first — otherwise a REPLACE based on + // currentUAC=0 would wipe account-type bits and ACCOUNTDISABLE. + if (applyUac) { + controller.getLdapService().updateADPasswordFlags( + targetDN, mustChange, neverExpires, uac); } } - close(); - } catch (Exception ex) { - // The service already prefixes its message with "Password change - // failed:" and includes the LDAP result code; don't double-prefix. - String msg = messageOf(ex); - if (dirType == DirectoryType.EDIRECTORY - && !legacyRadio.isSelected() - && EdirPasswordPolicy.passwordModifySuggestsLegacy(ex)) { - offerLegacyRetry(msg); - return; + case EDIRECTORY -> { + if (legacy) { + controller.getLdapService().changePasswordLegacyNDS(targetDN, newPassword); + } else { + controller.getLdapService().changePasswordEDir(targetDN, oldPassword, newPassword); + } } - showError(msg); + default -> controller.getLdapService().changePasswordRFC3062( + targetDN, oldPassword, newPassword); } } /** - * Password Modify failed in the way a legacy-only tree fails (53, 19, - * or an NMAS error). Ask before replacing {@code userPassword}. A - * cleartext connection cannot do that replace, so the error stands. + * Password Modify failed and the lookup had already found no Universal + * Password policy. Ask before replacing {@code userPassword}. The + * replace runs off the JavaFX thread. A cleartext connection cannot do + * it, so the Password Modify error stands. */ - private void offerLegacyRetry(String message) { + private void offerLegacyRetry(String newPassword, String message) { if (!controller.getLdapService().isSecureConnection()) { showError(message + "\n\nA legacy NDS replace was not attempted because this " @@ -351,34 +378,41 @@ private void offerLegacyRetry(String message) { alert.setTitle("Retry with Legacy NDS Password"); alert.setHeaderText("Retry by replacing userPassword?"); alert.setContentText(message - + "\n\nPassword Modify was rejected. Retry with Legacy NDS " + + "\n\nNo Universal Password policy applies. Retry with Legacy NDS " + "(replace userPassword over this LDAPS connection)? " - + "That is the modify Apache Directory Studio sends, and it " - + "does not go through Universal Password."); + + "That does not verify the current password and does not go " + + "through Universal Password."); alert.getButtonTypes().setAll(ButtonType.YES, ButtonType.NO); var window = ownerWindow(); if (window != null) alert.initOwner(window); var choice = alert.showAndWait(); if (choice.isEmpty() || choice.get() != ButtonType.YES) return; - try { - controller.getLdapService().changePasswordLegacyNDS(targetDN, newPasswordField.getText()); - close(); - } catch (Exception retryEx) { - showError(messageOf(retryEx)); - } + + okButton.setDisable(true); + Thread.ofVirtual().start(() -> { + try { + controller.getLdapService().changePasswordLegacyNDS(targetDN, newPassword); + Platform.runLater(this::close); + } catch (Exception retryEx) { + Platform.runLater(() -> { + okButton.setDisable(false); + showError(messageOf(retryEx)); + }); + } + }); } private void loadEdirPolicy(Button okBtn) { Thread.ofVirtual().start(() -> { - EdirPasswordPolicy.Assignment assignment = null; + EdirPasswordPolicy.PolicyLookup lookup = null; String failure = null; try { - assignment = controller.getLdapService().findUniversalPasswordPolicy(targetDN); + lookup = controller.getLdapService().findUniversalPasswordPolicy(targetDN); } catch (Exception e) { failure = e.getMessage() == null || e.getMessage().isBlank() ? e.toString() : e.getMessage(); } - EdirPasswordPolicy.Assignment found = assignment; + EdirPasswordPolicy.PolicyLookup found = lookup; String error = failure; boolean secure = false; try { @@ -388,13 +422,17 @@ private void loadEdirPolicy(Button okBtn) { } boolean secureConnection = secure; Platform.runLater(() -> { - policyStatusLabel.setText(EdirPasswordPolicy.describeStatus(found, error)); - boolean hasPolicy = found != null; - policyDnLabel.setText(hasPolicy ? found.policyDN() : ""); + // A failed read is not a lookup result. Leave policyLookup null + // so a later Password Modify error is shown as-is. + policyLookup = error == null ? found : null; + EdirPasswordPolicy.PolicyLookup forUi = policyLookup; + policyStatusLabel.setText(EdirPasswordPolicy.describeStatus(forUi, error)); + boolean hasPolicy = forUi != null && forUi.assignment() != null; + policyDnLabel.setText(hasPolicy ? forUi.assignment().policyDN() : ""); policyDnLabel.setVisible(hasPolicy); policyDnLabel.setManaged(hasPolicy); - boolean offer = EdirPasswordPolicy.offerMethodChoice(found); + boolean offer = EdirPasswordPolicy.offerMethodChoice(forUi); methodBox.setVisible(offer); methodBox.setManaged(offer); legacyRadio.setDisable(!secureConnection); @@ -402,7 +440,7 @@ private void loadEdirPolicy(Button okBtn) { legacySecureHint.setManaged(offer && !secureConnection); EdirPasswordPolicy.Method method = EdirPasswordPolicy.defaultMethod( - found, error != null, secureConnection); + forUi, secureConnection); if (method == EdirPasswordPolicy.Method.LEGACY_NDS) { legacyRadio.setSelected(true); } else { diff --git a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java index 76859fd..95a27ab 100644 --- a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java +++ b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java @@ -1,7 +1,10 @@ package com.pointbluetech.arborj.util; +import com.pointbluetech.arborj.util.EdirPasswordPolicy.AncestorProbe; import com.pointbluetech.arborj.util.EdirPasswordPolicy.Assignment; import com.pointbluetech.arborj.util.EdirPasswordPolicy.Method; +import com.pointbluetech.arborj.util.EdirPasswordPolicy.PartitionAssessment; +import com.pointbluetech.arborj.util.EdirPasswordPolicy.PolicyLookup; import com.pointbluetech.arborj.util.EdirPasswordPolicy.Source; import com.unboundid.asn1.ASN1OctetString; import com.unboundid.ldap.sdk.Control; @@ -229,34 +232,36 @@ void candidates() { class DialogDecisions { private final Assignment policy = new Assignment(POLICY, Source.USER, USER); + private final PolicyLookup found = new PolicyLookup(policy, true); + private final PolicyLookup none = new PolicyLookup(null, true); @Test @DisplayName("a found policy is shown and Password Modify stays selected") void policyFound() { - String status = EdirPasswordPolicy.describeStatus(policy, null); + String status = EdirPasswordPolicy.describeStatus(found, null); assertTrue(status.startsWith("Universal Password policy applies. ")); assertTrue(status.contains("Assigned to this user.")); - assertFalse(EdirPasswordPolicy.offerMethodChoice(policy)); + assertFalse(EdirPasswordPolicy.offerMethodChoice(found)); assertEquals(Method.UNIVERSAL_PASSWORD, - EdirPasswordPolicy.defaultMethod(policy, false, true)); + EdirPasswordPolicy.defaultMethod(found, true)); } @Test @DisplayName("no policy offers the choice and defaults to legacy on LDAPS") void noPolicyDefaultsToLegacyWhenSecure() { - String status = EdirPasswordPolicy.describeStatus(null, null); + String status = EdirPasswordPolicy.describeStatus(none, null); assertTrue(status.contains("No Universal Password policy")); assertTrue(status.contains("Login Policy")); - assertTrue(EdirPasswordPolicy.offerMethodChoice(null)); + assertTrue(EdirPasswordPolicy.offerMethodChoice(none)); assertEquals(Method.LEGACY_NDS, - EdirPasswordPolicy.defaultMethod(null, false, true)); + EdirPasswordPolicy.defaultMethod(none, true)); } @Test @DisplayName("no policy on a cleartext connection keeps Password Modify") void noPolicyOnCleartextStaysUniversal() { assertEquals(Method.UNIVERSAL_PASSWORD, - EdirPasswordPolicy.defaultMethod(null, false, false)); + EdirPasswordPolicy.defaultMethod(none, false)); } @Test @@ -267,7 +272,7 @@ void lookupFailed() { assertTrue(status.contains("insufficient access")); assertTrue(EdirPasswordPolicy.offerMethodChoice(null)); assertEquals(Method.UNIVERSAL_PASSWORD, - EdirPasswordPolicy.defaultMethod(null, true, true)); + EdirPasswordPolicy.defaultMethod(null, true)); } @Test @@ -325,6 +330,25 @@ void matchedDnCode() { new LDAPException(ResultCode.OTHER, "Password too short", "42", null))); } + @Test + @DisplayName("retry only when the lookup succeeded and found no policy") + void retryRequiresConclusiveNone() { + LDAPException unwilling = new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "no"); + LDAPException constraint = new LDAPException(ResultCode.CONSTRAINT_VIOLATION, "too short"); + PolicyLookup none = new PolicyLookup(null, true); + PolicyLookup applied = new PolicyLookup( + new Assignment(POLICY, Source.USER, USER), true); + PolicyLookup unseenPartition = new PolicyLookup(null, false); + + assertTrue(EdirPasswordPolicy.offerLegacyRetry(none, unwilling)); + assertTrue(EdirPasswordPolicy.offerLegacyRetry(none, constraint)); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(applied, unwilling)); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(unseenPartition, unwilling)); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(null, unwilling)); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(none, + new LDAPException(ResultCode.INSUFFICIENT_ACCESS_RIGHTS, "no rights"))); + } + @Test @DisplayName("a wrong current password or missing rights is not a legacy retry") void otherFailures() { @@ -339,6 +363,98 @@ void otherFailures() { } } + @Nested + @DisplayName("partition root not held on this server") + class PartitionVisibility { + + @Test + @DisplayName("an unread closer container blocks a farther naming context") + void unreadCloserContainerIsNotAParentPartition() { + // namingContexts would report o=acme, but ou=secret sits between + // the user and that context and could not be read. It may be the + // real partition root. Do not treat o=acme as this user's partition. + PartitionAssessment assessment = EdirPasswordPolicy.assessPartition(List.of( + new AncestorProbe("ou=secret,o=acme", false, false))); + assertFalse(assessment.known()); + assertNull(assessment.partitionRootDn()); + + String user = "cn=alice,ou=secret,o=acme"; + Map map = policies( + user, null, + "ou=secret,o=acme", null, + PARTITION, POLICY, + LOGIN, OTHER); + PolicyLookup lookup = EdirPasswordPolicy.lookup(user, map, null, false, LOGIN); + + assertNull(lookup.assignment()); + assertFalse(lookup.conclusive()); + String status = EdirPasswordPolicy.describeStatus(lookup, null); + assertFalse(status.contains("No Universal Password policy")); + assertTrue(status.toLowerCase().contains("partition")); + assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(lookup, true)); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(lookup, + new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "NMAS"))); + } + + @Test + @DisplayName("a replica attribute closer than any naming context is the partition root") + void replicaBeatsAFartherNamingContext() { + PartitionAssessment assessment = EdirPasswordPolicy.assessPartition(List.of( + new AncestorProbe(PARENT, false, true), + new AncestorProbe(MIDDLE, true, true))); + assertTrue(assessment.known()); + assertEquals(MIDDLE, assessment.partitionRootDn()); + + Map map = policies(USER, null, PARENT, null, MIDDLE, POLICY, PARTITION, OTHER); + PolicyLookup lookup = EdirPasswordPolicy.lookup(USER, map, MIDDLE, true, LOGIN); + assertEquals(Source.PARTITION_ROOT, lookup.assignment().source()); + assertEquals(POLICY, lookup.assignment().policyDN()); + assertTrue(lookup.conclusive()); + } + + @Test + @DisplayName("a naming context is the root after closer containers are readable and are not") + void namingContextAfterConfirmedContainers() { + PartitionAssessment assessment = EdirPasswordPolicy.assessPartition(List.of( + new AncestorProbe(PARENT, false, true), + new AncestorProbe(MIDDLE, false, true), + new AncestorProbe(PARTITION, true, true))); + assertTrue(assessment.known()); + assertEquals(PARTITION, assessment.partitionRootDn()); + } + + @Test + @DisplayName("walking off the top without a partition root is not 'no policy'") + void noPartitionRootOnThisServer() { + PartitionAssessment assessment = EdirPasswordPolicy.assessPartition(List.of( + new AncestorProbe(PARENT, false, true), + new AncestorProbe(PARTITION, false, true))); + assertFalse(assessment.known()); + + PolicyLookup lookup = EdirPasswordPolicy.lookup( + USER, policies(USER, null, PARENT, null, LOGIN, POLICY), null, false, LOGIN); + assertNull(lookup.assignment()); + assertFalse(lookup.conclusive()); + assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(lookup, true)); + } + + @Test + @DisplayName("a user or parent policy is still conclusive when the partition is unseen") + void userAndContainerDoNotNeedThePartition() { + PolicyLookup onUser = EdirPasswordPolicy.lookup( + USER, policies(USER, POLICY), null, false, LOGIN); + assertTrue(onUser.conclusive()); + assertEquals(Source.USER, onUser.assignment().source()); + + PolicyLookup onParent = EdirPasswordPolicy.lookup( + USER, policies(USER, null, PARENT, POLICY, LOGIN, OTHER), null, false, LOGIN); + assertTrue(onParent.conclusive()); + assertEquals(Source.CONTAINER, onParent.assignment().source()); + assertEquals(POLICY, onParent.assignment().policyDN()); + assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(onParent, true)); + } + } + @Nested @DisplayName("secure connection") class SecureConnection { From 485a953dbc8a49d3d610f1a36a9ce20f7083d719 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 23:11:11 +0000 Subject: [PATCH 3/5] Use the NMAS policy-info extended op; make password changes admin-only The policy lookup read nspmPasswordPolicyDN on the user, container, partition root and Login Policy. That attribute only shows explicit assignments, so inherited policies were missed and missing or unreadable attributes read as "no policy". Ask NMAS instead with Get password policy information (2.16.840.1.113719.1.39.42.100.19), hand-encoded as SEQUENCE { version 1, userDN }. The response is decoded as { version, nmasError, policyDN? }. Errors, unsupported servers and unexpected layouts are UNKNOWN (with the raw bytes) and never default to Legacy NDS. The dialog is now an administrative reset for every directory type: no current-password field or self-service mode. The legacy retry is offered only when NMAS said no policy applies and the failure is not invalid credentials, insufficient access, or an NMAS/NDS rights code. OK, Cancel and window close are disabled while a write is in flight. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KqCmx8MWAPFyiJVE7bLxyz --- .../arborj/service/LDAPService.java | 241 +------- .../arborj/util/EdirPasswordPolicy.java | 539 ++++++------------ .../arborj/view/ChangePasswordDialog.java | 129 ++--- .../service/LDAPServicePasswordErrorTest.java | 7 +- .../arborj/util/EdirPasswordPolicyTest.java | 533 +++++------------ 5 files changed, 385 insertions(+), 1064 deletions(-) diff --git a/src/main/java/com/pointbluetech/arborj/service/LDAPService.java b/src/main/java/com/pointbluetech/arborj/service/LDAPService.java index 541e3aa..d814eff 100644 --- a/src/main/java/com/pointbluetech/arborj/service/LDAPService.java +++ b/src/main/java/com/pointbluetech/arborj/service/LDAPService.java @@ -651,123 +651,38 @@ public void changePasswordLegacyNDS(String dn, String newPassword) throws LDAPEx } /** - * Read-only: which Universal Password policy applies to {@code userDN}. - * Reads {@code nspmPasswordPolicyDN} on the user, the parent container, - * the partition root, and the Login Policy object. The partition root is - * the nearest ancestor that is a naming context or carries a - * {@code replica} attribute. When that root is not visible on this - * server, the lookup is not a conclusive "no policy". Does not modify - * the directory and does not require a writable connection. + * Read-only: which Universal Password policy NMAS applies to + * {@code userDN}, from the NMAS Get password policy information extended + * operation. NMAS does the inheritance (user, container, partition root, + * Login Policy). A failed or unsupported operation is + * {@link EdirPasswordPolicy.Status#UNKNOWN}, never "no policy". */ public EdirPasswordPolicy.PolicyLookup findUniversalPasswordPolicy(String userDN) throws LDAPException { ensureConnected(); - List contexts = fetchNamingContexts(); - - Map policies = new LinkedHashMap<>(); - SearchResultEntry user = readEntryRequired(userDN, EdirPasswordPolicy.POLICY_ATTRIBUTE); - policies.put(userDN, attributeOrNull(user, EdirPasswordPolicy.POLICY_ATTRIBUTE)); - // The user's own assignment wins. Don't walk containers we may not - // be allowed to read before reporting it. - if (policies.get(userDN) != null) { - return EdirPasswordPolicy.lookup(userDN, policies, null, false, null); - } - - String parent = EdirPasswordPolicy.parentDn(userDN); - if (parent != null) { - SearchResultEntry parentEntry = readEntryIfPresent(parent, EdirPasswordPolicy.POLICY_ATTRIBUTE); - policies.put(parent, parentEntry == null - ? null - : attributeOrNull(parentEntry, EdirPasswordPolicy.POLICY_ATTRIBUTE)); - if (policies.get(parent) != null) { - return EdirPasswordPolicy.lookup(userDN, policies, null, false, null); - } - } - - EdirPasswordPolicy.PartitionAssessment partition = - EdirPasswordPolicy.assessPartition(probePartitionAncestors(userDN, contexts)); - if (!partition.known()) { - return new EdirPasswordPolicy.PolicyLookup(null, false); - } - - String root = partition.partitionRootDn(); - if (root != null && (parent == null || !EdirPasswordPolicy.sameDn(root, parent))) { - try { - SearchResultEntry rootEntry = readEntryIfPresent(root, EdirPasswordPolicy.POLICY_ATTRIBUTE); - policies.put(root, rootEntry == null - ? null - : attributeOrNull(rootEntry, EdirPasswordPolicy.POLICY_ATTRIBUTE)); - } catch (LDAPException e) { - if (e.getResultCode() == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { - return new EdirPasswordPolicy.PolicyLookup(null, false); - } - throw e; - } - } - - String loginDN = null; + LDAPConnection conn = connection; + if (conn == null) throw new LDAPException(ResultCode.SERVER_DOWN, "Not connected"); + ExtendedResult result; try { - SearchResultEntry login = findLoginPolicyEntry(contexts); - loginDN = login == null ? null : login.getDN(); - if (login != null) { - policies.put(loginDN, attributeOrNull(login, EdirPasswordPolicy.POLICY_ATTRIBUTE)); - } + result = conn.processExtendedOperation( + EdirPasswordPolicy.GET_POLICY_INFO_REQUEST_OID, + EdirPasswordPolicy.encodeRequest(userDN)); } catch (LDAPException e) { - if (e.getResultCode() == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { - // A policy already read on the partition root still applies. - // Not being able to read the Login Policy only matters when - // nothing closer was assigned. - EdirPasswordPolicy.PolicyLookup soFar = - EdirPasswordPolicy.lookup(userDN, policies, root, true, null); - if (soFar.assignment() != null) return soFar; - return new EdirPasswordPolicy.PolicyLookup(null, false); - } - throw e; + return EdirPasswordPolicy.PolicyLookup.unknown(describeResult( + e.getResultCode(), e.getDiagnosticMessage())); } - return EdirPasswordPolicy.lookup(userDN, policies, root, true, loginDN); + if (result.getResultCode() != ResultCode.SUCCESS) { + return EdirPasswordPolicy.PolicyLookup.unknown(describeResult( + result.getResultCode(), result.getDiagnosticMessage())); + } + return EdirPasswordPolicy.decodeResponse( + result.getValue() == null ? null : result.getValue().getValue()); } - /** - * Ancestors from the parent upward. A naming context is a partition root - * this server holds. Any closer container is checked for the operational - * {@code replica} attribute, which marks a partition root even when - * namingContexts omitted it. An unreadable ancestor ends the walk. - */ - private List probePartitionAncestors( - String userDN, List namingContexts) throws LDAPException { - List probes = new ArrayList<>(); - String current = EdirPasswordPolicy.parentDn(userDN); - while (current != null) { - if (EdirPasswordPolicy.isNamingContext(current, namingContexts)) { - probes.add(new EdirPasswordPolicy.AncestorProbe(current, true, true)); - break; - } - try { - SearchResultEntry entry = connection.getEntry(current, "replica"); - if (entry == null) { - probes.add(new EdirPasswordPolicy.AncestorProbe(current, false, false)); - break; - } - boolean replica = entry.hasAttribute("replica"); - probes.add(new EdirPasswordPolicy.AncestorProbe(current, replica, true)); - if (replica) break; - } catch (LDAPException e) { - ResultCode rc = e.getResultCode(); - if (rc == ResultCode.NO_SUCH_ATTRIBUTE) { - probes.add(new EdirPasswordPolicy.AncestorProbe(current, false, true)); - } else if (rc == ResultCode.NO_SUCH_OBJECT - || rc == ResultCode.REFERRAL - || rc == ResultCode.INSUFFICIENT_ACCESS_RIGHTS - || rc == ResultCode.INVALID_DN_SYNTAX) { - probes.add(new EdirPasswordPolicy.AncestorProbe(current, false, false)); - break; - } else { - throw e; - } - } - current = EdirPasswordPolicy.parentDn(current); - } - return probes; + private static String describeResult(ResultCode rc, String diagnostic) { + String text = rc == null ? "error" : rc.getName() + " (" + rc.intValue() + ")"; + if (diagnostic != null && !diagnostic.isBlank()) text += ": " + diagnostic.trim(); + return text; } /** @@ -789,116 +704,6 @@ public boolean isSecureConnection() { return EdirPasswordPolicy.connectionIsSecure(connected, ssl, useTls, port); } - private SearchResultEntry readEntryRequired(String dn, String attribute) throws LDAPException { - try { - SearchResultEntry entry = connection.getEntry(dn, attribute); - if (entry == null) { - throw new LDAPException(ResultCode.NO_SUCH_OBJECT, "Entry not found: " + dn); - } - return entry; - } catch (LDAPException e) { - if (e.getResultCode() == ResultCode.NO_SUCH_OBJECT) { - throw new LDAPException(ResultCode.NO_SUCH_OBJECT, "Entry not found: " + dn, e); - } - throw e; - } - } - - /** - * Base-level read. Missing entries, referrals, and unparsable DNs are - * "not present". Any other failure (including insufficient rights) is - * raised so a policy we could not see is not reported as absent. - */ - private SearchResultEntry readEntryIfPresent(String dn, String attribute) throws LDAPException { - try { - return connection.getEntry(dn, attribute); - } catch (LDAPException e) { - ResultCode rc = e.getResultCode(); - if (rc == ResultCode.NO_SUCH_OBJECT - || rc == ResultCode.INVALID_DN_SYNTAX - || rc == ResultCode.REFERRAL) { - return null; - } - throw e; - } - } - - private static String attributeOrNull(SearchResultEntry entry, String attribute) { - if (entry == null) return null; - String value = entry.getAttributeValue(attribute); - if (value == null || value.isBlank()) return null; - return value; - } - - /** - * Locate {@code cn=Login Policy} under the Security container. Tries the - * well-known DNs first, then a one-level search. An access failure is - * remembered and rethrown only when no Login Policy entry was found, so - * a missing {@code cn=Security} at the root does not hide the real one - * under a naming context — and a Security container we cannot read is - * not treated as "no policy". - */ - private SearchResultEntry findLoginPolicyEntry(List namingContexts) throws LDAPException { - LDAPException accessDenied = null; - for (String dn : EdirPasswordPolicy.loginPolicyCandidates(namingContexts)) { - try { - SearchResultEntry entry = connection.getEntry(dn, EdirPasswordPolicy.POLICY_ATTRIBUTE); - if (entry != null) return entry; - } catch (LDAPException e) { - if (e.getResultCode() == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { - accessDenied = e; - continue; - } - if (e.getResultCode() == ResultCode.NO_SUCH_OBJECT - || e.getResultCode() == ResultCode.INVALID_DN_SYNTAX - || e.getResultCode() == ResultCode.REFERRAL) { - continue; - } - throw e; - } - } - for (String base : EdirPasswordPolicy.securityContainerCandidates(namingContexts)) { - try { - SearchResultEntry entry = searchLoginPolicy(base); - if (entry != null) return entry; - } catch (LDAPException e) { - if (e.getResultCode() == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { - accessDenied = e; - continue; - } - throw e; - } - } - if (accessDenied != null) throw accessDenied; - return null; - } - - private SearchResultEntry searchLoginPolicy(String base) throws LDAPException { - SearchRequest request = new SearchRequest(base, - com.unboundid.ldap.sdk.SearchScope.ONE, - "(cn=Login Policy)", - EdirPasswordPolicy.POLICY_ATTRIBUTE); - request.setSizeLimit(1); - request.setTimeLimitSeconds(15); - try { - SearchResult result = connection.search(request); - if (result.getSearchEntries().isEmpty()) return null; - return result.getSearchEntries().getFirst(); - } catch (LDAPSearchException e) { - if (e.getResultCode() == ResultCode.SIZE_LIMIT_EXCEEDED - && e.getSearchResult() != null - && !e.getSearchResult().getSearchEntries().isEmpty()) { - return e.getSearchResult().getSearchEntries().getFirst(); - } - if (e.getResultCode() == ResultCode.NO_SUCH_OBJECT - || e.getResultCode() == ResultCode.REFERRAL - || e.getResultCode() == ResultCode.INVALID_DN_SYNTAX) { - return null; - } - throw e; - } - } - // --- Active Directory Helpers --- public int fetchADUserAccountControl(String dn) throws LDAPException { diff --git a/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java index 24bf45f..e8cb625 100644 --- a/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java +++ b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java @@ -1,61 +1,70 @@ package com.pointbluetech.arborj.util; +import com.unboundid.asn1.ASN1Element; +import com.unboundid.asn1.ASN1Exception; +import com.unboundid.asn1.ASN1Integer; +import com.unboundid.asn1.ASN1OctetString; +import com.unboundid.asn1.ASN1Sequence; import com.unboundid.ldap.sdk.Control; -import com.unboundid.ldap.sdk.DN; import com.unboundid.ldap.sdk.LDAPException; -import com.unboundid.ldap.sdk.RDN; import com.unboundid.ldap.sdk.ResultCode; -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; +import java.util.HexFormat; +import java.util.Set; /** * Which Universal Password policy, if any, applies to an eDirectory user. * - *

NMAS looks at {@code nspmPasswordPolicyDN} and keeps the first hit, in - * this order (OpenText eDirectory administration guide, "Assigning Password - * Policies"): - *

    - *
  1. the user
  2. - *
  3. the container that holds the user — only that container, not every - * ancestor. A policy on a container that is not a partition root is - * not inherited by users in subcontainers
  4. - *
  5. the partition root of the partition the user is in (the nearest - * naming context above the user). A policy on a parent partition does - * not apply inside a child partition
  6. - *
  7. the Login Policy object in the Security container, the tree-wide - * default
  8. - *
- * No assignment means the tree is still on legacy NDS password rules. + *

NMAS resolves the effective policy (user, container, partition root, + * Login Policy) itself. {@code nspmPasswordPolicyDN} only shows an explicit + * assignment on the entry that holds it, so reading it cannot tell whether a + * policy is inherited. The NMAS "Get password policy information" extended + * operation asks the server instead: + * + *

+ * requestName  2.16.840.1.113719.1.39.42.100.19
+ * requestValue SEQUENCE { nmasVersion INTEGER (1), objectDN OCTET STRING }
+ *
+ * responseName 2.16.840.1.113719.1.39.42.100.20
+ * responseValue SEQUENCE { nmasVersion INTEGER, nmasError INTEGER,
+ *                          policyDN OCTET STRING OPTIONAL }
+ * 
+ * + * The layout follows the other NMAS LDAP extensions (Get/Set Password). + * {@link #decodeResponse} accepts the elements with or without the outer + * SEQUENCE and reports anything else as {@link Status#UNKNOWN} with the raw + * bytes, so an unexpected layout never reads as "no policy". + * + *

No policy means the tree is still on legacy NDS password rules. * Password Modify (RFC 3062) is an NMAS operation and fails there; a * {@code userPassword} replace over LDAPS is what works. - * - *

This class only decides. {@code LDAPService} does the reads, and those - * reads are not writes. */ public final class EdirPasswordPolicy { - public static final String POLICY_ATTRIBUTE = "nspmPasswordPolicyDN"; + public static final String GET_POLICY_INFO_REQUEST_OID = "2.16.840.1.113719.1.39.42.100.19"; + public static final String GET_POLICY_INFO_RESPONSE_OID = "2.16.840.1.113719.1.39.42.100.20"; + public static final int NMAS_LDAP_EXT_VERSION = 1; /** eDirectory NMAS response controls: {@code 2.16.840.1.113719.1.27.103.*}. */ public static final String NMAS_CONTROL_PREFIX = "2.16.840.1.113719.1.27.103."; - /** LDAP result codes that mean "Password Modify cannot do this". */ - public static final int CONSTRAINT_VIOLATION = 19; - public static final int UNWILLING_TO_PERFORM = 53; + /** + * NMAS / NDS codes that mean the bound account may not set this password. + * A legacy replace would only hit the same wall, so it is not offered. + * -16049 NMAS insufficient rights, -672 NDS no access. + */ + private static final Set RIGHTS_CODES = Set.of(-16049, -672); private EdirPasswordPolicy() { } - public enum Source { - USER, - CONTAINER, - PARTITION_ROOT, - LOGIN_POLICY + public enum Status { + /** NMAS returned a policy DN. */ + POLICY, + /** NMAS answered successfully with no policy DN. */ + NONE, + /** The operation failed, is not supported, or answered in an unexpected form. */ + UNKNOWN } public enum Method { @@ -63,390 +72,188 @@ public enum Method { LEGACY_NDS } - /** - * A policy that applies, where it was found, and the entry that holds - * the {@code nspmPasswordPolicyDN} value. - */ - public record Assignment(String policyDN, Source source, String assignedOnDN) { - public String sourceText() { - return switch (source) { - case USER -> "Assigned to this user."; - case CONTAINER -> "Inherited from container " + assignedOnDN + "."; - case PARTITION_ROOT -> "Inherited from partition root " + assignedOnDN + "."; - case LOGIN_POLICY -> "Inherited from the Login Policy (" + assignedOnDN + ")."; - }; + /** Result of the policy check. {@code detail} explains an UNKNOWN result. */ + public record PolicyLookup(Status status, String policyDN, String detail) { + public static PolicyLookup policy(String dn) { + return new PolicyLookup(Status.POLICY, dn, null); } - } - /** - * Result of a policy check. {@code conclusive} is false when the user's - * partition root could not be seen on this server: a missing assignment - * is then not "no policy". An assignment on the user or the parent - * container is still conclusive, because those are checked before the - * partition root. - */ - public record PolicyLookup(Assignment assignment, boolean conclusive) { - public boolean foundNone() { - return conclusive && assignment == null; + public static PolicyLookup none() { + return new PolicyLookup(Status.NONE, null, null); } - } - - /** - * One container above the user, closest first. - * {@code partitionRoot} means this entry has a {@code replica} attribute - * or is a naming context this server holds. {@code readable} is false - * when the read failed or was referred — the walk stops there, because a - * closer partition root may have been missed. - */ - public record AncestorProbe(String dn, boolean partitionRoot, boolean readable) {} - - /** The user's partition root, or {@code known == false} when it isn't visible here. */ - public record PartitionAssessment(String partitionRootDn, boolean known) {} - /** - * Status line for the change-password dialog. The policy DN, when there - * is one, is {@link Assignment#policyDN()} and is shown separately. - * A failed lookup ({@code failureMessage}) and an unseen partition root - * both refuse to say that no policy applies. - */ - public static String describeStatus(PolicyLookup lookup, String failureMessage) { - if (failureMessage != null && !failureMessage.isBlank()) { - return "Could not check whether a Universal Password policy applies: " - + failureMessage; - } - if (lookup != null && lookup.assignment() != null) { - return "Universal Password policy applies. " + lookup.assignment().sourceText(); + public static PolicyLookup unknown(String detail) { + return new PolicyLookup(Status.UNKNOWN, null, detail); } - if (lookup == null || !lookup.conclusive()) { - return "This server does not show the user's partition root. " - + "namingContexts lists only partitions replicated here, and no " - + "closer replica attribute could be read, so a Universal Password " - + "policy on that partition could not be ruled out. " - + "Password Modify stays selected."; - } - return "No Universal Password policy applies to this user " - + "(none on the user, the parent container, the partition root, " - + "or the Login Policy)."; - } - - /** - * Show the Universal Password / Legacy NDS choice when no policy was - * found. That includes a failed or inconclusive lookup; {@link #defaultMethod} - * keeps Password Modify selected in those cases. - */ - public static boolean offerMethodChoice(PolicyLookup lookup) { - return lookup == null || lookup.assignment() == null; - } - /** - * Legacy NDS only when the lookup succeeded, the partition root was - * visible, and nothing was assigned — and the connection can carry a - * {@code userPassword} replace. A policy, a failed lookup, an unseen - * partition, or a cleartext connection keeps Password Modify. - */ - public static Method defaultMethod(PolicyLookup lookup, boolean secureConnection) { - if (lookup == null || !lookup.foundNone() || !secureConnection) { - return Method.UNIVERSAL_PASSWORD; + public boolean foundNone() { + return status == Status.NONE; } - return Method.LEGACY_NDS; - } - /** - * Offer a {@code userPassword} retry only when the lookup succeeded and - * found no Universal Password policy, and Password Modify failed in the - * way a legacy-only tree fails. A policy, a failed lookup, or an unseen - * partition root just shows the Password Modify error. - */ - public static boolean offerLegacyRetry(PolicyLookup lookup, Throwable error) { - return lookup != null && lookup.foundNone() && passwordModifySuggestsLegacy(error); - } - - /** - * True when a failed Password Modify is the case the legacy replace - * can cover: result 53 (unwillingToPerform), 19 (constraintViolation), - * an NMAS response control, an error that says NMAS, or a matched DN - * carrying an NMAS code. Other failures (bad current password, no - * rights) are not retried this way. - */ - public static boolean passwordModifySuggestsLegacy(Throwable error) { - Throwable current = error; - for (int depth = 0; current != null && depth < 5; depth++) { - if (current instanceof LDAPException ldap) { - ResultCode rc = ldap.getResultCode(); - int code = rc == null ? -1 : rc.intValue(); - if (code == CONSTRAINT_VIOLATION || code == UNWILLING_TO_PERFORM) return true; - if (hasNmasControl(ldap.getResponseControls())) return true; - if (mentionsNmas(ldap.getDiagnosticMessage())) return true; - // eDirectory sometimes puts the NMAS code in the matched DN - // ("-216") and the formatted dialog text no longer says NMAS. - if (matchedDnIsNmas(ldap.getMatchedDN())) return true; - } - if (mentionsNmas(current.getMessage())) return true; - Throwable next = current.getCause(); - current = next == current ? null : next; + public boolean hasPolicy() { + return status == Status.POLICY; } - return false; } - /** - * A connected socket is secure only when it is actually TLS. Config - * flags apply before a socket exists. - */ - public static boolean connectionIsSecure(boolean connected, boolean sslSessionActive, - boolean useTls, int port) { - if (connected) return sslSessionActive; - return sslSessionActive || useTls || port == 636; + /** BER request value for the Get password policy information operation. */ + public static ASN1OctetString encodeRequest(String userDN) { + ASN1Sequence seq = new ASN1Sequence( + new ASN1Integer(NMAS_LDAP_EXT_VERSION), + new ASN1OctetString(userDN == null ? "" : userDN)); + return new ASN1OctetString(seq.encode()); } /** - * DNs to read, in NMAS order, excluding the Login Policy (that object - * is located separately). The user is always first. The partition root - * is omitted when it is the parent already listed. + * Decode the response value. A missing value, a non-zero NMAS error, or + * a layout that is not {version, error[, policyDN]} is UNKNOWN. */ - public static List policySearchDns(String userDN, Collection namingContexts) { - if (userDN == null || userDN.isBlank()) return List.of(); - List dns = new ArrayList<>(); - dns.add(userDN); - String parent = parentDn(userDN); - if (parent != null) dns.add(parent); - String partition = nearestPartitionRoot(userDN, namingContexts); - if (partition != null && (parent == null || !sameDn(partition, parent))) { - dns.add(partition); + public static PolicyLookup decodeResponse(byte[] value) { + if (value == null || value.length == 0) { + return PolicyLookup.unknown("the server returned no response value"); } - return List.copyOf(dns); - } - - /** - * Pick the policy from values already read, treating the nearest - * naming context as the partition root. When the user is not under any - * naming context, the partition root is unknown: a user or parent - * assignment is still returned, and anything else is not "no policy". - * {@link #lookup} is the same decision with an explicit partition root - * from {@link #assessPartition}. - */ - public static Assignment resolve(String userDN, Map policyByDn, - Collection namingContexts, String loginPolicyDN) { - if (userDN == null || userDN.isBlank()) return null; - String root = nearestPartitionRoot(userDN, namingContexts); - return lookup(userDN, policyByDn, root, root != null, loginPolicyDN).assignment(); - } - - /** - * NMAS order. {@code partitionRootKnown} is false when this server could - * not see the user's partition root. The user and the parent container - * are still decisive. The partition root and the Login Policy are not - * consulted then, and the result is not a conclusive "none". - */ - public static PolicyLookup lookup(String userDN, Map policyByDn, - String partitionRootDn, boolean partitionRootKnown, - String loginPolicyDN) { - if (userDN == null || userDN.isBlank()) return new PolicyLookup(null, false); - - Hit user = find(policyByDn, userDN); - if (user != null) { - return new PolicyLookup(new Assignment(user.policyDN, Source.USER, user.dn), true); + ASN1Element[] elements; + try { + byte[] wrapped = value[0] == 0x30 + ? value + : new ASN1Element((byte) 0x30, value).encode(); + elements = ASN1Sequence.decodeAsSequence(wrapped).elements(); + } catch (ASN1Exception e) { + return PolicyLookup.unknown("unreadable response " + hex(value)); } - String parent = parentDn(userDN); - if (parent != null) { - Hit container = find(policyByDn, parent); - if (container != null) { - return new PolicyLookup( - new Assignment(container.policyDN, Source.CONTAINER, container.dn), true); + Integer version = null; + Integer error = null; + String policyDN = null; + try { + for (ASN1Element element : elements) { + if (element.getType() == ASN1Constants.INTEGER) { + int n = ASN1Integer.decodeAsInteger(element).intValue(); + if (version == null) version = n; + else if (error == null) error = n; + } else if (element.getType() == ASN1Constants.OCTET_STRING && policyDN == null) { + policyDN = ASN1OctetString.decodeAsOctetString(element).stringValue(); + } } + } catch (ASN1Exception e) { + return PolicyLookup.unknown("unreadable response " + hex(value)); } - if (!partitionRootKnown) return new PolicyLookup(null, false); - - if (partitionRootDn != null && (parent == null || !sameDn(partitionRootDn, parent))) { - Hit part = find(policyByDn, partitionRootDn); - if (part != null) { - return new PolicyLookup( - new Assignment(part.policyDN, Source.PARTITION_ROOT, part.dn), true); - } + if (version == null || error == null) { + return PolicyLookup.unknown("unexpected response " + hex(value)); } - - if (loginPolicyDN != null && !loginPolicyDN.isBlank()) { - Hit login = find(policyByDn, loginPolicyDN); - if (login != null) { - return new PolicyLookup( - new Assignment(login.policyDN, Source.LOGIN_POLICY, login.dn), true); - } + if (error != 0) { + return PolicyLookup.unknown("NMAS error " + error); + } + if (policyDN == null || policyDN.isBlank()) { + return PolicyLookup.none(); } - return new PolicyLookup(null, true); + return PolicyLookup.policy(policyDN.trim()); } - /** - * Walk ancestors from the parent upward. The first readable partition - * root wins. An unreadable ancestor stops the walk: a naming context - * further up is not the user's partition, because a closer one may have - * been missed. Finishing the walk without a partition root means this - * server does not show it. - */ - public static PartitionAssessment assessPartition(List fromParentUpward) { - if (fromParentUpward == null) return new PartitionAssessment(null, false); - for (AncestorProbe probe : fromParentUpward) { - if (probe == null || !probe.readable()) return new PartitionAssessment(null, false); - if (probe.partitionRoot()) return new PartitionAssessment(probe.dn(), true); + /** Status line for the change-password dialog. The policy DN is shown separately. */ + public static String describeStatus(PolicyLookup lookup, String failureMessage) { + if (failureMessage != null && !failureMessage.isBlank()) { + return "Could not check whether a Universal Password policy applies: " + + failureMessage + ". Password Modify stays selected."; + } + if (lookup == null) { + return "Could not check whether a Universal Password policy applies. " + + "Password Modify stays selected."; } - return new PartitionAssessment(null, false); + return switch (lookup.status()) { + case POLICY -> "Universal Password policy applies:"; + case NONE -> "No Universal Password policy applies to this user."; + case UNKNOWN -> "Could not check whether a Universal Password policy applies: " + + lookup.detail() + ". Password Modify stays selected."; + }; } - /** True when {@code dn} is one of the naming contexts, ignoring case and spacing. */ - public static boolean isNamingContext(String dn, Collection namingContexts) { - if (dn == null || dn.isBlank() || namingContexts == null) return false; - for (String ctx : namingContexts) { - if (ctx == null || ctx.isBlank()) continue; - if (sameDn(dn, ctx)) return true; - } - return false; + /** Offer the Universal Password / Legacy NDS choice unless a policy was found. */ + public static boolean offerMethodChoice(PolicyLookup lookup) { + return lookup == null || !lookup.hasPolicy(); } - /** Parent DN, or null for a top-level or empty DN. */ - public static String parentDn(String dn) { - if (dn == null || dn.isBlank()) return null; - try { - DN parsed = new DN(dn); - DN parent = parsed.getParent(); - if (parent == null || parent.isNullDN()) return null; - return parent.toString(); - } catch (LDAPException e) { - return null; + /** Legacy NDS only when NMAS said there is no policy and the connection is LDAPS. */ + public static Method defaultMethod(PolicyLookup lookup, boolean secureConnection) { + if (lookup == null || !lookup.foundNone() || !secureConnection) { + return Method.UNIVERSAL_PASSWORD; } + return Method.LEGACY_NDS; } /** - * Longest naming context the user is strictly inside. eDirectory's - * root DSE lists only partitions replicated on this server, so a hit - * here can still be a parent partition with a closer partition root - * in between. {@link #assessPartition} is what decides that. The user - * DN itself is not a partition root for this purpose. + * Offer a {@code userPassword} retry after a Password Modify failure only + * when NMAS said no policy applies and the failure is not a rights + * problem. Invalid credentials and insufficient access are shown as-is. */ - public static String nearestPartitionRoot(String userDN, Collection namingContexts) { - if (userDN == null || userDN.isBlank() || namingContexts == null) return null; - DN user; - try { - user = new DN(userDN); - } catch (LDAPException e) { - return null; - } - String best = null; - int bestRdns = -1; - for (String ctx : namingContexts) { - if (ctx == null || ctx.isBlank()) continue; - try { - DN ctxDn = new DN(ctx); - if (ctxDn.isNullDN()) continue; - if (user.isDescendantOf(ctxDn, false) && ctxDn.getRDNs().length > bestRdns) { - bestRdns = ctxDn.getRDNs().length; - best = ctx; - } - } catch (LDAPException ignored) { - // A naming context that is not a DN cannot be a partition root. - } - } - return best; - } - - /** {@code cn=Security} and {@code cn=Security} under each naming context. */ - public static List securityContainerCandidates(Collection namingContexts) { - LinkedHashSet out = new LinkedHashSet<>(); - out.add("cn=Security"); - if (namingContexts != null) { - for (String ctx : namingContexts) { - if (ctx == null || ctx.isBlank()) continue; - if (isSecurityContainer(ctx)) out.add(ctx); - else out.add("cn=Security," + ctx); - } + public static boolean offerLegacyRetry(PolicyLookup lookup, Throwable error) { + if (lookup == null || !lookup.foundNone()) return false; + LDAPException ldap = null; + for (Throwable t = error; t != null && ldap == null; t = t.getCause() == t ? null : t.getCause()) { + if (t instanceof LDAPException e) ldap = e; } - return List.copyOf(out); - } - - /** {@code cn=Login Policy} directly under each security-container candidate. */ - public static List loginPolicyCandidates(Collection namingContexts) { - LinkedHashSet out = new LinkedHashSet<>(); - for (String security : securityContainerCandidates(namingContexts)) { - out.add("cn=Login Policy," + security); + if (ldap == null) return false; + ResultCode rc = ldap.getResultCode(); + if (rc == ResultCode.INVALID_CREDENTIALS || rc == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { + return false; } - return List.copyOf(out); - } - - public static boolean sameDn(String a, String b) { - if (a == null || b == null) return false; - return normalize(a).equals(normalize(b)); + Integer code = nmasCode(error); + return code == null || !RIGHTS_CODES.contains(code); } - public static String normalize(String dn) { - if (dn == null) return ""; - try { - return new DN(dn).toNormalizedString(); - } catch (LDAPException e) { - return dn.trim().toLowerCase(Locale.ROOT); + /** + * NMAS error code from the first exception in the cause chain that has + * one: an NMAS response control (first 4 bytes, big-endian) or a matched + * DN that is just the number. + */ + public static Integer nmasCode(Throwable error) { + for (Throwable t = error; t != null; t = t.getCause() == t ? null : t.getCause()) { + if (!(t instanceof LDAPException ldap)) continue; + Integer code = nmasCode(ldap.getResponseControls(), ldap.getMatchedDN()); + if (code != null) return code; } + return null; } - private static boolean isSecurityContainer(String dn) { - try { - DN parsed = new DN(dn); - if (parsed.isNullDN()) return false; - RDN rdn = parsed.getRDN(); - String[] names = rdn.getAttributeNames(); - String[] values = rdn.getAttributeValues(); - for (int i = 0; i < names.length && i < values.length; i++) { - if ("cn".equalsIgnoreCase(names[i]) && "security".equalsIgnoreCase(values[i])) { - return true; + public static Integer nmasCode(Control[] controls, String matchedDN) { + if (controls != null) { + for (Control c : controls) { + if (c == null || c.getOID() == null || !c.getOID().startsWith(NMAS_CONTROL_PREFIX)) continue; + ASN1OctetString val = c.getValue(); + byte[] bytes = val == null ? null : val.getValue(); + if (bytes != null && bytes.length >= 4) { + return (bytes[0] << 24) | ((bytes[1] & 0xFF) << 16) + | ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF); } } - return false; - } catch (LDAPException e) { - return false; } - } - - private record Hit(String dn, String policyDN) {} - - private static Hit find(Map policyByDn, String dn) { - if (policyByDn == null || dn == null) return null; - if (policyByDn.containsKey(dn)) { - String value = blankToNull(policyByDn.get(dn)); - return value == null ? null : new Hit(dn, value); - } - String want = normalize(dn); - for (var entry : policyByDn.entrySet()) { - if (want.equals(normalize(entry.getKey()))) { - String value = blankToNull(entry.getValue()); - return value == null ? null : new Hit(entry.getKey(), value); + if (matchedDN != null && matchedDN.trim().matches("-?\\d+")) { + try { + return Integer.parseInt(matchedDN.trim()); + } catch (NumberFormatException ignored) { + return null; } } return null; } - private static String blankToNull(String value) { - if (value == null) return null; - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - - private static boolean mentionsNmas(String text) { - return text != null && text.toLowerCase(Locale.ROOT).contains("nmas"); + /** + * A connected socket is secure only when it is actually TLS. Config + * flags apply before a socket exists. + */ + public static boolean connectionIsSecure(boolean connected, boolean sslSessionActive, + boolean useTls, int port) { + if (connected) return sslSessionActive; + return sslSessionActive || useTls || port == 636; } - private static boolean matchedDnIsNmas(String matchedDN) { - if (matchedDN == null || matchedDN.isBlank()) return false; - String trimmed = matchedDN.trim(); - if (trimmed.matches("-\\d+")) return true; - if (trimmed.startsWith("NDS error:")) return true; - return mentionsNmas(trimmed); + private static String hex(byte[] value) { + return "(" + HexFormat.of().formatHex(value) + ")"; } - private static boolean hasNmasControl(Control[] controls) { - if (controls == null) return false; - for (Control control : controls) { - if (control != null && control.getOID() != null - && control.getOID().startsWith(NMAS_CONTROL_PREFIX)) { - return true; - } - } - return false; + /** Universal-class tags used in the response. */ + private static final class ASN1Constants { + static final byte INTEGER = 0x02; + static final byte OCTET_STRING = 0x04; } } diff --git a/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java b/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java index 5d54e7e..52f8fc6 100644 --- a/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java +++ b/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java @@ -4,7 +4,6 @@ import com.pointbluetech.arborj.model.DirectoryType; import com.pointbluetech.arborj.util.EdirPasswordPolicy; import javafx.application.Platform; -import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.*; @@ -12,20 +11,20 @@ import javafx.scene.text.Font; /** - * Password change dialog that adapts its UI and behavior - * based on the connected directory type. + * Administrative password reset. The new password is set with the bound + * account's rights; the user's current password is never asked for. * - * - Active Directory: new password only, plus UAC flag checkboxes. - * - eDirectory: shows the Universal Password policy that applies (read from - * {@code nspmPasswordPolicyDN} on the user, the parent container, the - * partition root, or the Login Policy). When none applies, the user - * chooses Universal Password (RFC 3062) or Legacy NDS (replace - * {@code userPassword} over LDAPS). A Password Modify failure offers - * that legacy replace only when the policy lookup succeeded and found - * no policy. The writes run off the JavaFX thread. - * - Generic / OpenLDAP: RFC 3062 Password Modify. Supports self-service - * (current + new password) and administrative reset (new password only — - * uses the connected admin's rights). + * - Active Directory: replace unicodePwd, plus UAC flag checkboxes. + * - eDirectory: asks NMAS which Universal Password policy applies (Get + * password policy information extended operation). When NMAS says none + * applies, the user chooses Universal Password (RFC 3062) or Legacy NDS + * (replace {@code userPassword} over LDAPS), with Legacy preselected. A + * Password Modify failure offers that legacy replace only when NMAS said + * no policy applies and the failure is not a rights error. + * - Generic / OpenLDAP: RFC 3062 Password Modify without the old password. + * + * The writes run off the JavaFX thread. OK and Cancel are disabled while + * one is in flight. */ public class ChangePasswordDialog extends Dialog { @@ -34,15 +33,9 @@ public class ChangePasswordDialog extends Dialog { private final MainController controller; private final String targetDN; - private final PasswordField currentPasswordField = new PasswordField(); private final PasswordField newPasswordField = new PasswordField(); private final PasswordField confirmPasswordField = new PasswordField(); - // Non-AD: admin-reset toggle (RFC 3062 allows omitting the old password - // when the bound user has reset rights on the target). - private final CheckBox adminResetCheckbox = - new CheckBox("Administrative reset (don't require current password)"); - // eDirectory: policy status and, when no Universal Password policy // applies, which write to send. private final Label policyStatusLabel = @@ -57,11 +50,13 @@ public class ChangePasswordDialog extends Dialog { private final Label legacySecureHint = new Label( "Legacy NDS replaces userPassword and needs an LDAPS connection."); private final Label legacyNoVerifyHint = new Label( - "Legacy NDS does not verify the current password. It replaces userPassword using the bound account's rights."); + "Legacy NDS replaces userPassword using the bound account's rights. It does not go through Universal Password."); /** Set on the JavaFX thread when the policy read finishes. Null if it failed. */ private EdirPasswordPolicy.PolicyLookup policyLookup; private Button okButton; + private Button cancelButton; + private boolean busy; // AD-specific controls private final CheckBox mustChangeCheckbox = new CheckBox("User must change password at next logon"); @@ -89,7 +84,6 @@ public ChangePasswordDialog(MainController controller, String targetDN, DirectoryType dirType = controller.directoryTypeProperty().get(); Font mono = Font.font("monospaced", 12); - currentPasswordField.setFont(mono); newPasswordField.setFont(mono); confirmPasswordField.setFont(mono); @@ -105,6 +99,12 @@ public ChangePasswordDialog(MainController controller, String targetDN, getDialogPane().getButtonTypes().addAll(okType, cancelType); okButton = (Button) getDialogPane().lookupButton(okType); + cancelButton = (Button) getDialogPane().lookupButton(cancelType); + // The window's close box and Esc also close the dialog. Keep it open + // while a write is in flight. + setOnCloseRequest(e -> { + if (busy) e.consume(); + }); Button okBtn = okButton; // addEventFilter (not setOnAction): JavaFX runs filters before the // Dialog's internal action that closes the pane, so consuming here @@ -182,34 +182,6 @@ private VBox buildContent(DirectoryType dirType) { form.add(methodBox, 0, row++, 2, 1); } - // Current password -- shown for non-AD directories, and only when - // an administrative reset is NOT being performed. Legacy NDS is a - // userPassword replace and does not send the current password. - boolean showOldPassword = dirType != DirectoryType.ACTIVE_DIRECTORY; - Label currentLabel = new Label("Current Password:"); - if (showOldPassword) { - form.add(currentLabel, 0, row); - form.add(currentPasswordField, 1, row++); - - // Empty placeholder cell + checkbox in the value column - form.add(adminResetCheckbox, 1, row++); - - var showCurrent = Bindings.and( - adminResetCheckbox.selectedProperty().not(), - legacyRadio.selectedProperty().not()); - currentLabel.visibleProperty().bind(showCurrent); - currentLabel.managedProperty().bind(currentLabel.visibleProperty()); - currentPasswordField.visibleProperty().bind(showCurrent); - currentPasswordField.managedProperty().bind(currentPasswordField.visibleProperty()); - adminResetCheckbox.visibleProperty().bind(legacyRadio.selectedProperty().not()); - adminResetCheckbox.managedProperty().bind(adminResetCheckbox.visibleProperty()); - // Clear the field when switching to admin-reset so a stale value - // can't accidentally be sent. - adminResetCheckbox.selectedProperty().addListener((obs, was, now) -> { - if (now) currentPasswordField.clear(); - }); - } - // New password form.add(new Label("New Password:"), 0, row); form.add(newPasswordField, 1, row++); @@ -289,37 +261,23 @@ private void doChangePassword(DirectoryType dirType) { confirmPasswordField.requestFocus(); return; } - // Self-service (non-admin) Password Modify requires the current - // password. A legacy NDS replace does not send one. - if (dirType != DirectoryType.ACTIVE_DIRECTORY - && !legacyRadio.isSelected() - && !adminResetCheckbox.isSelected() - && currentPasswordField.getText().isEmpty()) { - showError("Current password is required.\n\n" - + "Check “Administrative reset” if you are doing an " - + "admin password reset using your bound credentials."); - currentPasswordField.requestFocus(); - return; - } - // Read every control here. The write itself runs off the JavaFX thread. boolean legacy = legacyRadio.isSelected(); - String oldPassword = effectiveOldPassword(); boolean mustChange = mustChangeCheckbox.isSelected(); boolean neverExpires = neverExpiresCheckbox.isSelected(); boolean applyUac = uacFetched; int uac = currentUAC; EdirPasswordPolicy.PolicyLookup lookup = policyLookup; - okButton.setDisable(true); + setBusy(true); Thread.ofVirtual().start(() -> { try { - writePassword(dirType, newPassword, oldPassword, legacy, + writePassword(dirType, newPassword, legacy, mustChange, neverExpires, applyUac, uac); Platform.runLater(this::close); } catch (Exception ex) { Platform.runLater(() -> { - okButton.setDisable(false); + setBusy(false); // formatPasswordError's text is the exception message. // A legacy retry is only for a conclusive "no policy". String msg = messageOf(ex); @@ -335,8 +293,7 @@ private void doChangePassword(DirectoryType dirType) { }); } - private void writePassword(DirectoryType dirType, String newPassword, String oldPassword, - boolean legacy, boolean mustChange, boolean neverExpires, + private void writePassword(DirectoryType dirType, String newPassword, boolean legacy, boolean mustChange, boolean neverExpires, boolean applyUac, int uac) throws Exception { switch (dirType) { case ACTIVE_DIRECTORY -> { @@ -353,17 +310,17 @@ private void writePassword(DirectoryType dirType, String newPassword, String old if (legacy) { controller.getLdapService().changePasswordLegacyNDS(targetDN, newPassword); } else { - controller.getLdapService().changePasswordEDir(targetDN, oldPassword, newPassword); + controller.getLdapService().changePasswordEDir(targetDN, null, newPassword); } } default -> controller.getLdapService().changePasswordRFC3062( - targetDN, oldPassword, newPassword); + targetDN, null, newPassword); } } /** - * Password Modify failed and the lookup had already found no Universal - * Password policy. Ask before replacing {@code userPassword}. The + * Password Modify failed and NMAS had already said no Universal + * Password policy applies. Ask before replacing {@code userPassword}. The * replace runs off the JavaFX thread. A cleartext connection cannot do * it, so the Password Modify error stands. */ @@ -380,22 +337,21 @@ private void offerLegacyRetry(String newPassword, String message) { alert.setContentText(message + "\n\nNo Universal Password policy applies. Retry with Legacy NDS " + "(replace userPassword over this LDAPS connection)? " - + "That does not verify the current password and does not go " - + "through Universal Password."); + + "That does not go through Universal Password."); alert.getButtonTypes().setAll(ButtonType.YES, ButtonType.NO); var window = ownerWindow(); if (window != null) alert.initOwner(window); var choice = alert.showAndWait(); if (choice.isEmpty() || choice.get() != ButtonType.YES) return; - okButton.setDisable(true); + setBusy(true); Thread.ofVirtual().start(() -> { try { controller.getLdapService().changePasswordLegacyNDS(targetDN, newPassword); Platform.runLater(this::close); } catch (Exception retryEx) { Platform.runLater(() -> { - okButton.setDisable(false); + setBusy(false); showError(messageOf(retryEx)); }); } @@ -427,8 +383,8 @@ private void loadEdirPolicy(Button okBtn) { policyLookup = error == null ? found : null; EdirPasswordPolicy.PolicyLookup forUi = policyLookup; policyStatusLabel.setText(EdirPasswordPolicy.describeStatus(forUi, error)); - boolean hasPolicy = forUi != null && forUi.assignment() != null; - policyDnLabel.setText(hasPolicy ? forUi.assignment().policyDN() : ""); + boolean hasPolicy = forUi != null && forUi.hasPolicy(); + policyDnLabel.setText(hasPolicy ? forUi.policyDN() : ""); policyDnLabel.setVisible(hasPolicy); policyDnLabel.setManaged(hasPolicy); @@ -457,16 +413,11 @@ private static String messageOf(Exception ex) { return msg; } - /** - * Returns the old-password value to send with RFC 3062, or null when the - * user has selected an administrative reset. UnboundID's - * PasswordModifyExtendedRequest accepts null for the old password and the - * server uses the bound user's rights to authorize the change. - */ - private String effectiveOldPassword() { - if (adminResetCheckbox.isSelected()) return null; - String typed = currentPasswordField.getText(); - return typed.isEmpty() ? null : typed; + /** Disable OK and Cancel while a write is in flight, so the dialog can't close under it. */ + private void setBusy(boolean busy) { + this.busy = busy; + okButton.setDisable(busy); + cancelButton.setDisable(busy); } private void showError(String message) { diff --git a/src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java b/src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java index b9eb1a4..9508ba2 100644 --- a/src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java +++ b/src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java @@ -29,13 +29,12 @@ void matchedDnCode() { Password change failed: Password too short [result: other (80)]""", msg); - // The dialog sees the formatted text (no longer says "NMAS") wrapped - // around the server exception, which still carries the matched DN. + // The dialog sees the formatted text wrapped around the server + // exception, which still carries the matched DN. LDAPException cause = new LDAPException(ResultCode.OTHER, "NMAS Change Password extension failed", "-216", null); - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( + assertEquals(-216, EdirPasswordPolicy.nmasCode( new LDAPException(ResultCode.OTHER, msg, cause))); - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy(cause)); } @Test diff --git a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java index 95a27ab..d3ef471 100644 --- a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java +++ b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java @@ -1,12 +1,12 @@ package com.pointbluetech.arborj.util; -import com.pointbluetech.arborj.util.EdirPasswordPolicy.AncestorProbe; -import com.pointbluetech.arborj.util.EdirPasswordPolicy.Assignment; import com.pointbluetech.arborj.util.EdirPasswordPolicy.Method; -import com.pointbluetech.arborj.util.EdirPasswordPolicy.PartitionAssessment; import com.pointbluetech.arborj.util.EdirPasswordPolicy.PolicyLookup; -import com.pointbluetech.arborj.util.EdirPasswordPolicy.Source; +import com.pointbluetech.arborj.util.EdirPasswordPolicy.Status; +import com.unboundid.asn1.ASN1Element; +import com.unboundid.asn1.ASN1Integer; import com.unboundid.asn1.ASN1OctetString; +import com.unboundid.asn1.ASN1Sequence; import com.unboundid.ldap.sdk.Control; import com.unboundid.ldap.sdk.LDAPException; import com.unboundid.ldap.sdk.ResultCode; @@ -14,9 +14,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.io.ByteArrayOutputStream; +import java.util.HexFormat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -25,454 +24,214 @@ class EdirPasswordPolicyTest { - private static final String USER = "cn=alice,ou=eng,ou=users,o=acme"; - private static final String PARENT = "ou=eng,ou=users,o=acme"; - private static final String MIDDLE = "ou=users,o=acme"; - private static final String PARTITION = "o=acme"; + private static final String USER = "cn=alice,ou=eng,o=acme"; private static final String POLICY = "cn=Sample Password Policy,cn=Password Policies,cn=Security"; - private static final String OTHER = "cn=Other Policy,cn=Password Policies,cn=Security"; - private static final String LOGIN = "cn=Login Policy,cn=Security"; - private static Map policies(String... kv) { - Map map = new LinkedHashMap<>(); - for (int i = 0; i < kv.length; i += 2) { - map.put(kv[i], kv[i + 1]); - } - return map; + private static byte[] seq(ASN1Element... elements) { + return new ASN1Sequence(elements).encode(); } - @Nested - @DisplayName("resolve") - class Resolve { - - @Test - @DisplayName("a policy on the user wins over the container, partition, and Login Policy") - void userAssignmentWins() { - Map map = policies( - USER, POLICY, - PARENT, OTHER, - PARTITION, OTHER, - LOGIN, OTHER); - - Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN); - - assertEquals(POLICY, found.policyDN()); - assertEquals(Source.USER, found.source()); - assertEquals(USER, found.assignedOnDN()); - } - - @Test - @DisplayName("the parent container wins over the partition root and the Login Policy") - void containerBeatsPartitionAndLoginPolicy() { - Map map = policies( - USER, null, - PARENT, POLICY, - PARTITION, OTHER, - LOGIN, OTHER); - - Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN); - - assertEquals(Source.CONTAINER, found.source()); - assertEquals(POLICY, found.policyDN()); - assertTrue(found.sourceText().contains(PARENT)); - } - - @Test - @DisplayName("a policy on a container that is neither the parent nor the partition root is ignored") - void intermediateContainerDoesNotApply() { - // ou=users is between the parent and the partition root, and it is - // not itself a partition. NMAS does not inherit that assignment. - Map map = policies( - USER, null, - PARENT, null, - MIDDLE, POLICY, - PARTITION, null, - LOGIN, null); - - assertNull(EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN)); - } - - @Test - @DisplayName("the partition root applies when the parent container has no policy") - void partitionRootWhenParentHasNone() { - Map map = policies( - USER, "", - PARENT, " ", - PARTITION, POLICY, - LOGIN, OTHER); - - Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN); - - assertEquals(Source.PARTITION_ROOT, found.source()); - assertEquals(POLICY, found.policyDN()); - assertEquals(PARTITION, found.assignedOnDN()); - } - - @Test - @DisplayName("a child partition does not inherit the parent partition's policy") - void childPartitionDoesNotUseParentPartition() { - String user = "cn=alice,ou=secret,o=acme"; - String childPartition = "ou=secret,o=acme"; - Map map = policies( - user, null, - "ou=secret,o=acme", null, - PARTITION, POLICY, - LOGIN, null); - - assertNull(EdirPasswordPolicy.resolve( - user, map, List.of(PARTITION, childPartition), LOGIN)); - } - - @Test - @DisplayName("the Login Policy is the default when nothing closer is assigned") - void loginPolicyIsTheDefault() { - Map map = policies( - USER, null, - PARENT, null, - PARTITION, null, - LOGIN, POLICY); - - Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN); - - assertEquals(Source.LOGIN_POLICY, found.source()); - assertEquals(POLICY, found.policyDN()); - assertEquals(LOGIN, found.assignedOnDN()); - } + private static byte[] concat(ASN1Element... elements) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (ASN1Element e : elements) out.writeBytes(e.encode()); + return out.toByteArray(); + } - @Test - @DisplayName("no assignment anywhere means legacy NDS rules") - void nothingAssigned() { - Map map = policies(USER, null, PARENT, null, PARTITION, null, LOGIN, null); - assertNull(EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), LOGIN)); - assertNull(EdirPasswordPolicy.resolve(USER, map, List.of(PARTITION), null)); - } + @Nested + @DisplayName("request encoding") + class Request { @Test - @DisplayName("DN spelling differences still match") - void dnMatchIsCaseInsensitive() { - Map map = new LinkedHashMap<>(); - map.put("CN=Alice,OU=Eng,OU=Users,O=ACME", POLICY); + @DisplayName("SEQUENCE { INTEGER 1, OCTET STRING userDN }") + void layout() throws Exception { + byte[] value = EdirPasswordPolicy.encodeRequest("cn=a").getValue(); + assertEquals("3009020101040463 6e3d61".replace(" ", ""), + HexFormat.of().formatHex(value)); - Assignment found = EdirPasswordPolicy.resolve(USER, map, List.of("O=Acme"), null); - - assertEquals(Source.USER, found.source()); - assertEquals("CN=Alice,OU=Eng,OU=Users,O=ACME", found.assignedOnDN()); + ASN1Element[] elements = ASN1Sequence.decodeAsSequence(value).elements(); + assertEquals(2, elements.length); + assertEquals(1, ASN1Integer.decodeAsInteger(elements[0]).intValue()); + assertEquals(USER, ASN1OctetString.decodeAsOctetString( + ASN1Sequence.decodeAsSequence(EdirPasswordPolicy.encodeRequest(USER).getValue()) + .elements()[1]).stringValue()); } @Test - @DisplayName("a blank user DN does not resolve") - void blankUser() { - assertNull(EdirPasswordPolicy.resolve(" ", Map.of(USER, POLICY), List.of(PARTITION), LOGIN)); - assertNull(EdirPasswordPolicy.resolve(null, Map.of(), List.of(), null)); + @DisplayName("OIDs are the NMAS Get password policy information pair") + void oids() { + assertEquals("2.16.840.1.113719.1.39.42.100.19", EdirPasswordPolicy.GET_POLICY_INFO_REQUEST_OID); + assertEquals("2.16.840.1.113719.1.39.42.100.20", EdirPasswordPolicy.GET_POLICY_INFO_RESPONSE_OID); } } @Nested - @DisplayName("which entries are consulted") - class SearchList { + @DisplayName("response decoding") + class Response { @Test - @DisplayName("user directly in a partition is not read twice") - void parentThatIsThePartitionIsListedOnce() { - String user = "cn=alice,o=acme"; - assertEquals(List.of(user, "o=acme"), - EdirPasswordPolicy.policySearchDns(user, List.of("o=acme", ""))); + @DisplayName("version, error 0, policy DN is a policy") + void policy() { + PolicyLookup lookup = EdirPasswordPolicy.decodeResponse(seq( + new ASN1Integer(1), new ASN1Integer(0), new ASN1OctetString(POLICY))); + assertEquals(Status.POLICY, lookup.status()); + assertEquals(POLICY, lookup.policyDN()); } @Test - @DisplayName("parent and a different partition root are both listed; intermediate containers are not") - void parentAndPartitionOnly() { - List dns = EdirPasswordPolicy.policySearchDns(USER, List.of(PARTITION, "cn=Security")); - assertEquals(List.of(USER, PARENT, PARTITION), dns); - assertFalse(dns.contains(MIDDLE)); + @DisplayName("the elements without the outer SEQUENCE decode the same") + void unwrapped() { + PolicyLookup lookup = EdirPasswordPolicy.decodeResponse(concat( + new ASN1Integer(1), new ASN1Integer(0), new ASN1OctetString(POLICY))); + assertEquals(Status.POLICY, lookup.status()); + assertEquals(POLICY, lookup.policyDN()); } @Test - @DisplayName("escaped comma in the RDN stays in the user DN") - void escapedCommaParent() { - String user = "cn=a\\,b,ou=c,o=d"; - assertEquals("ou=c,o=d", EdirPasswordPolicy.parentDn(user)); - assertNull(EdirPasswordPolicy.parentDn("cn=only")); - assertNull(EdirPasswordPolicy.parentDn(null)); + @DisplayName("error 0 with no DN, or an empty DN, is no policy") + void none() { + assertEquals(Status.NONE, EdirPasswordPolicy.decodeResponse(seq( + new ASN1Integer(1), new ASN1Integer(0))).status()); + assertEquals(Status.NONE, EdirPasswordPolicy.decodeResponse(seq( + new ASN1Integer(1), new ASN1Integer(0), new ASN1OctetString(""))).status()); } @Test - @DisplayName("the nearest naming context is the partition, ignoring spacing") - void nearestNamingContext() { - assertEquals("ou=users, o=acme", - EdirPasswordPolicy.nearestPartitionRoot( - "cn=alice,ou=eng,ou=users,o=acme", - List.of("", "o=acme", "ou=users, o=acme", "o=other"))); - // The user entry is not its own partition root. - assertNull(EdirPasswordPolicy.nearestPartitionRoot("o=acme", List.of("o=acme"))); + @DisplayName("a non-zero NMAS error is unknown, not no policy") + void nmasError() { + PolicyLookup lookup = EdirPasswordPolicy.decodeResponse(seq( + new ASN1Integer(1), new ASN1Integer(-1697))); + assertEquals(Status.UNKNOWN, lookup.status()); + assertEquals("NMAS error -1697", lookup.detail()); } - } - - @Nested - @DisplayName("Login Policy location") - class LoginPolicyDn { @Test - @DisplayName("candidates cover the root Security container and each naming context") - void candidates() { - List contexts = List.of("", "o=acme", "cn=Security,o=acme"); - assertEquals(List.of( - "cn=Security", - "cn=Security,o=acme"), - EdirPasswordPolicy.securityContainerCandidates(contexts)); - assertEquals(List.of( - "cn=Login Policy,cn=Security", - "cn=Login Policy,cn=Security,o=acme"), - EdirPasswordPolicy.loginPolicyCandidates(contexts)); + @DisplayName("missing, short or garbled values are unknown and show the bytes") + void garbage() { + assertEquals(Status.UNKNOWN, EdirPasswordPolicy.decodeResponse(null).status()); + assertEquals(Status.UNKNOWN, EdirPasswordPolicy.decodeResponse(new byte[0]).status()); + + PolicyLookup oneInt = EdirPasswordPolicy.decodeResponse(seq(new ASN1Integer(0))); + assertEquals(Status.UNKNOWN, oneInt.status()); + assertTrue(oneInt.detail().contains("3003020100"), oneInt.detail()); + + PolicyLookup junk = EdirPasswordPolicy.decodeResponse(new byte[] {0x30, 0x05, 0x02}); + assertEquals(Status.UNKNOWN, junk.status()); + assertTrue(junk.detail().startsWith("unreadable response"), junk.detail()); } } @Nested - @DisplayName("dialog decisions") - class DialogDecisions { - - private final Assignment policy = new Assignment(POLICY, Source.USER, USER); - private final PolicyLookup found = new PolicyLookup(policy, true); - private final PolicyLookup none = new PolicyLookup(null, true); + @DisplayName("method choice") + class Choice { @Test - @DisplayName("a found policy is shown and Password Modify stays selected") - void policyFound() { - String status = EdirPasswordPolicy.describeStatus(found, null); - assertTrue(status.startsWith("Universal Password policy applies. ")); - assertTrue(status.contains("Assigned to this user.")); - assertFalse(EdirPasswordPolicy.offerMethodChoice(found)); + @DisplayName("Legacy is the default only for NMAS-confirmed no policy over LDAPS") + void defaults() { + assertEquals(Method.LEGACY_NDS, EdirPasswordPolicy.defaultMethod(PolicyLookup.none(), true)); + assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(PolicyLookup.none(), false)); assertEquals(Method.UNIVERSAL_PASSWORD, - EdirPasswordPolicy.defaultMethod(found, true)); - } - - @Test - @DisplayName("no policy offers the choice and defaults to legacy on LDAPS") - void noPolicyDefaultsToLegacyWhenSecure() { - String status = EdirPasswordPolicy.describeStatus(none, null); - assertTrue(status.contains("No Universal Password policy")); - assertTrue(status.contains("Login Policy")); - assertTrue(EdirPasswordPolicy.offerMethodChoice(none)); - assertEquals(Method.LEGACY_NDS, - EdirPasswordPolicy.defaultMethod(none, true)); - } - - @Test - @DisplayName("no policy on a cleartext connection keeps Password Modify") - void noPolicyOnCleartextStaysUniversal() { + EdirPasswordPolicy.defaultMethod(PolicyLookup.unknown("x"), true)); assertEquals(Method.UNIVERSAL_PASSWORD, - EdirPasswordPolicy.defaultMethod(none, false)); + EdirPasswordPolicy.defaultMethod(PolicyLookup.policy(POLICY), true)); + assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(null, true)); } @Test - @DisplayName("a failed lookup keeps Password Modify and still offers the choice") - void lookupFailed() { - String status = EdirPasswordPolicy.describeStatus(null, "insufficient access"); - assertTrue(status.startsWith("Could not check whether a Universal Password policy applies: ")); - assertTrue(status.contains("insufficient access")); + @DisplayName("the choice is hidden only when a policy applies") + void offer() { + assertFalse(EdirPasswordPolicy.offerMethodChoice(PolicyLookup.policy(POLICY))); + assertTrue(EdirPasswordPolicy.offerMethodChoice(PolicyLookup.none())); + assertTrue(EdirPasswordPolicy.offerMethodChoice(PolicyLookup.unknown("x"))); assertTrue(EdirPasswordPolicy.offerMethodChoice(null)); - assertEquals(Method.UNIVERSAL_PASSWORD, - EdirPasswordPolicy.defaultMethod(null, true)); } @Test - @DisplayName("partition and Login Policy source text names the entry") - void sourceText() { - assertEquals("Inherited from partition root " + PARTITION + ".", - new Assignment(POLICY, Source.PARTITION_ROOT, PARTITION).sourceText()); - assertEquals("Inherited from the Login Policy (" + LOGIN + ").", - new Assignment(POLICY, Source.LOGIN_POLICY, LOGIN).sourceText()); + @DisplayName("status text") + void status() { + assertEquals("Universal Password policy applies:", + EdirPasswordPolicy.describeStatus(PolicyLookup.policy(POLICY), null)); + assertEquals("No Universal Password policy applies to this user.", + EdirPasswordPolicy.describeStatus(PolicyLookup.none(), null)); + assertEquals("Could not check whether a Universal Password policy applies: " + + "protocol error (2): Unrecognized extended operation. " + + "Password Modify stays selected.", + EdirPasswordPolicy.describeStatus(PolicyLookup.unknown( + "protocol error (2): Unrecognized extended operation"), null)); + assertTrue(EdirPasswordPolicy.describeStatus(null, "boom").contains("boom")); } } @Nested - @DisplayName("when to offer a legacy retry") - class LegacyRetry { - - @Test - @DisplayName("result 53 and 19 offer a retry") - void unwillingAndConstraint() { - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "no"))); - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.CONSTRAINT_VIOLATION, "too short"))); - } + @DisplayName("legacy retry") + class Retry { - @Test - @DisplayName("an NMAS diagnostic or response control offers a retry") - void nmas() { - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.OTHER, "NMAS Change Password extension failed"))); - Control control = new Control(EdirPasswordPolicy.NMAS_CONTROL_PREFIX + "7", false, - new ASN1OctetString(new byte[] {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x28})); - LDAPException withControl = new LDAPException(ResultCode.OTHER, "Password too short", - null, null, new Control[] {control}, null); - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy(withControl)); - } + private final LDAPException generic = new LDAPException(ResultCode.OTHER, + "NMAS Change Password extension failed"); @Test - @DisplayName("NMAS on the cause is enough when the wrapper text dropped the word") - void nmasOnTheCause() { - LDAPException cause = new LDAPException(ResultCode.OTHER, - "NMAS Change Password extension failed"); - LDAPException wrapper = new LDAPException(ResultCode.OTHER, "Password too short", cause); - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy(wrapper)); + @DisplayName("offered after a Password Modify failure when NMAS said no policy") + void offered() { + assertTrue(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), generic)); + assertTrue(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "x"))); + assertTrue(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new RuntimeException("wrapped", generic))); } @Test - @DisplayName("a negative matched DN is an NMAS failure even when the text is not") - void matchedDnCode() { - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.OTHER, "Password too short", "-216", null))); - assertTrue(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.OTHER, "Password too short", "NDS error: too short", null))); - assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.OTHER, "Password too short", "42", null))); + @DisplayName("not offered when a policy applies or the lookup was inconclusive") + void lookupGates() { + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.policy(POLICY), generic)); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.unknown("x"), generic)); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(null, generic)); } @Test - @DisplayName("retry only when the lookup succeeded and found no policy") - void retryRequiresConclusiveNone() { - LDAPException unwilling = new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "no"); - LDAPException constraint = new LDAPException(ResultCode.CONSTRAINT_VIOLATION, "too short"); - PolicyLookup none = new PolicyLookup(null, true); - PolicyLookup applied = new PolicyLookup( - new Assignment(POLICY, Source.USER, USER), true); - PolicyLookup unseenPartition = new PolicyLookup(null, false); - - assertTrue(EdirPasswordPolicy.offerLegacyRetry(none, unwilling)); - assertTrue(EdirPasswordPolicy.offerLegacyRetry(none, constraint)); - assertFalse(EdirPasswordPolicy.offerLegacyRetry(applied, unwilling)); - assertFalse(EdirPasswordPolicy.offerLegacyRetry(unseenPartition, unwilling)); - assertFalse(EdirPasswordPolicy.offerLegacyRetry(null, unwilling)); - assertFalse(EdirPasswordPolicy.offerLegacyRetry(none, - new LDAPException(ResultCode.INSUFFICIENT_ACCESS_RIGHTS, "no rights"))); + @DisplayName("not offered for rights or credential failures") + void rights() { + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new LDAPException(ResultCode.INSUFFICIENT_ACCESS_RIGHTS, "no"))); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new LDAPException(ResultCode.INVALID_CREDENTIALS, "no"))); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new LDAPException(ResultCode.OTHER, "NMAS failed", "-16049", null))); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new LDAPException(ResultCode.OTHER, "outer", + new LDAPException(ResultCode.OTHER, "NDS", "-672", null)))); } @Test - @DisplayName("a wrong current password or missing rights is not a legacy retry") - void otherFailures() { - assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.INVALID_CREDENTIALS, "Current password is incorrect."))); - assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.INSUFFICIENT_ACCESS_RIGHTS, "no rights"))); - assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy( - new LDAPException(ResultCode.OTHER, "server returned other"))); - assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy(null)); - assertFalse(EdirPasswordPolicy.passwordModifySuggestsLegacy(new IllegalStateException("no"))); + @DisplayName("not offered for a non-LDAP failure") + void nonLdap() { + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new IllegalStateException("boom"))); } } @Nested - @DisplayName("partition root not held on this server") - class PartitionVisibility { + @DisplayName("NMAS code extraction") + class Codes { @Test - @DisplayName("an unread closer container blocks a farther naming context") - void unreadCloserContainerIsNotAParentPartition() { - // namingContexts would report o=acme, but ou=secret sits between - // the user and that context and could not be read. It may be the - // real partition root. Do not treat o=acme as this user's partition. - PartitionAssessment assessment = EdirPasswordPolicy.assessPartition(List.of( - new AncestorProbe("ou=secret,o=acme", false, false))); - assertFalse(assessment.known()); - assertNull(assessment.partitionRootDn()); - - String user = "cn=alice,ou=secret,o=acme"; - Map map = policies( - user, null, - "ou=secret,o=acme", null, - PARTITION, POLICY, - LOGIN, OTHER); - PolicyLookup lookup = EdirPasswordPolicy.lookup(user, map, null, false, LOGIN); - - assertNull(lookup.assignment()); - assertFalse(lookup.conclusive()); - String status = EdirPasswordPolicy.describeStatus(lookup, null); - assertFalse(status.contains("No Universal Password policy")); - assertTrue(status.toLowerCase().contains("partition")); - assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(lookup, true)); - assertFalse(EdirPasswordPolicy.offerLegacyRetry(lookup, - new LDAPException(ResultCode.UNWILLING_TO_PERFORM, "NMAS"))); - } - - @Test - @DisplayName("a replica attribute closer than any naming context is the partition root") - void replicaBeatsAFartherNamingContext() { - PartitionAssessment assessment = EdirPasswordPolicy.assessPartition(List.of( - new AncestorProbe(PARENT, false, true), - new AncestorProbe(MIDDLE, true, true))); - assertTrue(assessment.known()); - assertEquals(MIDDLE, assessment.partitionRootDn()); - - Map map = policies(USER, null, PARENT, null, MIDDLE, POLICY, PARTITION, OTHER); - PolicyLookup lookup = EdirPasswordPolicy.lookup(USER, map, MIDDLE, true, LOGIN); - assertEquals(Source.PARTITION_ROOT, lookup.assignment().source()); - assertEquals(POLICY, lookup.assignment().policyDN()); - assertTrue(lookup.conclusive()); - } - - @Test - @DisplayName("a naming context is the root after closer containers are readable and are not") - void namingContextAfterConfirmedContainers() { - PartitionAssessment assessment = EdirPasswordPolicy.assessPartition(List.of( - new AncestorProbe(PARENT, false, true), - new AncestorProbe(MIDDLE, false, true), - new AncestorProbe(PARTITION, true, true))); - assertTrue(assessment.known()); - assertEquals(PARTITION, assessment.partitionRootDn()); - } - - @Test - @DisplayName("walking off the top without a partition root is not 'no policy'") - void noPartitionRootOnThisServer() { - PartitionAssessment assessment = EdirPasswordPolicy.assessPartition(List.of( - new AncestorProbe(PARENT, false, true), - new AncestorProbe(PARTITION, false, true))); - assertFalse(assessment.known()); - - PolicyLookup lookup = EdirPasswordPolicy.lookup( - USER, policies(USER, null, PARENT, null, LOGIN, POLICY), null, false, LOGIN); - assertNull(lookup.assignment()); - assertFalse(lookup.conclusive()); - assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(lookup, true)); - } - - @Test - @DisplayName("a user or parent policy is still conclusive when the partition is unseen") - void userAndContainerDoNotNeedThePartition() { - PolicyLookup onUser = EdirPasswordPolicy.lookup( - USER, policies(USER, POLICY), null, false, LOGIN); - assertTrue(onUser.conclusive()); - assertEquals(Source.USER, onUser.assignment().source()); - - PolicyLookup onParent = EdirPasswordPolicy.lookup( - USER, policies(USER, null, PARENT, POLICY, LOGIN, OTHER), null, false, LOGIN); - assertTrue(onParent.conclusive()); - assertEquals(Source.CONTAINER, onParent.assignment().source()); - assertEquals(POLICY, onParent.assignment().policyDN()); - assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(onParent, true)); + @DisplayName("from a response control, then the matched DN") + void sources() { + byte[] code = {(byte) 0xFF, (byte) 0xFF, (byte) 0xC1, (byte) 0x4F}; // -16049 + Control control = new Control(EdirPasswordPolicy.NMAS_CONTROL_PREFIX + "1", false, + new ASN1OctetString(code)); + assertEquals(-16049, EdirPasswordPolicy.nmasCode(new Control[] {control}, null)); + assertEquals(-216, EdirPasswordPolicy.nmasCode(null, " -216 ")); + assertNull(EdirPasswordPolicy.nmasCode(null, "cn=x")); + assertNull(EdirPasswordPolicy.nmasCode((Throwable) null)); } } - @Nested - @DisplayName("secure connection") - class SecureConnection { - - @Test - @DisplayName("a connected socket counts only when TLS is actually on") - void liveSocket() { - assertTrue(EdirPasswordPolicy.connectionIsSecure(true, true, false, 389)); - assertFalse(EdirPasswordPolicy.connectionIsSecure(true, false, true, 636)); - assertFalse(EdirPasswordPolicy.connectionIsSecure(true, false, false, 389)); - } - - @Test - @DisplayName("before connect, LDAPS config counts") - void configOnly() { - assertTrue(EdirPasswordPolicy.connectionIsSecure(false, false, true, 389)); - assertTrue(EdirPasswordPolicy.connectionIsSecure(false, false, false, 636)); - assertFalse(EdirPasswordPolicy.connectionIsSecure(false, false, false, 389)); - } + @Test + @DisplayName("a connected socket is secure only when TLS is active") + void secureConnection() { + assertTrue(EdirPasswordPolicy.connectionIsSecure(true, true, false, 389)); + assertFalse(EdirPasswordPolicy.connectionIsSecure(true, false, true, 636)); + assertTrue(EdirPasswordPolicy.connectionIsSecure(false, false, true, 389)); + assertTrue(EdirPasswordPolicy.connectionIsSecure(false, false, false, 636)); + assertFalse(EdirPasswordPolicy.connectionIsSecure(false, false, false, 389)); } } From afda3e59d5e68758f1e766f36734f1fc60909ad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 00:17:59 +0000 Subject: [PATCH 4/5] Treat NMAS -224 as a rights failure for the legacy retry Matches Arbor and the decodeNmasCode map, which labels -224 as insufficient rights. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KqCmx8MWAPFyiJVE7bLxyz --- .../com/pointbluetech/arborj/util/EdirPasswordPolicy.java | 4 ++-- .../com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java index e8cb625..6fb9dd5 100644 --- a/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java +++ b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java @@ -51,9 +51,9 @@ public final class EdirPasswordPolicy { /** * NMAS / NDS codes that mean the bound account may not set this password. * A legacy replace would only hit the same wall, so it is not offered. - * -16049 NMAS insufficient rights, -672 NDS no access. + * -224 and -16049 insufficient rights, -672 NDS no access. */ - private static final Set RIGHTS_CODES = Set.of(-16049, -672); + private static final Set RIGHTS_CODES = Set.of(-224, -672, -16049); private EdirPasswordPolicy() { } diff --git a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java index d3ef471..e30d2e4 100644 --- a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java +++ b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java @@ -195,6 +195,8 @@ void rights() { new LDAPException(ResultCode.INVALID_CREDENTIALS, "no"))); assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), new LDAPException(ResultCode.OTHER, "NMAS failed", "-16049", null))); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new LDAPException(ResultCode.OTHER, "NMAS failed", "-224", null))); assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), new LDAPException(ResultCode.OTHER, "outer", new LDAPException(ResultCode.OTHER, "NDS", "-672", null)))); From becdc45752a117e9bb7a402b4fcad45e7bb032f7 Mon Sep 17 00:00:00 2001 From: Jerry Combs Date: Thu, 5 Nov 2026 11:40:17 -0600 Subject: [PATCH 5/5] Add the live eDirectory 9.2.8 policy-info response as a regression test Captured from ig4 for cn=test15,ou=users,o=data. The outer SEQUENCE length is long form (30 84 00 00 00 42); the decoder must keep accepting it. --- .../arborj/util/EdirPasswordPolicyTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java index e30d2e4..f8cac5f 100644 --- a/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java +++ b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java @@ -236,4 +236,16 @@ void secureConnection() { assertTrue(EdirPasswordPolicy.connectionIsSecure(false, false, false, 636)); assertFalse(EdirPasswordPolicy.connectionIsSecure(false, false, false, 389)); } + + @Test + @DisplayName("live eDirectory 9.2.8 response with long-form length decodes to the policy DN") + void liveEDirectoryLongFormLength() { + byte[] live = HexFormat.of().parseHex( + "3084000000420201010201" + + "00043a636e3d53616d706c652050617373776f726420506f6c6963792c636e3d50617373" + + "776f726420506f6c69636965732c636e3d5365637572697479"); + PolicyLookup lookup = EdirPasswordPolicy.decodeResponse(live); + assertEquals(Status.POLICY, lookup.status()); + assertEquals("cn=Sample Password Policy,cn=Password Policies,cn=Security", lookup.policyDN()); + } }