1 package org
.argeo
.util
.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
.util
.directory
.DirectoryDigestUtils
;
26 import org
.argeo
.util
.naming
.LdapAttrs
;
27 import org
.argeo
.util
.naming
.LdapObjs
;
28 import org
.argeo
.util
.naming
.SharedSecret
;
30 /** An entry in an LDAP (or LDIF) directory. */
31 public class DefaultLdapEntry
implements LdapEntry
{
32 private final AbstractLdapDirectory directory
;
34 private final LdapName dn
;
36 private Attributes publishedAttributes
;
38 // Temporarily expose the fields
39 protected final AttributeDictionary properties
;
40 protected final AttributeDictionary credentials
;
42 protected DefaultLdapEntry(AbstractLdapDirectory directory
, LdapName dn
, Attributes attributes
) {
43 Objects
.requireNonNull(directory
);
44 Objects
.requireNonNull(dn
);
45 this.directory
= directory
;
47 this.publishedAttributes
= attributes
;
48 properties
= new AttributeDictionary(false);
49 credentials
= new AttributeDictionary(true);
53 public LdapName
getDn() {
57 public synchronized Attributes
getAttributes() {
58 return isEditing() ?
getModifiedAttributes() : publishedAttributes
;
62 public List
<LdapName
> getReferences(String attributeId
) {
63 Attribute memberAttribute
= getAttributes().get(attributeId
);
64 if (memberAttribute
== null)
65 return new ArrayList
<LdapName
>();
67 List
<LdapName
> roles
= new ArrayList
<LdapName
>();
68 NamingEnumeration
<?
> values
= memberAttribute
.getAll();
69 while (values
.hasMore()) {
70 LdapName dn
= new LdapName(values
.next().toString());
74 } catch (NamingException e
) {
75 throw new IllegalStateException("Cannot get members", e
);
80 /** Should only be called from working copy thread. */
81 protected synchronized Attributes
getModifiedAttributes() {
82 assert getWc() != null;
83 return getWc().getModifiedData().get(getDn());
86 protected synchronized boolean isEditing() {
87 return getWc() != null && getModifiedAttributes() != null;
90 private synchronized LdapEntryWorkingCopy
getWc() {
91 return directory
.getWorkingCopy();
94 protected synchronized void startEditing() {
96 // throw new IllegalStateException("Cannot edit frozen view");
97 if (directory
.isReadOnly())
98 throw new IllegalStateException("User directory is read-only");
99 assert getModifiedAttributes() == null;
100 getWc().startEditing(this);
101 // modifiedAttributes = (Attributes) publishedAttributes.clone();
104 public synchronized void publishAttributes(Attributes modifiedAttributes
) {
105 publishedAttributes
= modifiedAttributes
;
112 public Dictionary
<String
, Object
> getProperties() {
120 public boolean hasCredential(String key
, Object value
) {
122 // TODO check other sources (like PKCS12)
123 // String pwd = new String((char[]) value);
124 // authPassword (RFC 312 https://tools.ietf.org/html/rfc3112)
125 char[] password
= DirectoryDigestUtils
.bytesToChars(value
);
127 if (getDirectory().getForcedPassword() != null
128 && getDirectory().getForcedPassword().equals(new String(password
)))
131 AuthPassword authPassword
= AuthPassword
.matchAuthValue(getAttributes(), password
);
132 if (authPassword
!= null) {
133 if (authPassword
.getAuthScheme().equals(SharedSecret
.X_SHARED_SECRET
)) {
134 SharedSecret onceToken
= new SharedSecret(authPassword
);
135 if (onceToken
.isExpired()) {
136 // AuthPassword.remove(getAttributes(), onceToken);
139 // boolean wasRemoved = AuthPassword.remove(getAttributes(), onceToken);
142 // TODO delete expired tokens?
144 // TODO implement SHA
145 throw new UnsupportedOperationException(
146 "Unsupported authPassword scheme " + authPassword
.getAuthScheme());
151 // byte[] hashedPassword = hash(password, DigestUtils.PASSWORD_SCHEME_PBKDF2_SHA256);
152 if (hasCredential(LdapAttrs
.userPassword
.name(), DirectoryDigestUtils
.charsToBytes(password
)))
157 Object storedValue
= credentials
.get(key
);
158 if (storedValue
== null || value
== null)
160 if (!(value
instanceof String
|| value
instanceof byte[]))
162 if (storedValue
instanceof String
&& value
instanceof String
)
163 return storedValue
.equals(value
);
164 if (storedValue
instanceof byte[] && value
instanceof byte[]) {
165 String storedBase64
= new String((byte[]) storedValue
, US_ASCII
);
166 String passwordScheme
= null;
167 if (storedBase64
.charAt(0) == '{') {
168 int index
= storedBase64
.indexOf('}');
170 passwordScheme
= storedBase64
.substring(1, index
);
171 String storedValueBase64
= storedBase64
.substring(index
+ 1);
172 byte[] storedValueBytes
= Base64
.getDecoder().decode(storedValueBase64
);
173 char[] passwordValue
= DirectoryDigestUtils
.bytesToChars((byte[]) value
);
175 if (DirectoryDigestUtils
.PASSWORD_SCHEME_SHA
.equals(passwordScheme
)) {
176 valueBytes
= DirectoryDigestUtils
.toPasswordScheme(passwordScheme
, passwordValue
, null, null,
178 } else if (DirectoryDigestUtils
.PASSWORD_SCHEME_PBKDF2_SHA256
.equals(passwordScheme
)) {
179 // see https://www.thesubtlety.com/post/a-389-ds-pbkdf2-password-checker/
180 byte[] iterationsArr
= Arrays
.copyOfRange(storedValueBytes
, 0, 4);
181 BigInteger iterations
= new BigInteger(iterationsArr
);
182 byte[] salt
= Arrays
.copyOfRange(storedValueBytes
, iterationsArr
.length
,
183 iterationsArr
.length
+ 64);
184 byte[] keyArr
= Arrays
.copyOfRange(storedValueBytes
, iterationsArr
.length
+ salt
.length
,
185 storedValueBytes
.length
);
186 int keyLengthBits
= keyArr
.length
* 8;
187 valueBytes
= DirectoryDigestUtils
.toPasswordScheme(passwordScheme
, passwordValue
, salt
,
188 iterations
.intValue(), keyLengthBits
);
190 throw new UnsupportedOperationException("Unknown password scheme " + passwordScheme
);
192 return Arrays
.equals(storedValueBytes
, valueBytes
);
196 // if (storedValue instanceof byte[] && value instanceof byte[]) {
197 // return Arrays.equals((byte[]) storedValue, (byte[]) value);
202 /** Hash the password */
203 private static byte[] sha1hash(char[] password
) {
204 byte[] hashedPassword
= ("{SHA}" + Base64
.getEncoder()
205 .encodeToString(DirectoryDigestUtils
.sha1(DirectoryDigestUtils
.charsToBytes(password
))))
206 .getBytes(StandardCharsets
.UTF_8
);
207 return hashedPassword
;
210 public AbstractLdapDirectory
getDirectory() {
214 public LdapDirectoryDao
getDirectoryDao() {
215 return directory
.getDirectoryDao();
219 public int hashCode() {
220 return dn
.hashCode();
224 public boolean equals(Object obj
) {
227 if (obj
instanceof LdapEntry
) {
228 LdapEntry that
= (LdapEntry
) obj
;
229 return this.dn
.equals(that
.getDn());
235 public String
toString() {
236 return dn
.toString();
239 private static boolean isAsciiPrintable(String str
) {
243 int sz
= str
.length();
244 for (int i
= 0; i
< sz
; i
++) {
245 if (isAsciiPrintable(str
.charAt(i
)) == false) {
252 private static boolean isAsciiPrintable(char ch
) {
253 return ch
>= 32 && ch
< 127;
256 protected class AttributeDictionary
extends Dictionary
<String
, Object
> {
257 private final List
<String
> effectiveKeys
= new ArrayList
<String
>();
258 private final List
<String
> attrFilter
;
259 private final Boolean includeFilter
;
261 public AttributeDictionary(Boolean credentials
) {
262 this.attrFilter
= getDirectory().getCredentialAttributeIds();
263 this.includeFilter
= credentials
;
265 NamingEnumeration
<String
> ids
= getAttributes().getIDs();
266 while (ids
.hasMore()) {
267 String id
= ids
.next();
268 if (credentials
&& attrFilter
.contains(id
))
269 effectiveKeys
.add(id
);
270 else if (!credentials
&& !attrFilter
.contains(id
))
271 effectiveKeys
.add(id
);
273 } catch (NamingException e
) {
274 throw new IllegalStateException("Cannot initialise attribute dictionary", e
);
277 effectiveKeys
.add(LdapAttrs
.objectClasses
.name());
282 return effectiveKeys
.size();
286 public boolean isEmpty() {
287 return effectiveKeys
.size() == 0;
291 public Enumeration
<String
> keys() {
292 return Collections
.enumeration(effectiveKeys
);
296 public Enumeration
<Object
> elements() {
297 final Iterator
<String
> it
= effectiveKeys
.iterator();
298 return new Enumeration
<Object
>() {
301 public boolean hasMoreElements() {
306 public Object
nextElement() {
307 String key
= it
.next();
315 public Object
get(Object key
) {
317 Attribute attr
= !key
.equals(LdapAttrs
.objectClasses
.name()) ?
getAttributes().get(key
.toString())
318 : getAttributes().get(LdapAttrs
.objectClass
.name());
321 Object value
= attr
.get();
322 if (value
instanceof byte[]) {
323 if (key
.equals(LdapAttrs
.userPassword
.name()))
324 // TODO other cases (certificates, images)
326 value
= new String((byte[]) value
, StandardCharsets
.UTF_8
);
328 if (attr
.size() == 1)
330 // special case for object class
331 if (key
.equals(LdapAttrs
.objectClass
.name())) {
332 // TODO support multiple object classes
333 NamingEnumeration
<?
> en
= attr
.getAll();
335 attrs
: while (en
.hasMore()) {
336 String v
= en
.next().toString();
337 if (v
.equalsIgnoreCase(LdapObjs
.top
.name()))
341 if (v
.equalsIgnoreCase(getDirectory().getUserObjectClass()))
342 return getDirectory().getUserObjectClass();
343 else if (v
.equalsIgnoreCase(getDirectory().getGroupObjectClass()))
344 return getDirectory().getGroupObjectClass();
348 throw new IllegalStateException("Cannot find objectClass in " + value
);
350 NamingEnumeration
<?
> en
= attr
.getAll();
351 StringJoiner values
= new StringJoiner("\n");
352 while (en
.hasMore()) {
353 String v
= en
.next().toString();
356 return values
.toString();
360 } catch (NamingException e
) {
361 throw new IllegalStateException("Cannot get value for attribute " + key
, e
);
366 public Object
put(String key
, Object value
) {
368 // TODO persist to other sources (like PKCS12)
369 char[] password
= DirectoryDigestUtils
.bytesToChars(value
);
370 byte[] hashedPassword
= sha1hash(password
);
371 return put(LdapAttrs
.userPassword
.name(), hashedPassword
);
373 if (key
.startsWith("X-")) {
374 return put(LdapAttrs
.authPassword
.name(), value
);
377 getDirectory().checkEdit();
381 if (!(value
instanceof String
|| value
instanceof byte[]))
382 throw new IllegalArgumentException("Value must be String or byte[]");
384 if (includeFilter
&& !attrFilter
.contains(key
))
385 throw new IllegalArgumentException("Key " + key
+ " not included");
386 else if (!includeFilter
&& attrFilter
.contains(key
))
387 throw new IllegalArgumentException("Key " + key
+ " excluded");
390 Attribute attribute
= getModifiedAttributes().get(key
.toString());
391 // if (attribute == null) // block unit tests
392 attribute
= new BasicAttribute(key
.toString());
393 if (value
instanceof String
&& !isAsciiPrintable(((String
) value
)))
394 attribute
.add(((String
) value
).getBytes(StandardCharsets
.UTF_8
));
396 attribute
.add(value
);
397 Attribute previousAttribute
= getModifiedAttributes().put(attribute
);
398 if (previousAttribute
!= null)
399 return previousAttribute
.get();
402 } catch (NamingException e
) {
403 throw new IllegalStateException("Cannot get value for attribute " + key
, e
);
408 public Object
remove(Object key
) {
409 getDirectory().checkEdit();
413 if (includeFilter
&& !attrFilter
.contains(key
))
414 throw new IllegalArgumentException("Key " + key
+ " not included");
415 else if (!includeFilter
&& attrFilter
.contains(key
))
416 throw new IllegalArgumentException("Key " + key
+ " excluded");
419 Attribute attr
= getModifiedAttributes().remove(key
.toString());
424 } catch (NamingException e
) {
425 throw new IllegalStateException("Cannot remove attribute " + key
, e
);