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
;
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 Attributes publishedAttributes
;
37 // Temporarily expose the fields
38 protected final AttributeDictionary properties
;
39 protected final AttributeDictionary credentials
;
41 protected DefaultLdapEntry(AbstractLdapDirectory directory
, LdapName dn
, Attributes attributes
) {
42 Objects
.requireNonNull(directory
);
43 Objects
.requireNonNull(dn
);
44 this.directory
= directory
;
46 this.publishedAttributes
= attributes
;
47 properties
= new AttributeDictionary(false);
48 credentials
= new AttributeDictionary(true);
52 public LdapName
getDn() {
56 public synchronized Attributes
getAttributes() {
57 return isEditing() ?
getModifiedAttributes() : publishedAttributes
;
61 public List
<LdapName
> getReferences(String attributeId
) {
62 Attribute memberAttribute
= getAttributes().get(attributeId
);
63 if (memberAttribute
== null)
64 return new ArrayList
<LdapName
>();
66 List
<LdapName
> roles
= new ArrayList
<LdapName
>();
67 NamingEnumeration
<?
> values
= memberAttribute
.getAll();
68 while (values
.hasMore()) {
69 LdapName dn
= new LdapName(values
.next().toString());
73 } catch (NamingException e
) {
74 throw new IllegalStateException("Cannot get members", e
);
79 /** Should only be called from working copy thread. */
80 protected synchronized Attributes
getModifiedAttributes() {
81 assert getWc() != null;
82 return getWc().getModifiedData().get(getDn());
85 protected synchronized boolean isEditing() {
86 return getWc() != null && getModifiedAttributes() != null;
89 private synchronized LdapEntryWorkingCopy
getWc() {
90 return directory
.getWorkingCopy();
93 protected synchronized void startEditing() {
95 // throw new IllegalStateException("Cannot edit frozen view");
96 if (directory
.isReadOnly())
97 throw new IllegalStateException("User directory is read-only");
98 assert getModifiedAttributes() == null;
99 getWc().startEditing(this);
100 // modifiedAttributes = (Attributes) publishedAttributes.clone();
103 public synchronized void publishAttributes(Attributes modifiedAttributes
) {
104 publishedAttributes
= modifiedAttributes
;
111 public Dictionary
<String
, Object
> getProperties() {
119 public boolean hasCredential(String key
, Object value
) {
121 // TODO check other sources (like PKCS12)
122 // String pwd = new String((char[]) value);
123 // authPassword (RFC 312 https://tools.ietf.org/html/rfc3112)
124 char[] password
= DirectoryDigestUtils
.bytesToChars(value
);
126 if (getDirectory().getForcedPassword() != null
127 && getDirectory().getForcedPassword().equals(new String(password
)))
130 AuthPassword authPassword
= AuthPassword
.matchAuthValue(getAttributes(), password
);
131 if (authPassword
!= null) {
132 if (authPassword
.getAuthScheme().equals(SharedSecret
.X_SHARED_SECRET
)) {
133 SharedSecret onceToken
= new SharedSecret(authPassword
);
134 if (onceToken
.isExpired()) {
135 // AuthPassword.remove(getAttributes(), onceToken);
138 // boolean wasRemoved = AuthPassword.remove(getAttributes(), onceToken);
141 // TODO delete expired tokens?
143 // TODO implement SHA
144 throw new UnsupportedOperationException(
145 "Unsupported authPassword scheme " + authPassword
.getAuthScheme());
150 // byte[] hashedPassword = hash(password, DigestUtils.PASSWORD_SCHEME_PBKDF2_SHA256);
151 if (hasCredential(LdapAttrs
.userPassword
.name(), DirectoryDigestUtils
.charsToBytes(password
)))
156 Object storedValue
= credentials
.get(key
);
157 if (storedValue
== null || value
== null)
159 if (!(value
instanceof String
|| value
instanceof byte[]))
161 if (storedValue
instanceof String
&& value
instanceof String
)
162 return storedValue
.equals(value
);
163 if (storedValue
instanceof byte[] && value
instanceof byte[]) {
164 String storedBase64
= new String((byte[]) storedValue
, US_ASCII
);
165 String passwordScheme
= null;
166 if (storedBase64
.charAt(0) == '{') {
167 int index
= storedBase64
.indexOf('}');
169 passwordScheme
= storedBase64
.substring(1, index
);
170 String storedValueBase64
= storedBase64
.substring(index
+ 1);
171 byte[] storedValueBytes
= Base64
.getDecoder().decode(storedValueBase64
);
172 char[] passwordValue
= DirectoryDigestUtils
.bytesToChars((byte[]) value
);
174 if (DirectoryDigestUtils
.PASSWORD_SCHEME_SHA
.equals(passwordScheme
)) {
175 valueBytes
= DirectoryDigestUtils
.toPasswordScheme(passwordScheme
, passwordValue
, null, null,
177 } else if (DirectoryDigestUtils
.PASSWORD_SCHEME_PBKDF2_SHA256
.equals(passwordScheme
)) {
178 // see https://www.thesubtlety.com/post/a-389-ds-pbkdf2-password-checker/
179 byte[] iterationsArr
= Arrays
.copyOfRange(storedValueBytes
, 0, 4);
180 BigInteger iterations
= new BigInteger(iterationsArr
);
181 byte[] salt
= Arrays
.copyOfRange(storedValueBytes
, iterationsArr
.length
,
182 iterationsArr
.length
+ 64);
183 byte[] keyArr
= Arrays
.copyOfRange(storedValueBytes
, iterationsArr
.length
+ salt
.length
,
184 storedValueBytes
.length
);
185 int keyLengthBits
= keyArr
.length
* 8;
186 valueBytes
= DirectoryDigestUtils
.toPasswordScheme(passwordScheme
, passwordValue
, salt
,
187 iterations
.intValue(), keyLengthBits
);
189 throw new UnsupportedOperationException("Unknown password scheme " + passwordScheme
);
191 return Arrays
.equals(storedValueBytes
, valueBytes
);
195 // if (storedValue instanceof byte[] && value instanceof byte[]) {
196 // return Arrays.equals((byte[]) storedValue, (byte[]) value);
201 /** Hash the password */
202 private static byte[] sha1hash(char[] password
) {
203 byte[] hashedPassword
= ("{SHA}" + Base64
.getEncoder()
204 .encodeToString(DirectoryDigestUtils
.sha1(DirectoryDigestUtils
.charsToBytes(password
))))
205 .getBytes(StandardCharsets
.UTF_8
);
206 return hashedPassword
;
209 public AbstractLdapDirectory
getDirectory() {
213 public LdapDirectoryDao
getDirectoryDao() {
214 return directory
.getDirectoryDao();
218 public int hashCode() {
219 return dn
.hashCode();
223 public boolean equals(Object obj
) {
226 if (obj
instanceof LdapEntry
) {
227 LdapEntry that
= (LdapEntry
) obj
;
228 return this.dn
.equals(that
.getDn());
234 public String
toString() {
235 return dn
.toString();
238 private static boolean isAsciiPrintable(String str
) {
242 int sz
= str
.length();
243 for (int i
= 0; i
< sz
; i
++) {
244 if (isAsciiPrintable(str
.charAt(i
)) == false) {
251 private static boolean isAsciiPrintable(char ch
) {
252 return ch
>= 32 && ch
< 127;
255 protected class AttributeDictionary
extends Dictionary
<String
, Object
> {
256 private final List
<String
> effectiveKeys
= new ArrayList
<String
>();
257 private final List
<String
> attrFilter
;
258 private final Boolean includeFilter
;
260 public AttributeDictionary(Boolean credentials
) {
261 this.attrFilter
= getDirectory().getCredentialAttributeIds();
262 this.includeFilter
= credentials
;
264 NamingEnumeration
<String
> ids
= getAttributes().getIDs();
265 while (ids
.hasMore()) {
266 String id
= ids
.next();
267 if (credentials
&& attrFilter
.contains(id
))
268 effectiveKeys
.add(id
);
269 else if (!credentials
&& !attrFilter
.contains(id
))
270 effectiveKeys
.add(id
);
272 } catch (NamingException e
) {
273 throw new IllegalStateException("Cannot initialise attribute dictionary", e
);
276 effectiveKeys
.add(LdapAttrs
.objectClasses
.name());
281 return effectiveKeys
.size();
285 public boolean isEmpty() {
286 return effectiveKeys
.size() == 0;
290 public Enumeration
<String
> keys() {
291 return Collections
.enumeration(effectiveKeys
);
295 public Enumeration
<Object
> elements() {
296 final Iterator
<String
> it
= effectiveKeys
.iterator();
297 return new Enumeration
<Object
>() {
300 public boolean hasMoreElements() {
305 public Object
nextElement() {
306 String key
= it
.next();
314 public Object
get(Object key
) {
316 Attribute attr
= !key
.equals(LdapAttrs
.objectClasses
.name()) ?
getAttributes().get(key
.toString())
317 : getAttributes().get(LdapAttrs
.objectClass
.name());
320 Object value
= attr
.get();
321 if (value
instanceof byte[]) {
322 if (key
.equals(LdapAttrs
.userPassword
.name()))
323 // TODO other cases (certificates, images)
325 value
= new String((byte[]) value
, StandardCharsets
.UTF_8
);
327 if (attr
.size() == 1)
329 // special case for object class
330 if (key
.equals(LdapAttrs
.objectClass
.name())) {
331 // TODO support multiple object classes
332 NamingEnumeration
<?
> en
= attr
.getAll();
334 attrs
: while (en
.hasMore()) {
335 String v
= en
.next().toString();
336 if (v
.equalsIgnoreCase(LdapObjs
.top
.name()))
340 if (v
.equalsIgnoreCase(getDirectory().getUserObjectClass()))
341 return getDirectory().getUserObjectClass();
342 else if (v
.equalsIgnoreCase(getDirectory().getGroupObjectClass()))
343 return getDirectory().getGroupObjectClass();
347 throw new IllegalStateException("Cannot find objectClass in " + value
);
349 NamingEnumeration
<?
> en
= attr
.getAll();
350 StringJoiner values
= new StringJoiner("\n");
351 while (en
.hasMore()) {
352 String v
= en
.next().toString();
355 return values
.toString();
359 } catch (NamingException e
) {
360 throw new IllegalStateException("Cannot get value for attribute " + key
, e
);
365 public Object
put(String key
, Object value
) {
367 // TODO persist to other sources (like PKCS12)
368 char[] password
= DirectoryDigestUtils
.bytesToChars(value
);
369 byte[] hashedPassword
= sha1hash(password
);
370 return put(LdapAttrs
.userPassword
.name(), hashedPassword
);
372 if (key
.startsWith("X-")) {
373 return put(LdapAttrs
.authPassword
.name(), value
);
376 getDirectory().checkEdit();
380 if (!(value
instanceof String
|| value
instanceof byte[]))
381 throw new IllegalArgumentException("Value must be String or byte[]");
383 if (includeFilter
&& !attrFilter
.contains(key
))
384 throw new IllegalArgumentException("Key " + key
+ " not included");
385 else if (!includeFilter
&& attrFilter
.contains(key
))
386 throw new IllegalArgumentException("Key " + key
+ " excluded");
389 Attribute attribute
= getModifiedAttributes().get(key
.toString());
390 // if (attribute == null) // block unit tests
391 attribute
= new BasicAttribute(key
.toString());
392 if (value
instanceof String
&& !isAsciiPrintable(((String
) value
)))
393 attribute
.add(((String
) value
).getBytes(StandardCharsets
.UTF_8
));
395 attribute
.add(value
);
396 Attribute previousAttribute
= getModifiedAttributes().put(attribute
);
397 if (previousAttribute
!= null)
398 return previousAttribute
.get();
401 } catch (NamingException e
) {
402 throw new IllegalStateException("Cannot get value for attribute " + key
, e
);
407 public Object
remove(Object key
) {
408 getDirectory().checkEdit();
412 if (includeFilter
&& !attrFilter
.contains(key
))
413 throw new IllegalArgumentException("Key " + key
+ " not included");
414 else if (!includeFilter
&& attrFilter
.contains(key
))
415 throw new IllegalArgumentException("Key " + key
+ " excluded");
418 Attribute attr
= getModifiedAttributes().remove(key
.toString());
423 } catch (NamingException e
) {
424 throw new IllegalStateException("Cannot remove attribute " + key
, e
);