]> git.argeo.org Git - lgpl/argeo-commons.git/blob - directory/ldap/DefaultLdapEntry.java
Prepare next development cycle
[lgpl/argeo-commons.git] / directory / ldap / DefaultLdapEntry.java
1 package org.argeo.cms.directory.ldap;
2
3 import static java.nio.charset.StandardCharsets.US_ASCII;
4
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;
17
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;
24
25 import org.argeo.api.acr.ldap.LdapAttr;
26 import org.argeo.api.acr.ldap.LdapObj;
27 import org.argeo.api.cms.directory.DirectoryDigestUtils;
28
29 /** An entry in an LDAP (or LDIF) directory. */
30 public class DefaultLdapEntry implements LdapEntry {
31 private final AbstractLdapDirectory directory;
32
33 private final LdapName dn;
34
35 private AttributeDictionary properties;
36 private AttributeDictionary credentials;
37
38 // private String primaryObjectClass;
39 // private List<String> objectClasses = new ArrayList<>();
40
41 protected DefaultLdapEntry(AbstractLdapDirectory directory, LdapName dn) {
42 Objects.requireNonNull(directory);
43 Objects.requireNonNull(dn);
44 this.directory = directory;
45 this.dn = dn;
46
47 // Object classes
48 // Objects.requireNonNull(initialAttributes);
49 // try {
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()))
55 // continue attrs;
56 // if (first == null)
57 // first = v;
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);
63 // }
64 // if (primaryObjectClass == null) {
65 // if (first == null)
66 // throw new IllegalStateException("Could not find primary object class");
67 // primaryObjectClass = first;
68 // }
69 // } catch (NamingException e) {
70 // throw new IllegalStateException("Cannot find object classes", e);
71 // }
72
73 }
74
75 @Override
76 public LdapName getDn() {
77 // always return a copy since LdapName is mutable
78 return (LdapName) dn.clone();
79 }
80
81 public synchronized Attributes getAttributes() {
82 return isEditing() ? getModifiedAttributes() : getDirectory().getDirectoryDao().doGetAttributes(dn);
83 }
84
85 @Override
86 public List<LdapName> getReferences(String attributeId) {
87 Attribute memberAttribute = getAttributes().get(attributeId);
88 if (memberAttribute == null)
89 return new ArrayList<LdapName>();
90 try {
91 List<LdapName> roles = new ArrayList<LdapName>();
92 NamingEnumeration<?> values = memberAttribute.getAll();
93 while (values.hasMore()) {
94 LdapName dn = new LdapName(values.next().toString());
95 roles.add(dn);
96 }
97 return roles;
98 } catch (NamingException e) {
99 throw new IllegalStateException("Cannot get members", e);
100 }
101
102 }
103
104 /** Should only be called from working copy thread. */
105 protected synchronized Attributes getModifiedAttributes() {
106 assert getWc() != null;
107 return getWc().getModifiedData().get(getDn());
108 }
109
110 protected synchronized boolean isEditing() {
111 return getWc() != null && getModifiedAttributes() != null;
112 }
113
114 private synchronized LdapEntryWorkingCopy getWc() {
115 return directory.getWorkingCopy();
116 }
117
118 protected synchronized void startEditing() {
119 // if (frozen)
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();
126 }
127
128 public synchronized void publishAttributes(Attributes modifiedAttributes) {
129 // publishedAttributes = modifiedAttributes;
130 }
131
132 /*
133 * PROPERTIES
134 */
135 @Override
136 public Dictionary<String, Object> getProperties() {
137 if (properties == null)
138 properties = new AttributeDictionary(false);
139 return properties;
140 }
141
142 public Dictionary<String, Object> getCredentials() {
143 if (credentials == null)
144 credentials = new AttributeDictionary(true);
145 return credentials;
146 }
147
148 /*
149 * CREDENTIALS
150 */
151 @Override
152 public boolean hasCredential(String key, Object value) {
153 if (key == null) {
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);
158
159 if (getDirectory().getForcedPassword() != null
160 && getDirectory().getForcedPassword().equals(new String(password)))
161 return true;
162
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);
169 return false;
170 } else {
171 // boolean wasRemoved = AuthPassword.remove(getAttributes(), onceToken);
172 return true;
173 }
174 // TODO delete expired tokens?
175 } else {
176 // TODO implement SHA
177 throw new UnsupportedOperationException(
178 "Unsupported authPassword scheme " + authPassword.getAuthScheme());
179 }
180 }
181
182 // Regular password
183 // byte[] hashedPassword = hash(password, DigestUtils.PASSWORD_SCHEME_PBKDF2_SHA256);
184 if (hasCredential(LdapAttr.userPassword.name(), DirectoryDigestUtils.charsToBytes(password)))
185 return true;
186 return false;
187 }
188
189 Object storedValue = getCredentials().get(key);
190 if (storedValue == null || value == null)
191 return false;
192 if (!(value instanceof String || value instanceof byte[]))
193 return false;
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('}');
201 if (index > 0) {
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);
206 byte[] valueBytes;
207 if (DirectoryDigestUtils.PASSWORD_SCHEME_SHA.equals(passwordScheme)) {
208 valueBytes = DirectoryDigestUtils.toPasswordScheme(passwordScheme, passwordValue, null, null,
209 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);
221 } else {
222 throw new UnsupportedOperationException("Unknown password scheme " + passwordScheme);
223 }
224 return Arrays.equals(storedValueBytes, valueBytes);
225 }
226 }
227 }
228 // if (storedValue instanceof byte[] && value instanceof byte[]) {
229 // return Arrays.equals((byte[]) storedValue, (byte[]) value);
230 // }
231 return false;
232 }
233
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;
240 }
241
242 public AbstractLdapDirectory getDirectory() {
243 return directory;
244 }
245
246 public LdapDirectoryDao getDirectoryDao() {
247 return directory.getDirectoryDao();
248 }
249
250 @Override
251 public int hashCode() {
252 return dn.hashCode();
253 }
254
255 @Override
256 public boolean equals(Object obj) {
257 if (this == obj)
258 return true;
259 if (obj instanceof LdapEntry) {
260 LdapEntry that = (LdapEntry) obj;
261 return this.dn.equals(that.getDn());
262 }
263 return false;
264 }
265
266 @Override
267 public String toString() {
268 return dn.toString();
269 }
270
271 private static boolean isAsciiPrintable(String str) {
272 if (str == null) {
273 return false;
274 }
275 int sz = str.length();
276 for (int i = 0; i < sz; i++) {
277 if (isAsciiPrintable(str.charAt(i)) == false) {
278 return false;
279 }
280 }
281 return true;
282 }
283
284 private static boolean isAsciiPrintable(char ch) {
285 return ch >= 32 && ch < 127;
286 }
287
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;
292
293 public AttributeDictionary(Boolean credentials) {
294 this.attrFilter = getDirectory().getCredentialAttributeIds();
295 this.includeFilter = credentials;
296 try {
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);
304 }
305 } catch (NamingException e) {
306 throw new IllegalStateException("Cannot initialise attribute dictionary", e);
307 }
308 if (!credentials)
309 effectiveKeys.add(LdapAttr.objectClasses.name());
310 }
311
312 @Override
313 public int size() {
314 return effectiveKeys.size();
315 }
316
317 @Override
318 public boolean isEmpty() {
319 return effectiveKeys.size() == 0;
320 }
321
322 @Override
323 public Enumeration<String> keys() {
324 return Collections.enumeration(effectiveKeys);
325 }
326
327 @Override
328 public Enumeration<Object> elements() {
329 final Iterator<String> it = effectiveKeys.iterator();
330 return new Enumeration<Object>() {
331
332 @Override
333 public boolean hasMoreElements() {
334 return it.hasNext();
335 }
336
337 @Override
338 public Object nextElement() {
339 String key = it.next();
340 return get(key);
341 }
342
343 };
344 }
345
346 @Override
347 public Object get(Object key) {
348 try {
349 Attribute attr = !key.equals(LdapAttr.objectClasses.name()) ? getAttributes().get(key.toString())
350 : getAttributes().get(LdapAttr.objectClass.name());
351 if (attr == null)
352 return null;
353 Object value = attr.get();
354 if (value instanceof byte[]) {
355 if (key.equals(LdapAttr.userPassword.name()))
356 // TODO other cases (certificates, images)
357 return value;
358 value = new String((byte[]) value, StandardCharsets.UTF_8);
359 }
360 if (attr.size() == 1)
361 return value;
362 // special case for object class
363 if (key.equals(LdapAttr.objectClass.name())) {
364 // TODO support multiple object classes
365 NamingEnumeration<?> en = attr.getAll();
366 String first = null;
367 attrs: while (en.hasMore()) {
368 String v = en.next().toString();
369 if (v.equalsIgnoreCase(LdapObj.top.name()))
370 continue attrs;
371 if (first == null)
372 first = v;
373 if (v.equalsIgnoreCase(getDirectory().getUserObjectClass()))
374 return getDirectory().getUserObjectClass();
375 else if (v.equalsIgnoreCase(getDirectory().getGroupObjectClass()))
376 return getDirectory().getGroupObjectClass();
377 }
378 if (first != null)
379 return first;
380 throw new IllegalStateException("Cannot find objectClass in " + value);
381 } else {
382 NamingEnumeration<?> en = attr.getAll();
383 StringJoiner values = new StringJoiner("\n");
384 while (en.hasMore()) {
385 String v = en.next().toString();
386 values.add(v);
387 }
388 return values.toString();
389 }
390 // else
391 // return value;
392 } catch (NamingException e) {
393 throw new IllegalStateException("Cannot get value for attribute " + key, e);
394 }
395 }
396
397 @Override
398 public Object put(String key, Object value) {
399 Objects.requireNonNull(value, "Value for key " + key + " is null");
400 try {
401 if (key == 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);
407 }
408 if (key.startsWith("X-")) {
409 return put(LdapAttr.authPassword.name(), value);
410 }
411
412 // start editing
413 getDirectory().checkEdit();
414 if (!isEditing())
415 startEditing();
416
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(""))
423 continue;
424 attribute.add(objectClass);
425 }
426 Attribute previousAttribute = getModifiedAttributes().put(attribute);
427 if (previousAttribute != null)
428 return previousAttribute.get();
429 else
430 return null;
431 }
432
433 if (!(value instanceof String || value instanceof byte[]))
434 throw new IllegalArgumentException("Value must be String or byte[]");
435
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");
440
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));
446 else
447 attribute.add(value);
448 Attribute previousAttribute = getModifiedAttributes().put(attribute);
449 if (previousAttribute != null)
450 return previousAttribute.get();
451 else
452 return null;
453 } catch (NamingException e) {
454 throw new IllegalStateException("Cannot get value for attribute " + key, e);
455 }
456 }
457
458 @Override
459 public Object remove(Object key) {
460 getDirectory().checkEdit();
461 if (!isEditing())
462 startEditing();
463
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");
468
469 try {
470 Attribute attr = getModifiedAttributes().remove(key.toString());
471 if (attr != null)
472 return attr.get();
473 else
474 return null;
475 } catch (NamingException e) {
476 throw new IllegalStateException("Cannot remove attribute " + key, e);
477 }
478 }
479
480 }
481
482 }