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 d0cadaa..d814eff 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,92 @@ 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."); + } + // 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 " + + "a secure connection (LDAPS)."); + } + modifyAttribute(dn, "userPassword", LDAPModifyOperation.REPLACE, List.of(newPassword)); + } + + /** + * 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(); + LDAPConnection conn = connection; + if (conn == null) throw new LDAPException(ResultCode.SERVER_DOWN, "Not connected"); + ExtendedResult result; + try { + result = conn.processExtendedOperation( + EdirPasswordPolicy.GET_POLICY_INFO_REQUEST_OID, + EdirPasswordPolicy.encodeRequest(userDN)); + } catch (LDAPException e) { + return EdirPasswordPolicy.PolicyLookup.unknown(describeResult( + e.getResultCode(), e.getDiagnosticMessage())); + } + if (result.getResultCode() != ResultCode.SUCCESS) { + return EdirPasswordPolicy.PolicyLookup.unknown(describeResult( + result.getResultCode(), result.getDiagnosticMessage())); + } + return EdirPasswordPolicy.decodeResponse( + result.getValue() == null ? null : result.getValue().getValue()); + } + + 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; + } + + /** + * 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); + } + // --- 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..6fb9dd5 --- /dev/null +++ b/src/main/java/com/pointbluetech/arborj/util/EdirPasswordPolicy.java @@ -0,0 +1,259 @@ +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.LDAPException; +import com.unboundid.ldap.sdk.ResultCode; + +import java.util.HexFormat; +import java.util.Set; + +/** + * Which Universal Password policy, if any, applies to an eDirectory user. + * + *

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. + */ +public final class EdirPasswordPolicy { + + 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."; + + /** + * 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. + * -224 and -16049 insufficient rights, -672 NDS no access. + */ + private static final Set RIGHTS_CODES = Set.of(-224, -672, -16049); + + private EdirPasswordPolicy() { + } + + 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 { + UNIVERSAL_PASSWORD, + LEGACY_NDS + } + + /** 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); + } + + public static PolicyLookup none() { + return new PolicyLookup(Status.NONE, null, null); + } + + public static PolicyLookup unknown(String detail) { + return new PolicyLookup(Status.UNKNOWN, null, detail); + } + + public boolean foundNone() { + return status == Status.NONE; + } + + public boolean hasPolicy() { + return status == Status.POLICY; + } + } + + /** 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()); + } + + /** + * 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 PolicyLookup decodeResponse(byte[] value) { + if (value == null || value.length == 0) { + return PolicyLookup.unknown("the server returned no response value"); + } + 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)); + } + + 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 (version == null || error == null) { + return PolicyLookup.unknown("unexpected response " + hex(value)); + } + if (error != 0) { + return PolicyLookup.unknown("NMAS error " + error); + } + if (policyDN == null || policyDN.isBlank()) { + return PolicyLookup.none(); + } + return PolicyLookup.policy(policyDN.trim()); + } + + /** 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 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."; + }; + } + + /** Offer the Universal Password / Legacy NDS choice unless a policy was found. */ + public static boolean offerMethodChoice(PolicyLookup lookup) { + return lookup == null || !lookup.hasPolicy(); + } + + /** 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; + } + + /** + * 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 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; + } + if (ldap == null) return false; + ResultCode rc = ldap.getResultCode(); + if (rc == ResultCode.INVALID_CREDENTIALS || rc == ResultCode.INSUFFICIENT_ACCESS_RIGHTS) { + return false; + } + Integer code = nmasCode(error); + return code == null || !RIGHTS_CODES.contains(code); + } + + /** + * 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; + } + + 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); + } + } + } + if (matchedDN != null && matchedDN.trim().matches("-?\\d+")) { + try { + return Integer.parseInt(matchedDN.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + /** + * 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 String hex(byte[] value) { + return "(" + HexFormat.of().formatHex(value) + ")"; + } + + /** 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 5a928e2..52f8fc6 100644 --- a/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java +++ b/src/main/java/com/pointbluetech/arborj/view/ChangePasswordDialog.java @@ -2,6 +2,7 @@ import com.pointbluetech.arborj.controller.MainController; import com.pointbluetech.arborj.model.DirectoryType; +import com.pointbluetech.arborj.util.EdirPasswordPolicy; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -10,13 +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 / 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 { @@ -25,14 +33,30 @@ 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 = + 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."); + private final Label legacyNoVerifyHint = new Label( + "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"); @@ -60,13 +84,12 @@ 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); VBox content = buildContent(dirType); content.setPadding(new Insets(12)); - content.setPrefWidth(450); + content.setPrefWidth(520); getDialogPane().setContent(content); @@ -75,7 +98,14 @@ 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); + 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 // keeps the dialog open. setOnAction runs after the close filter, @@ -91,6 +121,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,28 +153,33 @@ private VBox buildContent(DirectoryType dirType) { form.add(new Separator(), 0, row++, 2, 1); - // Current password -- shown for non-AD directories, and only when - // an administrative reset is NOT being performed. - 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++); - - // 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()); - currentLabel.managedProperty().bind(currentLabel.visibleProperty()); - currentPasswordField.visibleProperty().bind(adminResetCheckbox.selectedProperty().not()); - currentPasswordField.managedProperty().bind(currentPasswordField.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(); - }); + 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); + 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); + methodBox.setManaged(false); + form.add(methodBox, 0, row++, 2, 1); } // New password @@ -219,62 +261,163 @@ private void doChangePassword(DirectoryType dirType) { confirmPasswordField.requestFocus(); return; } - // Self-service (non-admin) on non-AD requires the current password. - if (dirType != DirectoryType.ACTIVE_DIRECTORY - && !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; - } - - 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); + // Read every control here. The write itself runs off the JavaFX thread. + boolean legacy = legacyRadio.isSelected(); + boolean mustChange = mustChangeCheckbox.isSelected(); + boolean neverExpires = neverExpiresCheckbox.isSelected(); + boolean applyUac = uacFetched; + int uac = currentUAC; + EdirPasswordPolicy.PolicyLookup lookup = policyLookup; + + setBusy(true); + Thread.ofVirtual().start(() -> { + try { + writePassword(dirType, newPassword, legacy, + mustChange, neverExpires, applyUac, uac); + Platform.runLater(this::close); + } catch (Exception ex) { + Platform.runLater(() -> { + setBusy(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; } + showError(msg); + }); + } + }); + } + + private void writePassword(DirectoryType dirType, String newPassword, 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); } - case EDIRECTORY -> { - String oldPassword = effectiveOldPassword(); - controller.getLdapService().changePasswordEDir(targetDN, oldPassword, newPassword); - } - default -> { - // Generic / OpenLDAP -- RFC 3062 - controller.getLdapService().changePasswordRFC3062( - targetDN, effectiveOldPassword(), newPassword); + } + case EDIRECTORY -> { + if (legacy) { + controller.getLdapService().changePasswordLegacyNDS(targetDN, newPassword); + } else { + controller.getLdapService().changePasswordEDir(targetDN, null, newPassword); } } - 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 = ex.getMessage(); - if (msg == null || msg.isBlank()) msg = "Password change failed."; - showError(msg); + default -> controller.getLdapService().changePasswordRFC3062( + targetDN, null, newPassword); } } /** - * 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. + * 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. */ - private String effectiveOldPassword() { - if (adminResetCheckbox.isSelected()) return null; - String typed = currentPasswordField.getText(); - return typed.isEmpty() ? null : typed; + private void offerLegacyRetry(String newPassword, 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\nNo Universal Password policy applies. Retry with Legacy NDS " + + "(replace userPassword over this LDAPS connection)? " + + "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; + + setBusy(true); + Thread.ofVirtual().start(() -> { + try { + controller.getLdapService().changePasswordLegacyNDS(targetDN, newPassword); + Platform.runLater(this::close); + } catch (Exception retryEx) { + Platform.runLater(() -> { + setBusy(false); + showError(messageOf(retryEx)); + }); + } + }); + } + + private void loadEdirPolicy(Button okBtn) { + Thread.ofVirtual().start(() -> { + EdirPasswordPolicy.PolicyLookup lookup = null; + String failure = null; + try { + lookup = controller.getLdapService().findUniversalPasswordPolicy(targetDN); + } catch (Exception e) { + failure = e.getMessage() == null || e.getMessage().isBlank() + ? e.toString() : e.getMessage(); + } + EdirPasswordPolicy.PolicyLookup found = lookup; + String error = failure; + boolean secure = false; + try { + secure = controller.getLdapService().isSecureConnection(); + } catch (Exception ignored) { + secure = false; + } + boolean secureConnection = secure; + Platform.runLater(() -> { + // 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.hasPolicy(); + policyDnLabel.setText(hasPolicy ? forUi.policyDN() : ""); + policyDnLabel.setVisible(hasPolicy); + policyDnLabel.setManaged(hasPolicy); + + boolean offer = EdirPasswordPolicy.offerMethodChoice(forUi); + methodBox.setVisible(offer); + methodBox.setManaged(offer); + legacyRadio.setDisable(!secureConnection); + legacySecureHint.setVisible(offer && !secureConnection); + legacySecureHint.setManaged(offer && !secureConnection); + + EdirPasswordPolicy.Method method = EdirPasswordPolicy.defaultMethod( + forUi, 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; + } + + /** 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) { @@ -282,13 +425,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..9508ba2 --- /dev/null +++ b/src/test/java/com/pointbluetech/arborj/service/LDAPServicePasswordErrorTest.java @@ -0,0 +1,148 @@ +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 wrapped around the server + // exception, which still carries the matched DN. + LDAPException cause = new LDAPException(ResultCode.OTHER, + "NMAS Change Password extension failed", "-216", null); + assertEquals(-216, EdirPasswordPolicy.nmasCode( + new LDAPException(ResultCode.OTHER, msg, 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..f8cac5f --- /dev/null +++ b/src/test/java/com/pointbluetech/arborj/util/EdirPasswordPolicyTest.java @@ -0,0 +1,251 @@ +package com.pointbluetech.arborj.util; + +import com.pointbluetech.arborj.util.EdirPasswordPolicy.Method; +import com.pointbluetech.arborj.util.EdirPasswordPolicy.PolicyLookup; +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; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.HexFormat; + +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,o=acme"; + private static final String POLICY = "cn=Sample Password Policy,cn=Password Policies,cn=Security"; + + private static byte[] seq(ASN1Element... elements) { + return new ASN1Sequence(elements).encode(); + } + + private static byte[] concat(ASN1Element... elements) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (ASN1Element e : elements) out.writeBytes(e.encode()); + return out.toByteArray(); + } + + @Nested + @DisplayName("request encoding") + class Request { + + @Test + @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)); + + 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("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("response decoding") + class Response { + + @Test + @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("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("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("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()); + } + + @Test + @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("method choice") + class Choice { + + @Test + @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(PolicyLookup.unknown("x"), true)); + assertEquals(Method.UNIVERSAL_PASSWORD, + EdirPasswordPolicy.defaultMethod(PolicyLookup.policy(POLICY), true)); + assertEquals(Method.UNIVERSAL_PASSWORD, EdirPasswordPolicy.defaultMethod(null, true)); + } + + @Test + @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)); + } + + @Test + @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("legacy retry") + class Retry { + + private final LDAPException generic = new LDAPException(ResultCode.OTHER, + "NMAS Change Password extension failed"); + + @Test + @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("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("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, "NMAS failed", "-224", null))); + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new LDAPException(ResultCode.OTHER, "outer", + new LDAPException(ResultCode.OTHER, "NDS", "-672", null)))); + } + + @Test + @DisplayName("not offered for a non-LDAP failure") + void nonLdap() { + assertFalse(EdirPasswordPolicy.offerLegacyRetry(PolicyLookup.none(), + new IllegalStateException("boom"))); + } + } + + @Nested + @DisplayName("NMAS code extraction") + class Codes { + + @Test + @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)); + } + } + + @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)); + } + + @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()); + } +}