LdapVCardProvider.java 22.4 KB
Newer Older
1 2 3 4
/**
 * $Revision: 1217 $
 * $Date: 2005-04-11 14:11:06 -0700 (Mon, 11 Apr 2005) $
 *
5
 * Copyright (C) 2005-2008 Jive Software. All rights reserved.
6
 *
7 8 9 10 11 12 13 14 15 16 17
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
18 19
 */

20
package org.jivesoftware.openfire.ldap;
21

22 23 24 25 26 27 28 29 30 31 32
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.regex.Matcher;

import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;

33 34 35 36
import org.dom4j.Document;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.dom4j.Node;
37
import org.jivesoftware.openfire.vcard.DefaultVCardProvider;
38 39
import org.jivesoftware.openfire.vcard.VCardManager;
import org.jivesoftware.openfire.vcard.VCardProvider;
40 41 42 43 44 45 46 47
import org.jivesoftware.util.AlreadyExistsException;
import org.jivesoftware.util.Base64;
import org.jivesoftware.util.JiveGlobals;
import org.jivesoftware.util.NotFoundException;
import org.jivesoftware.util.PropertyEventDispatcher;
import org.jivesoftware.util.PropertyEventListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
48 49 50
import org.xmpp.packet.JID;

/**
51 52
 * Read-only LDAP provider for vCards.Configuration consists of adding a provider:
 * <p>
53
 * <tt>provider.vcard.className = org.jivesoftware.openfire.ldap.LdapVCardProvider</tt>
54 55 56
 * </p>
 * <p>and an xml vcard-mapping in the system properties.</p>
 * <p>
57
 * The vcard attributes can be configured by adding an <code>attrs="attr1,attr2"</code>
58 59
 * attribute to the vcard elements.</p>
 * <p>
60 61 62
 * Arbitrary text can be used for the element values as well as <code>MessageFormat</code>
 * style placeholders for the ldap attributes. For example, if you wanted to map the LDAP
 * attribute <code>displayName</code> to the vcard element <code>FN</code>, the xml
63 64
 * nippet would be:</p><br><pre>&lt;FN attrs=&quot;displayName&quot;&gt;{0}&lt;/FN&gt;</pre>
 * <p>
65 66 67 68
 * The vCard XML must be escaped in CDATA and must also be well formed. It is the exact
 * XML this provider will send to a client after after stripping <code>attr</code> attributes
 * and populating the placeholders with the data retrieved from LDAP. This system should
 * be flexible enough to handle any client's vCard format. An example mapping follows.<br>
69
 * </p>
70
 * <tt>ldap.vcard-mapping =
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
 *        &lt;![CDATA[
 *    		&lt;vCard xmlns='vcard-temp'&gt;
 *    			&lt;FN attrs=&quot;displayName&quot;&gt;{0}&lt;/FN&gt;
 *    			&lt;NICKNAME attrs=&quot;uid&quot;&gt;{0}&lt;/NICKNAME&gt;
 *    			&lt;BDAY attrs=&quot;dob&quot;&gt;{0}&lt;/BDAY&gt;
 *    			&lt;ADR&gt;
 *    				&lt;HOME/&gt;
 *    				&lt;EXTADR&gt;Ste 500&lt;/EXTADR&gt;
 *    				&lt;STREET&gt;317 SW Alder St&lt;/STREET&gt;
 *    				&lt;LOCALITY&gt;Portland&lt;/LOCALITY&gt;
 *    				&lt;REGION&gt;Oregon&lt;/REGION&gt;
 *    				&lt;PCODE&gt;97204&lt;/PCODE&gt;
 *    				&lt;CTRY&gt;USA&lt;/CTRY&gt;
 *    			&lt;/ADR&gt;
 *    			&lt;TEL&gt;
 *    				&lt;HOME/&gt;
 *    				&lt;VOICE/&gt;
 *    				&lt;NUMBER attrs=&quot;telephoneNumber&quot;&gt;{0}&lt;/NUMBER&gt;
 *    			&lt;/TEL&gt;
 *    			&lt;EMAIL&gt;
 *    				&lt;INTERNET/&gt;
 *    				&lt;USERID attrs=&quot;mail&quot;&gt;{0}&lt;/USERID&gt;
 *    			&lt;/EMAIL&gt;
 *    			&lt;TITLE attrs=&quot;title&quot;&gt;{0}&lt;/TITLE&gt;
 *    			&lt;ROLE attrs=&quot;&quot;&gt;{0}&lt;/ROLE&gt;
 *    			&lt;ORG&gt;
 *    				&lt;ORGNAME attrs=&quot;o&quot;&gt;{0}&lt;/ORGNAME&gt;
 *    				&lt;ORGUNIT attrs=&quot;&quot;&gt;{0}&lt;/ORGUNIT&gt;
 *    			&lt;/ORG&gt;
 *    			&lt;URL attrs=&quot;labeledURI&quot;&gt;{0}&lt;/URL&gt;
 *    			&lt;DESC attrs=&quot;uidNumber,homeDirectory,loginShell&quot;&gt;
 *    				uid: {0} home: {1} shell: {2}
 *    			&lt;/DESC&gt;
 *    		&lt;/vCard&gt;
 *        ]]&gt;
106
 * </tt>
107
 * <p>
108
 * An easy way to get the vcard format your client needs, assuming you've been
109
 * using the database store, is to do a <code>SELECT value FROM ofVCard WHERE
110
 * username='some_user'</code> in your favorite sql querier and paste the result
111
 * into the <code>vcard-mapping</code> (don't forget the CDATA).</p>
112 113 114
 *
 * @author rkelly
 */
115
public class LdapVCardProvider implements VCardProvider, PropertyEventListener {
116

117 118
	private static final Logger Log = LoggerFactory.getLogger(LdapVCardProvider.class);

119
    private LdapManager manager;
120
    private VCardTemplate template;
121 122 123
    private Boolean dbStorageEnabled = false;

    /**
124 125
     * The default vCard provider is used to handle the vCard in the database. vCard
     * fields that can be overriden are stored in the database.
126 127 128 129
     *
     * This is used/created only if we are storing avatars in the database.
     */
    private DefaultVCardProvider defaultProvider = null;
130 131

    public LdapVCardProvider() {
132 133 134
        // Convert XML based provider setup to Database based
        JiveGlobals.migrateProperty("ldap.vcard-mapping");

135
        manager = LdapManager.getInstance();
136 137 138
        initTemplate();
        // Listen to property events so that the template is always up to date
        PropertyEventDispatcher.addListener(this);
139 140 141 142
        // DB vcard provider used for loading properties overwritten in the DB
        defaultProvider = new DefaultVCardProvider();
        // Check of avatars can be overwritten (and stored in the database)
        dbStorageEnabled = JiveGlobals.getBooleanProperty("ldap.override.avatar", false);
143 144
    }

145 146 147
    /**
     * Initializes the VCard template as set by the administrator.
     */
148
    private void initTemplate() {
149
        String property = JiveGlobals.getProperty("ldap.vcard-mapping");
150
        Log.debug("LdapVCardProvider: Found vcard mapping: '" + property);
151
        try {
Gaston Dombiak's avatar
Gaston Dombiak committed
152 153 154 155
            // Remove CDATA wrapping element
            if (property.startsWith("<![CDATA[")) {
                property = property.substring(9, property.length()-3);
            }
156 157 158 159 160 161 162
            Document document = DocumentHelper.parseText(property);
            template = new VCardTemplate(document);
        }
        catch (Exception e) {
            Log.error("Error loading vcard mapping: " + e.getMessage());
        }

163
        Log.debug("LdapVCardProvider: attributes size==" + template.getAttributes().length);
164 165
    }

166 167 168 169 170 171
    /**
     * Creates a mapping of requested LDAP attributes to their values for the given user.
     *
     * @param username User we are looking up in LDAP.
     * @return Map of LDAP attribute to setting.
     */
172
    private Map<String, String> getLdapAttributes(String username) {
173 174
        // Un-escape username
        username = JID.unescapeNode(username);
175
        Map<String, String> map = new HashMap<>();
176 177 178 179 180

        DirContext ctx = null;
        try {
            String userDN = manager.findUserDN(username);

181
            ctx = manager.getContext(manager.getUsersBaseDN(username));
182 183 184 185 186 187
            Attributes attrs = ctx.getAttributes(userDN, template.getAttributes());

            for (String attribute : template.getAttributes()) {
                javax.naming.directory.Attribute attr = attrs.get(attribute);
                String value;
                if (attr == null) {
188
                    Log.debug("LdapVCardProvider: No ldap value found for attribute '" + attribute + "'");
189 190 191
                    value = "";
                }
                else {
192
                    Object ob = attrs.get(attribute).get();
193
                    Log.debug("LdapVCardProvider: Found attribute "+attribute+" of type: "+ob.getClass());
194 195 196 197 198
                    if(ob instanceof String) {
                        value = (String)ob;
                    } else {
                        value = Base64.encodeBytes((byte[])ob);
                    }
199
                }
200
                Log.debug("LdapVCardProvider: Ldap attribute '" + attribute + "'=>'" + value + "'");
201 202 203 204 205
                map.put(attribute, value);
            }
            return map;
        }
        catch (Exception e) {
206
            Log.error(e.getMessage(), e);
207
            return Collections.emptyMap();
208 209 210 211 212 213 214 215 216 217 218 219 220
        }
        finally {
            try {
                if (ctx != null) {
                    ctx.close();
                }
            }
            catch (Exception e) {
                // Ignore.
            }
        }
    }

221 222 223 224 225 226 227 228
    /**
     * Loads the avatar from LDAP, based off the vcard template.
     *
     * If enabled, will replace a blank PHOTO element with one from a DB stored vcard.
     *
     * @param username User we are loading the vcard for.
     * @return The loaded vcard element, or null if none found.
     */
229
    @Override
230 231 232 233
    public Element loadVCard(String username) {
        // Un-escape username.
        username = JID.unescapeNode(username);
        Map<String, String> map = getLdapAttributes(username);
234
        Log.debug("LdapVCardProvider: Getting mapped vcard for " + username);
235
        Element vcard = new VCard(template).getVCard(map);
236 237 238 239 240 241 242 243 244 245 246 247 248
        // If we have a vcard from ldap, but it doesn't have an avatar filled in, then we
        // may fill it with a locally stored vcard element.
        if (dbStorageEnabled && vcard != null && (vcard.element("PHOTO") == null || vcard.element("PHOTO").element("BINVAL") == null || vcard.element("PHOTO").element("BINVAL").getText().matches("\\s*"))) {
            Element avatarElement = loadAvatarFromDatabase(username);
            if (avatarElement != null) {
                Log.debug("LdapVCardProvider: Adding avatar element from local storage");
                Element currentElement = vcard.element("PHOTO");
                if (currentElement != null) {
                    vcard.remove(currentElement);
                }
                vcard.add(avatarElement);
            }
        }
249
        Log.debug("LdapVCardProvider: Returning vcard");
250 251 252
        return vcard;
    }

253 254 255 256 257 258 259 260 261 262 263 264
    /**
     * Returns a merged LDAP vCard combined with a PHOTO element provided in specified vCard.
     *
     * @param username User whose vCard this is.
     * @param mergeVCard vCard element that we are merging PHOTO element from into the LDAP vCard.
     * @return vCard element after merging in PHOTO element to LDAP data.
     */
    private Element getMergedVCard(String username, Element mergeVCard) {
        // Un-escape username.
        username = JID.unescapeNode(username);
        Map<String, String> map = getLdapAttributes(username);
        Log.debug("LdapVCardProvider: Retrieving LDAP mapped vcard for " + username);
265 266 267
        if (map.isEmpty()) {
            return null;
        }
268 269 270 271 272
        Element vcard = new VCard(template).getVCard(map);
        if (mergeVCard == null) {
            // No vcard passed in?  Hrm.  Fine, return LDAP vcard.
            return vcard;
        }
273 274 275 276
        if (mergeVCard.element("PHOTO") == null) {
            // Merged vcard has no photo element, return LDAP vcard as is.
            return vcard;
        }
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
        Element photoElement = mergeVCard.element("PHOTO").createCopy();
        if (photoElement == null || photoElement.element("BINVAL") == null || photoElement.element("BINVAL").getText().matches("\\s*")) {
            // We were passed something null or empty, so lets just return the LDAP based vcard.
            return vcard;
        }
        // Now we need to check that the LDAP vcard doesn't have a PHOTO element that's filled in.
        if (!((vcard.element("PHOTO") == null || vcard.element("PHOTO").element("BINVAL") == null || vcard.element("PHOTO").element("BINVAL").getText().matches("\\s*")))) {
            // Hrm, it does, return the original vcard;
            return vcard;
        }
        Log.debug("LdapVCardProvider: Merging avatar element from passed vcard");
        Element currentElement = vcard.element("PHOTO");
        if (currentElement != null) {
            vcard.remove(currentElement);
        }
        vcard.add(photoElement);
        return vcard;
    }

296 297 298 299 300 301 302 303 304 305 306 307 308
    /**
     * Loads the avatar element from the user's DB stored vcard.
     *
     * @param username User whose vcard/avatar element we are loading.
     * @return Loaded avatar element or null if not found.
     */
    private Element loadAvatarFromDatabase(String username) {
        Element vcardElement = defaultProvider.loadVCard(username);
        Element avatarElement = null;
        if (vcardElement != null && vcardElement.element("PHOTO") != null) {
            avatarElement = vcardElement.element("PHOTO").createCopy();
        }
        return avatarElement;
309 310
    }

311 312 313 314 315 316 317
    /**
     * Handles when a user creates a new vcard.
     *
     * @param username User that created a new vcard.
     * @param vCardElement vCard element containing the new vcard.
     * @throws UnsupportedOperationException If an invalid field is changed or we are in readonly mode.
     */
318
    @Override
319
    public Element createVCard(String username, Element vCardElement)
320 321
            throws UnsupportedOperationException, AlreadyExistsException {
        throw new UnsupportedOperationException("LdapVCardProvider: VCard changes not allowed.");
322 323
    }

324 325 326 327 328 329 330
    /**
     * Handles when a user updates their vcard.
     *
     * @param username User that updated their vcard.
     * @param vCardElement vCard element containing the new vcard.
     * @throws UnsupportedOperationException If an invalid field is changed or we are in readonly mode.
     */
331
    @Override
332
    public Element updateVCard(String username, Element vCardElement) throws UnsupportedOperationException {
333 334
        if (dbStorageEnabled && defaultProvider != null) {
            if (isValidVCardChange(username, vCardElement)) {
335
                Element mergedVCard = getMergedVCard(username, vCardElement);
336
                try {
337
                    defaultProvider.updateVCard(username, mergedVCard);
338 339
                } catch (NotFoundException e) {
                    try {
340
                        defaultProvider.createVCard(username, mergedVCard);
341 342 343 344
                    } catch (AlreadyExistsException e1) {
                        // Ignore
                    }
                }
345
                return mergedVCard;
346 347 348 349 350 351 352 353
            }
            else {
                throw new UnsupportedOperationException("LdapVCardProvider: Invalid vcard changes.");
            }
        }
        else {
            throw new UnsupportedOperationException("LdapVCardProvider: VCard changes not allowed.");
        }
354 355
    }

356 357 358 359 360 361
    /**
     * Handles when a user deletes their vcard.
     *
     * @param username User that deketed their vcard.
     * @throws UnsupportedOperationException If an invalid field is changed or we are in readonly mode.
     */
362
    @Override
363
    public void deleteVCard(String username) throws UnsupportedOperationException {
364
        throw new UnsupportedOperationException("LdapVCardProvider: Attempted to delete vcard in read-only mode.");
365 366 367 368 369 370
    }

    /**
     * Returns true or false if the change to the existing vcard is valid (only to PHOTO element)
     *
     * @param username User who's LDAP-based vcard we will compare with.
371
     * @param newvCard New vCard Element we will compare against.
372 373
     * @return True or false if the changes made were valid (only to PHOTO element)
     */
374 375
    private Boolean isValidVCardChange(String username, Element newvCard) {
        if (newvCard == null) {
376
            // Well if there's nothing to change, of course it's valid.
377
            Log.debug("LdapVCardProvider: No new vcard provided (no changes), accepting.");
378 379 380 381 382 383
            return true;
        }
        // Un-escape username.
        username = JID.unescapeNode(username);
        Map<String, String> map = getLdapAttributes(username);
        // Retrieve LDAP created vcard for comparison
384 385
        Element ldapvCard = new VCard(template).getVCard(map);
        if (ldapvCard == null) {
386
            // This person has no vcard at all, may not change it!
387
            Log.debug("LdapVCardProvider: User has no LDAP vcard, nothing they can change, rejecting.");
388 389
            return false;
        }
390 391 392 393 394 395 396 397
        // If the LDAP vcard has a non-empty PHOTO element set, then there is literally no way this will be accepted.
        Element ldapPhotoElem = ldapvCard.element("PHOTO");
        if (ldapPhotoElem != null) {
            Element ldapBinvalElem = ldapPhotoElem.element("BINVAL");
            if (ldapBinvalElem != null && !ldapBinvalElem.getTextTrim().matches("\\s*")) {
                // LDAP is providing a valid PHOTO element, byebye!
                Log.debug("LdapVCardProvider: LDAP has a PHOTO element set, no way to override, rejecting.");
                return false;
398 399
            }
        }
400 401 402 403 404 405 406 407
        // Retrieve database vcard, if it exists
        Element dbvCard = defaultProvider.loadVCard(username);
        if (dbvCard != null) {
            Element dbPhotoElem = dbvCard.element("PHOTO");
            if (dbPhotoElem == null) {
                // DB has no photo, lets accept what we got.
                Log.debug("LdapVCardProvider: Database has no PHOTO element, accepting update.");
                return true;
408
            }
409 410
            else {
                Element newPhotoElem = newvCard.element("PHOTO");
411 412 413 414
                if (newPhotoElem == null) {
                    Log.debug("LdapVCardProvider: Photo element was removed, accepting update.");
                    return true;
                }
415 416 417 418 419
                // Note: NodeComparator never seems to consider these equal, even if they are?
                if (!dbPhotoElem.asXML().equals(newPhotoElem.asXML())) {
                    // Photo element was changed.  Ignore all other changes and accept this.
                    Log.debug("LdapVCardProvider: PHOTO element changed, accepting update.");
                    return true;
420 421 422
                }
            }
        }
423 424 425 426 427
        else {
            // No vcard exists in database
            Log.debug("LdapVCardProvider: Database has no vCard stored, accepting update.");
            return true;
        }
428 429 430 431 432 433
        // Ok, either something bad changed or nothing changed.  Either way, user either:
        // 1. should not have tried to change something 'readonly'
        // 2. shouldn't have bothered submitting no changes
        // So we'll consider this a bad return.
        Log.debug("LdapVCardProvider: PHOTO element didn't change, no reason to accept this, rejecting.");
        return false;
434 435
    }

436

437
    @Override
438 439 440 441
    public boolean isReadOnly() {
        return !dbStorageEnabled;
    }

442
    @Override
443
    public void propertySet(String property, Map params) {
444 445
        if ("ldap.override.avatar".equals(property)) {
            dbStorageEnabled = Boolean.parseBoolean((String)params.get("value"));
446
        }
447 448 449 450 451
        else if ("ldap.vcard-mapping".equals(property)) {
            initTemplate();
            // Reset cache of vCards
            VCardManager.getInstance().reset();
        }
452 453
    }

454
    @Override
455
    public void propertyDeleted(String property, Map params) {
456 457
        if ("ldap.override.avatar".equals(property)) {
            dbStorageEnabled = false;
458
        }
459 460
    }

461
    @Override
462
    public void xmlPropertySet(String property, Map params) {
463
        //Ignore
464 465
    }

466
    @Override
467 468 469 470
    public void xmlPropertyDeleted(String property, Map params) {
        //Ignore
    }

471 472 473 474 475 476 477 478 479 480 481 482 483 484 485
    /**
     * Class to hold a <code>Document</code> representation of a vcard mapping
     * and unique attribute placeholders. Used by <code>VCard</code> to apply
     * a <code>Map</code> of ldap attributes to ldap values via
     * <code>MessageFormat</code>
     *
     * @author rkelly
     */
    private static class VCardTemplate {

        private Document document;

        private String[] attributes;

        public VCardTemplate(Document document) {
486
            Set<String> set = new HashSet<>();
487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504
            this.document = document;
            treeWalk(this.document.getRootElement(), set);
            attributes = set.toArray(new String[set.size()]);
        }

        public String[] getAttributes() {
            return attributes;
        }

        public Document getDocument() {
            return document;
        }

        private void treeWalk(Element element, Set<String> set) {
            for (int i = 0, size = element.nodeCount(); i < size; i++) {
                Node node = element.node(i);
                if (node instanceof Element) {
                    Element emement = (Element) node;
505 506 507

                    StringTokenizer st = new StringTokenizer(emement.getTextTrim(), ", //{}");
                    while (st.hasMoreTokens()) {
508
                        // Remove enclosing {}
509
                        String string = st.nextToken().replaceAll("(\\{)([\\d\\D&&[^}]]+)(})", "$2");
510 511
                        Log.debug("VCardTemplate: found attribute " + string);
                        set.add(string);
512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540
                    }
                    treeWalk(emement, set);
                }
            }
        }
    }

    /**
     * vCard class that converts vcard data using a template.
     */
    private static class VCard {

        private VCardTemplate template;

        public VCard(VCardTemplate template) {
            this.template = template;
        }

        public Element getVCard(Map<String, String> map) {
            Document document = (Document) template.getDocument().clone();
            Element element = document.getRootElement();
            return treeWalk(element, map);
        }

        private Element treeWalk(Element element, Map<String, String> map) {
            for (int i = 0, size = element.nodeCount(); i < size; i++) {
                Node node = element.node(i);
                if (node instanceof Element) {
                    Element emement = (Element) node;
541

542 543 544 545 546 547 548 549 550 551
                    String elementText = emement.getTextTrim();
                    if (elementText != null && !"".equals(elementText)) {
                        String format = emement.getStringValue();

                        StringTokenizer st = new StringTokenizer(elementText, ", //{}");
                        while (st.hasMoreTokens()) {
                            // Remove enclosing {}
                            String field = st.nextToken();
                            String attrib = field.replaceAll("(\\{)(" + field + ")(})", "$2");
                            String value = map.get(attrib);
552
                            format = format.replaceFirst("(\\{)(" + field + ")(})", Matcher.quoteReplacement(value));
553 554
                        }
                        emement.setText(format);
555 556 557 558 559 560 561
                    }
                    treeWalk(emement, map);
                }
            }
            return element;
        }
    }
562
}