1 package org
.argeo
.cms
.directory
.ldap
;
3 import static java
.nio
.charset
.StandardCharsets
.US_ASCII
;
5 import java
.math
.BigInteger
;
6 import java
.nio
.charset
.StandardCharsets
;
7 import java
.util
.ArrayList
;
8 import java
.util
.Arrays
;
9 import java
.util
.Base64
;
10 import java
.util
.Collections
;
11 import java
.util
.Dictionary
;
12 import java
.util
.Enumeration
;
13 import java
.util
.Iterator
;
14 import java
.util
.List
;
15 import java
.util
.Objects
;
16 import java
.util
.StringJoiner
;
18 import javax
.naming
.NamingEnumeration
;
19 import javax
.naming
.NamingException
;
20 import javax
.naming
.directory
.Attribute
;
21 import javax
.naming
.directory
.Attributes
;
22 import javax
.naming
.directory
.BasicAttribute
;
23 import javax
.naming
.ldap
.LdapName
;
25 import org
.argeo
.api
.acr
.ldap
.LdapAttr
;
26 import org
.argeo
.api
.acr
.ldap
.LdapObj
;
27 import org
.argeo
.api
.cms
.directory
.DirectoryDigestUtils
;
29 /** An entry in an LDAP (or LDIF) directory. */
30 public class DefaultLdapEntry
implements LdapEntry
{
31 private final AbstractLdapDirectory directory
;
33 private final LdapName dn
;
35 private AttributeDictionary properties
;
36 private AttributeDictionary credentials
;
38 // private String primaryObjectClass;
39 // private List<String> objectClasses = new ArrayList<>();
41 protected DefaultLdapEntry(AbstractLdapDirectory directory
, LdapName dn
) {
42 Objects
.requireNonNull(directory
);
43 Objects
.requireNonNull(dn
);
44 this.directory
= directory
;
48 // Objects.requireNonNull(initialAttributes);
50 // NamingEnumeration<?> en = initialAttributes.get(LdapAttrs.objectClass.name()).getAll();
51 // String first = null;
52 // attrs: while (en.hasMore()) {
53 // String v = en.next().toString();
54 // if (v.equalsIgnoreCase(LdapObjs.top.name()))
58 // if (v.equalsIgnoreCase(getDirectory().getUserObjectClass()))
59 // primaryObjectClass = getDirectory().getUserObjectClass();
60 // else if (v.equalsIgnoreCase(getDirectory().getGroupObjectClass()))
61 // primaryObjectClass = getDirectory().getGroupObjectClass();
62 // objectClasses.add(v);
64 // if (primaryObjectClass == null) {
66 // throw new IllegalStateException("Could not find primary object class");
67 // primaryObjectClass = first;
69 // } catch (NamingException e) {
70 // throw new IllegalStateException("Cannot find object classes", e);
76 public LdapName
getDn() {
77 // always return a copy since LdapName is mutable
78 return (LdapName
) dn
.clone();
81 public synchronized Attributes
getAttributes() {
82 return isEditing() ?
getModifiedAttributes() : getDirectory().getDirectoryDao().doGetAttributes(dn
);
86 public List
<LdapName
> getReferences(String attributeId
) {
87 Attribute memberAttribute
= getAttributes().get(attributeId
);
88 if (memberAttribute
== null)
89 return new ArrayList
<LdapName
>();
91 List
<LdapName
> roles
= new ArrayList
<LdapName
>();
92 NamingEnumeration
<?
> values
= memberAttribute
.getAll();
93 while (values
.hasMore()) {
94 LdapName dn
= new LdapName(values
.next().toString());
98 } catch (NamingException e
) {
99 throw new IllegalStateException("Cannot get members", e
);
104 /** Should only be called from working copy thread. */
105 protected synchronized Attributes
getModifiedAttributes() {
106 assert getWc() != null;
107 return getWc().getModifiedData().get(getDn());
110 protected synchronized boolean isEditing() {
111 return getWc() != null && getModifiedAttributes() != null;
114 private synchronized LdapEntryWorkingCopy
getWc() {
115 return directory
.getWorkingCopy();
118 protected synchronized void startEditing() {
120 // throw new IllegalStateException("Cannot edit frozen view");
121 if (directory
.isReadOnly())
122 throw new IllegalStateException("User directory is read-only");
123 assert getModifiedAttributes() == null;
124 getWc().startEditing(this);
125 // modifiedAttributes = (Attributes) publishedAttributes.clone();
128 public synchronized void publishAttributes(Attributes modifiedAttributes
) {
129 // publishedAttributes = modifiedAttributes;
136 public Dictionary
<String
, Object
> getProperties() {
137 if (properties
== null)
138 properties
= new AttributeDictionary(false);
142 public Dictionary
<String
, Object
> getCredentials() {
143 if (credentials
== null)
144 credentials
= new AttributeDictionary(true);
152 public boolean hasCredential(String key
, Object value
) {
154 // TODO check other sources (like PKCS12)
155 // String pwd = new String((char[]) value);
156 // authPassword (RFC 312 https://tools.ietf.org/html/rfc3112)
157 char[] password
= DirectoryDigestUtils
.bytesToChars(value
);
159 if (getDirectory().getForcedPassword() != null
160 && getDirectory().getForcedPassword().equals(new String(password
)))
163 AuthPassword authPassword
= AuthPassword
.matchAuthValue(getAttributes(), password
);
164 if (authPassword
!= null) {
165 if (authPassword
.getAuthScheme().equals(SharedSecret
.X_SHARED_SECRET
)) {
166 SharedSecret onceToken
= new SharedSecret(authPassword
);
167 if (onceToken
.isExpired()) {
168 // AuthPassword.remove(getAttributes(), onceToken);
171 // boolean wasRemoved = AuthPassword.remove(getAttributes(), onceToken);
174 // TODO delete expired tokens?
176 // TODO implement SHA
177 throw new UnsupportedOperationException(
178 "Unsupported authPassword scheme " + authPassword
.getAuthScheme());
183 // byte[] hashedPassword = hash(password, DigestUtils.PASSWORD_SCHEME_PBKDF2_SHA256);
184 if (hasCredential(LdapAttr
.userPassword
.name(), DirectoryDigestUtils
.charsToBytes(password
)))
189 Object storedValue
= getCredentials().get(key
);
190 if (storedValue
== null || value
== null)
192 if (!(value
instanceof String
|| value
instanceof byte[]))
194 if (storedValue
instanceof String
&& value
instanceof String
)
195 return storedValue
.equals(value
);
196 if (storedValue
instanceof byte[] && value
instanceof byte[]) {
197 String storedBase64
= new String((byte[]) storedValue
, US_ASCII
);
198 String passwordScheme
= null;
199 if (storedBase64
.charAt(0) == '{') {
200 int index
= storedBase64
.indexOf('}');
202 passwordScheme
= storedBase64
.substring(1, index
);
203 String storedValueBase64
= storedBase64
.substring(index
+ 1);
204 byte[] storedValueBytes
= Base64
.getDecoder().decode(storedValueBase64
);
205 char[] passwordValue
= DirectoryDigestUtils
.bytesToChars((byte[]) value
);
207 if (DirectoryDigestUtils
.PASSWORD_SCHEME_SHA
.equals(passwordScheme
)) {
208 valueBytes
= DirectoryDigestUtils
.toPasswordScheme(passwordScheme
, passwordValue
, null, null,
210 } else if (DirectoryDigestUtils
.PASSWORD_SCHEME_PBKDF2_SHA256
.equals(passwordScheme
)) {
211 // see https://www.thesubtlety.com/post/a-389-ds-pbkdf2-password-checker/
212 byte[] iterationsArr
= Arrays
.copyOfRange(storedValueBytes
, 0, 4);
213 BigInteger iterations
= new BigInteger(iterationsArr
);
214 byte[] salt
= Arrays
.copyOfRange(storedValueBytes
, iterationsArr
.length
,
215 iterationsArr
.length
+ 64);
216 byte[] keyArr
= Arrays
.copyOfRange(storedValueBytes
, iterationsArr
.length
+ salt
.length
,
217 storedValueBytes
.length
);
218 int keyLengthBits
= keyArr
.length
* 8;
219 valueBytes
= DirectoryDigestUtils
.toPasswordScheme(passwordScheme
, passwordValue
, salt
,
220 iterations
.intValue(), keyLengthBits
);
222 throw new UnsupportedOperationException("Unknown password scheme " + passwordScheme
);
224 return Arrays
.equals(storedValueBytes
, valueBytes
);
228 // if (storedValue instanceof byte[] && value instanceof byte[]) {
229 // return Arrays.equals((byte[]) storedValue, (byte[]) value);
234 /** Hash the password */
235 private static byte[] sha1hash(char[] password
) {
236 byte[] hashedPassword
= ("{SHA}" + Base64
.getEncoder()
237 .encodeToString(DirectoryDigestUtils
.sha1(DirectoryDigestUtils
.charsToBytes(password
))))
238 .getBytes(StandardCharsets
.UTF_8
);
239 return hashedPassword
;
242 public AbstractLdapDirectory
getDirectory() {
246 public LdapDirectoryDao
getDirectoryDao() {
247 return directory
.getDirectoryDao();
251 public int hashCode() {
252 return dn
.hashCode();
256 public boolean equals(Object obj
) {
259 if (obj
instanceof LdapEntry
) {
260 LdapEntry that
= (LdapEntry
) obj
;
261 return this.dn
.equals(that
.getDn());
267 public String
toString() {
268 return dn
.toString();
271 private static boolean isAsciiPrintable(String str
) {
275 int sz
= str
.length();
276 for (int i
= 0; i
< sz
; i
++) {
277 if (isAsciiPrintable(str
.charAt(i
)) == false) {
284 private static boolean isAsciiPrintable(char ch
) {
285 return ch
>= 32 && ch
< 127;
288 protected class AttributeDictionary
extends Dictionary
<String
, Object
> {
289 private final List
<String
> effectiveKeys
= new ArrayList
<String
>();
290 private final List
<String
> attrFilter
;
291 private final Boolean includeFilter
;
293 public AttributeDictionary(Boolean credentials
) {
294 this.attrFilter
= getDirectory().getCredentialAttributeIds();
295 this.includeFilter
= credentials
;
297 NamingEnumeration
<String
> ids
= getAttributes().getIDs();
298 while (ids
.hasMore()) {
299 String id
= ids
.next();
300 if (credentials
&& attrFilter
.contains(id
))
301 effectiveKeys
.add(id
);
302 else if (!credentials
&& !attrFilter
.contains(id
))
303 effectiveKeys
.add(id
);
305 } catch (NamingException e
) {
306 throw new IllegalStateException("Cannot initialise attribute dictionary", e
);
309 effectiveKeys
.add(LdapAttr
.objectClasses
.name());
314 return effectiveKeys
.size();
318 public boolean isEmpty() {
319 return effectiveKeys
.size() == 0;
323 public Enumeration
<String
> keys() {
324 return Collections
.enumeration(effectiveKeys
);
328 public Enumeration
<Object
> elements() {
329 final Iterator
<String
> it
= effectiveKeys
.iterator();
330 return new Enumeration
<Object
>() {
333 public boolean hasMoreElements() {
338 public Object
nextElement() {
339 String key
= it
.next();
347 public Object
get(Object key
) {
349 Attribute attr
= !key
.equals(LdapAttr
.objectClasses
.name()) ?
getAttributes().get(key
.toString())
350 : getAttributes().get(LdapAttr
.objectClass
.name());
353 Object value
= attr
.get();
354 if (value
instanceof byte[]) {
355 if (key
.equals(LdapAttr
.userPassword
.name()))
356 // TODO other cases (certificates, images)
358 value
= new String((byte[]) value
, StandardCharsets
.UTF_8
);
360 if (attr
.size() == 1)
362 // special case for object class
363 if (key
.equals(LdapAttr
.objectClass
.name())) {
364 // TODO support multiple object classes
365 NamingEnumeration
<?
> en
= attr
.getAll();
367 attrs
: while (en
.hasMore()) {
368 String v
= en
.next().toString();
369 if (v
.equalsIgnoreCase(LdapObj
.top
.name()))
373 if (v
.equalsIgnoreCase(getDirectory().getUserObjectClass()))
374 return getDirectory().getUserObjectClass();
375 else if (v
.equalsIgnoreCase(getDirectory().getGroupObjectClass()))
376 return getDirectory().getGroupObjectClass();
380 throw new IllegalStateException("Cannot find objectClass in " + value
);
382 NamingEnumeration
<?
> en
= attr
.getAll();
383 StringJoiner values
= new StringJoiner("\n");
384 while (en
.hasMore()) {
385 String v
= en
.next().toString();
388 return values
.toString();
392 } catch (NamingException e
) {
393 throw new IllegalStateException("Cannot get value for attribute " + key
, e
);
398 public Object
put(String key
, Object value
) {
399 Objects
.requireNonNull(value
, "Value for key " + key
+ " is null");
402 // FIXME remove this "feature", a key should be specified
403 // TODO persist to other sources (like PKCS12)
404 char[] password
= DirectoryDigestUtils
.bytesToChars(value
);
405 byte[] hashedPassword
= sha1hash(password
);
406 return put(LdapAttr
.userPassword
.name(), hashedPassword
);
408 if (key
.startsWith("X-")) {
409 return put(LdapAttr
.authPassword
.name(), value
);
413 getDirectory().checkEdit();
417 // object classes special case.
418 if (key
.equals(LdapAttr
.objectClasses
.name())) {
419 Attribute attribute
= new BasicAttribute(LdapAttr
.objectClass
.name());
420 String
[] objectClasses
= value
.toString().split("\n");
421 for (String objectClass
: objectClasses
) {
422 if (objectClass
.trim().equals(""))
424 attribute
.add(objectClass
);
426 Attribute previousAttribute
= getModifiedAttributes().put(attribute
);
427 if (previousAttribute
!= null)
428 return previousAttribute
.get();
433 if (!(value
instanceof String
|| value
instanceof byte[]))
434 throw new IllegalArgumentException("Value must be String or byte[]");
436 if (includeFilter
&& !attrFilter
.contains(key
))
437 throw new IllegalArgumentException("Key " + key
+ " not included");
438 else if (!includeFilter
&& attrFilter
.contains(key
))
439 throw new IllegalArgumentException("Key " + key
+ " excluded");
441 Attribute attribute
= getModifiedAttributes().get(key
.toString());
442 // if (attribute == null) // block unit tests
443 attribute
= new BasicAttribute(key
.toString());
444 if (value
instanceof String
&& !isAsciiPrintable(((String
) value
)))
445 attribute
.add(((String
) value
).getBytes(StandardCharsets
.UTF_8
));
447 attribute
.add(value
);
448 Attribute previousAttribute
= getModifiedAttributes().put(attribute
);
449 if (previousAttribute
!= null)
450 return previousAttribute
.get();
453 } catch (NamingException e
) {
454 throw new IllegalStateException("Cannot get value for attribute " + key
, e
);
459 public Object
remove(Object key
) {
460 getDirectory().checkEdit();
464 if (includeFilter
&& !attrFilter
.contains(key
))
465 throw new IllegalArgumentException("Key " + key
+ " not included");
466 else if (!includeFilter
&& attrFilter
.contains(key
))
467 throw new IllegalArgumentException("Key " + key
+ " excluded");
470 Attribute attr
= getModifiedAttributes().remove(key
.toString());
475 } catch (NamingException e
) {
476 throw new IllegalStateException("Cannot remove attribute " + key
, e
);