Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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<String> parseMasked(String raw) {
if (raw == null || raw.isBlank()) return Set.of();
Set<String> out = new HashSet<>();
Expand Down
95 changes: 89 additions & 6 deletions src/main/java/com/pointbluetech/arborj/service/LDAPService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.).
*
* <p>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.
Expand Down Expand Up @@ -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();
Expand All @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading