001/*
002 *  Licensed to the Apache Software Foundation (ASF) under one
003 *  or more contributor license agreements.  See the NOTICE file
004 *  distributed with this work for additional information
005 *  regarding copyright ownership.  The ASF licenses this file
006 *  to you under the Apache License, Version 2.0 (the
007 *  "License"); you may not use this file except in compliance
008 *  with the License.  You may obtain a copy of the License at
009 *
010 *        http://www.apache.org/licenses/LICENSE-2.0
011 *
012 *  Unless required by applicable law or agreed to in writing,
013 *  software distributed under the License is distributed on an
014 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 *  KIND, either express or implied.  See the License for the
016 *  specific language governing permissions and limitations
017 *  under the License.
018 */
019package org.apache.isis.extensions.shirorealmldap.realm.impl;
020
021import java.util.Collection;
022import java.util.Collections;
023import java.util.HashSet;
024import java.util.Map;
025import java.util.Set;
026
027import javax.naming.AuthenticationException;
028import javax.naming.NamingEnumeration;
029import javax.naming.NamingException;
030import javax.naming.directory.Attribute;
031import javax.naming.directory.SearchControls;
032import javax.naming.directory.SearchResult;
033import javax.naming.ldap.LdapContext;
034
035import org.apache.shiro.authz.AuthorizationInfo;
036import org.apache.shiro.authz.SimpleAuthorizationInfo;
037import org.apache.shiro.config.Ini;
038import org.apache.shiro.realm.ldap.DefaultLdapRealm;
039import org.apache.shiro.realm.ldap.LdapContextFactory;
040import org.apache.shiro.realm.ldap.LdapUtils;
041import org.apache.shiro.subject.PrincipalCollection;
042import org.apache.shiro.util.StringUtils;
043
044import org.apache.isis.commons.internal.collections._Maps;
045import org.apache.isis.commons.internal.collections._Sets;
046import org.apache.isis.security.shiro.permrolemapper.PermissionToRoleMapper;
047import org.apache.isis.security.shiro.permrolemapper.PermissionToRoleMapperFromIni;
048import org.apache.isis.security.shiro.permrolemapper.PermissionToRoleMapperFromString;
049
050import static org.apache.isis.commons.internal.base._NullSafe.stream;
051
052/**
053 * Implementation of {@link org.apache.shiro.realm.ldap.JndiLdapRealm} that also
054 * returns each user's groups.
055 * <p/>
056 * <p>
057 * Sample config for <tt>shiro.ini</tt>:
058 * <p/>
059 * <pre>
060 * contextFactory = org.apache.isis.extensions.shirorealmldap.realm.impl.IsisLdapContextFactory
061 * contextFactory.url = ldap://localhost:10389
062 * contextFactory.authenticationMechanism = CRAM-MD5
063 * contextFactory.systemAuthenticationMechanism = simple
064 * contextFactory.systemUsername = uid=admin,ou=system
065 * contextFactory.systemPassword = secret
066 *
067 * ldapRealm = org.apache.isis.extensions.shirorealmldap.realm.impl.IsisLdapRealm
068 * ldapRealm.contextFactory = $contextFactory
069 *
070 * ldapRealm.searchBase = ou=groups,o=mojo
071 * ldapRealm.groupObjectClass = groupOfUniqueNames
072 * ldapRealm.uniqueMemberAttribute = uniqueMember
073 * ldapRealm.uniqueMemberAttributeValueTemplate = uid={0}
074 *
075 * ldapRealm.searchUserBase = ou=users,o=mojo
076 * ldapRealm.userObjectClass=inetOrgPerson
077 * ldapRealm.groupExtractedAttribute=street,country
078 * ldapRealm.userExtractedAttribute=street,country
079 * ldapRealm.permissionByGroupAttribute=attribute:Folder.{street}:Read,attribute:Portfolio.{country}
080 * ldapRealm.permissionByUserAttribute=attribute:Folder.{street}:Read,attribute:Portfolio.{country}
081 *
082 * # optional mapping from physical groups to logical application roles
083 * ldapRealm.rolesByGroup = \
084 *    LDN_USERS: user_role,\
085 *    NYK_USERS: user_role,\
086 *    HKG_USERS: user_role,\
087 *    GLOBAL_ADMIN: admin_role,\
088 *    DEMOS: self-install_role
089 *
090 * securityManager.realms = $ldapRealm
091 * </pre>
092 * <p/>
093 * <p>
094 * The permissions for each role can be specified using the
095 * {@link #setResourcePath(String)} to an 'ini' file with a [roles] section, eg:
096 * <p/>
097 * <pre>
098 * ldapRealm.resourcePath=classpath:webapp/myroles.ini
099 * </pre>
100 * <p/>
101 * <p>
102 * where <tt>myroles.ini</tt> is in <tt>src/main/resources/webapp</tt>, and takes the form:
103 * <p/>
104 * <pre>
105 * [roles]
106 * user_role = *:ToDoItemsJdo:*:*,\
107 *             *:ToDoItem:*:*
108 * self-install_role = *:ToDoItemsFixturesService:install:*
109 * admin_role = *
110 * </pre>
111 * <p/>
112 * <p>
113 * This 'ini' file can then be referenced by other realms (if multiple realm are configured
114 * with the Shiro security manager).
115 * <p/>
116 * <p>
117 * Alternatively, permissions can be set directly using {@link #setPermissionsByRole(String)},
118 * where the string is the same information, formatted thus:
119 * <p/>
120 * <pre>
121 * ldapRealm.permissionsByRole=\
122 *    user_role = *:ToDoItemsJdo:*:*,\
123 *                *:ToDoItem:*:*; \
124 *    self-install_role = *:ToDoItemsFixturesService:install:* ; \
125 *    admin_role = *
126 * </pre>
127 * <p/>
128 * <p>
129 * Alternatively, permissions can be extracted from the base itself with the parameter searchUserBase,
130 * the attribute list as userExtractedAttribute and the permission url as permissionByUserAttribute.
131 * The idea is to extract attribute from the user or the group of the user and map directly to permission rule in
132 * replacing the string {attribute} by the extracted attribute (can me multiple).
133 * See the sample for group and user attribute and mapping.
134 * <p/>
135 * </p>
136 */
137public class IsisLdapRealm extends DefaultLdapRealm {
138
139    private static final String UNIQUEMEMBER_SUBSTITUTION_TOKEN = "{0}";
140    private static final SearchControls SUBTREE_SCOPE = new SearchControls();
141
142    static {
143        SUBTREE_SCOPE.setSearchScope(SearchControls.SUBTREE_SCOPE);
144    }
145
146    private String searchBase;
147    private String groupObjectClass;
148    private String uniqueMemberAttribute = "uniqueMember";
149    private String uniqueMemberAttributeValuePrefix;
150    private String uniqueMemberAttributeValueSuffix;
151
152    /**
153     * For Group Extracted attribute name with mapping name in parenthesis. Ex: street,country
154     */
155    protected Set<String> groupExtractedAttribute = _Sets.newConcurrentHashSet();
156
157    /**
158     * For User Extracted attribute name with mapping name in parenthesis. Ex: street,country
159     */
160    protected Set<String> userExtractedAttribute = _Sets.newConcurrentHashSet();
161
162    /**
163     * For Group Mapping of attributes. Ex:
164     * attribute:Folder.{street}:Read,attribute:Portfolio.{country}:*
165     */
166    protected Set<String> permissionByGroupAttribute = _Sets.newConcurrentHashSet();
167
168    /**
169     * For User Mapping of attributes. Ex:
170     * attribute:Folder.{street}:Read,attribute:Portfolio.{country}:*
171     */
172    protected Set<String> permissionByUserAttribute = _Sets.newConcurrentHashSet();
173
174    /**
175     * For search ldap on user
176     */
177    private String searchUserBase = "";
178
179    /**
180     * The object className as person
181     */
182    private String userObjectClass;
183
184    private final Map<String, String> rolesByGroup = _Maps.newLinkedHashMap();
185
186    private PermissionToRoleMapper permissionToRoleMapper;
187
188    /**
189     * cn attribute
190     */
191    private String cnAttribute = "cn";
192
193    public IsisLdapRealm() {
194        setGroupObjectClass("groupOfUniqueNames");
195        setUniqueMemberAttribute("uniqueMember");
196        setUniqueMemberAttributeValueTemplate("uid={0}");
197    }
198
199    /**
200     * Get groups from LDAP.
201     *
202     * @param principals         the principals of the Subject whose AuthenticationInfo should
203     *                           be queried from the LDAP server.
204     * @param ldapContextFactory factory used to retrieve LDAP connections.
205     * @return an {@link AuthorizationInfo} instance containing information
206     * retrieved from the LDAP server.
207     * @throws NamingException if any LDAP errors occur during the search.
208     */
209    @Override
210    protected AuthorizationInfo queryForAuthorizationInfo(final PrincipalCollection principals, final LdapContextFactory ldapContextFactory) throws NamingException {
211        final Set<String> roleNames = getRoles(principals, ldapContextFactory);
212        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(roleNames);
213        Set<String> stringPermissions = permsFor(roleNames);
214        final String username = (String) getAvailablePrincipal(principals);
215        final LdapContext finalLdapContext = ldapContextFactory.getSystemLdapContext();
216        stringPermissions.addAll(getPermissionForUser(username, finalLdapContext));
217        stringPermissions.addAll(getPermissionForRole(username, finalLdapContext));
218        simpleAuthorizationInfo.setStringPermissions(stringPermissions);
219
220        return simpleAuthorizationInfo;
221    }
222
223    private Set<String>
224    getPermissionForRole(String username, LdapContext ldapContext)
225            throws NamingException {
226        final Set<String> permissions = _Sets.newLinkedHashSet();
227
228        Set<String> groups = groupFor(username, ldapContext);
229        final NamingEnumeration<SearchResult> searchResultEnum = ldapContext.search(searchBase,
230                "objectClass=" + groupObjectClass, SUBTREE_SCOPE);
231        while (searchResultEnum.hasMore()) {
232            final SearchResult group = searchResultEnum.next();
233            if (memberOf(group, groups)) {
234                addPermIfFound(group, permissions, groupExtractedAttribute, permissionByGroupAttribute);
235            }
236        }
237        return permissions;
238    }
239
240    protected Set<String> groupFor(final String userName, final LdapContext ldapCtx)
241            throws NamingException {
242        final Set<String> roleNames = _Sets.newLinkedHashSet();
243        final NamingEnumeration<SearchResult> searchResultEnum = ldapCtx.search(searchBase,
244                "objectClass=" + groupObjectClass, SUBTREE_SCOPE);
245        while (searchResultEnum.hasMore()) {
246            final SearchResult group = searchResultEnum.next();
247            addRoleIfMember(userName, group, roleNames);
248        }
249        return roleNames;
250    }
251
252    protected boolean memberOf(SearchResult group, Set<String> groups) throws NamingException {
253        Attribute attribute = group.getAttributes().get(cnAttribute);
254        String groupName = attribute.get().toString();
255        return groups.contains(groupName);
256    }
257
258    private Collection<String> getPermissionForUser(
259            String username,
260            LdapContext ldapContextFactory) throws NamingException {
261
262        try {
263            return permUser(username, ldapContextFactory);
264        } catch (org.apache.shiro.authc.AuthenticationException ex) {
265            return Collections.emptySet();
266        }
267    }
268
269    private Collection<String> permUser(String username, LdapContext systemLdapCtx)
270            throws NamingException {
271        final Set<String> permissions = _Sets.newLinkedHashSet();
272        final NamingEnumeration<SearchResult> searchResultEnum = systemLdapCtx.search(
273                searchUserBase, "objectClass=" + userObjectClass, SUBTREE_SCOPE);
274        while (searchResultEnum.hasMore()) {
275            final SearchResult group = searchResultEnum.next();
276            addPermIfFound(group, permissions, userExtractedAttribute, permissionByUserAttribute);
277        }
278        return permissions;
279    }
280
281    private void addPermIfFound(
282            SearchResult group, Set<String> permissions,
283            Set<String> extractedAttributeP, Set<String> permissionByAttributeP)
284                    throws NamingException {
285        final NamingEnumeration<? extends Attribute> attributeEnum = group.getAttributes().getAll();
286        Map<String, Set<String>> keyValues = _Maps.newHashMap();
287        while (attributeEnum.hasMore()) {
288            final Attribute attr = attributeEnum.next();
289            if (extractedAttributeP.contains(attr.getID())) {
290                final NamingEnumeration<?> e = attr.getAll();
291                keyValues.put(attr.getID(), new HashSet<String>());
292                while (e.hasMore()) {
293                    String attrValue = e.next().toString();
294                    keyValues.get(attr.getID()).add(attrValue);
295                }
296            }
297        }
298        for (String permTempl : permissionByAttributeP) {
299            for (String key : keyValues.keySet()) {
300                if (permTempl.contains("{" + key + "}")) {
301                    for (String value : keyValues.get(key)) {
302                        permissions.add(permTempl.replaceAll("\\{" + key + "\\}", value));
303                    }
304                }
305            }
306        }
307    }
308
309    private Set<String> getRoles(final PrincipalCollection principals, final LdapContextFactory ldapContextFactory) throws NamingException {
310        final String username = (String) getAvailablePrincipal(principals);
311
312        LdapContext systemLdapCtx = null;
313        try {
314            systemLdapCtx = ldapContextFactory.getSystemLdapContext();
315            return rolesFor(username, systemLdapCtx);
316        } catch (AuthenticationException ex) {
317            // principal was not authenticated on LDAP
318            return Collections.emptySet();
319        } finally {
320            LdapUtils.closeContext(systemLdapCtx);
321        }
322    }
323
324    private Set<String> rolesFor(final String userName, final LdapContext ldapCtx) throws NamingException {
325        final Set<String> roleNames = _Sets.newLinkedHashSet();
326        final NamingEnumeration<SearchResult> searchResultEnum = ldapCtx.search(searchBase, "objectClass=" + groupObjectClass, SUBTREE_SCOPE);
327        while (searchResultEnum.hasMore()) {
328            final SearchResult group = searchResultEnum.next();
329            addRoleIfMember(userName, group, roleNames);
330        }
331        return roleNames;
332    }
333
334    private void addRoleIfMember(final String userName, final SearchResult group, final Set<String> roleNames) throws NamingException {
335        final NamingEnumeration<? extends Attribute> attributeEnum = group.getAttributes().getAll();
336        while (attributeEnum.hasMore()) {
337            final Attribute attr = attributeEnum.next();
338            if (!uniqueMemberAttribute.equalsIgnoreCase(attr.getID())) {
339                continue;
340            }
341            final NamingEnumeration<?> e = attr.getAll();
342            while (e.hasMore()) {
343                String attrValue = e.next().toString();
344                if ((uniqueMemberAttributeValuePrefix + userName + uniqueMemberAttributeValueSuffix).equals(attrValue)) {
345                    Attribute attribute = group.getAttributes().get("cn");
346                    String groupName = attribute.get().toString();
347                    String roleName = roleNameFor(groupName);
348                    if (roleName != null) {
349                        roleNames.add(roleName);
350                    }
351                    break;
352                }
353            }
354        }
355    }
356
357    private String roleNameFor(String groupName) {
358        return !rolesByGroup.isEmpty() ? rolesByGroup.get(groupName) : groupName;
359    }
360
361    private Set<String> permsFor(Set<String> roleNames) {
362        Set<String> perms = _Sets.newLinkedHashSet(); // preserve order
363        for (String role : roleNames) {
364            Set<String> permsForRole = getPermissionsByRole().get(role);
365            if (permsForRole != null) {
366                perms.addAll(permsForRole);
367            }
368        }
369        return perms;
370    }
371
372    public void setSearchBase(String searchBase) {
373        this.searchBase = searchBase;
374    }
375
376    public void setGroupObjectClass(String groupObjectClassAttribute) {
377        this.groupObjectClass = groupObjectClassAttribute;
378    }
379
380    public void setUniqueMemberAttribute(String uniqueMemberAttribute) {
381        this.uniqueMemberAttribute = uniqueMemberAttribute;
382    }
383
384    public void setUniqueMemberAttributeValueTemplate(String template) {
385        if (!StringUtils.hasText(template)) {
386            String msg = "User DN template cannot be null or empty.";
387            throw new IllegalArgumentException(msg);
388        }
389        int index = template.indexOf(UNIQUEMEMBER_SUBSTITUTION_TOKEN);
390        if (index < 0) {
391            String msg = "UniqueMember attribute value template must contain the '" +
392                    UNIQUEMEMBER_SUBSTITUTION_TOKEN + "' replacement token to understand how to " +
393                    "parse the group members.";
394            throw new IllegalArgumentException(msg);
395        }
396        String prefix = template.substring(0, index);
397        String suffix = template.substring(prefix.length() + UNIQUEMEMBER_SUBSTITUTION_TOKEN.length());
398        this.uniqueMemberAttributeValuePrefix = prefix;
399        this.uniqueMemberAttributeValueSuffix = suffix;
400    }
401
402    public void setRolesByGroup(Map<String, String> rolesByGroup) {
403        this.rolesByGroup.putAll(rolesByGroup);
404    }
405
406    /**
407     * Retrieves permissions by role set using either
408     * {@link #setPermissionsByRole(String)} or {@link #setResourcePath(String)}.
409     */
410    private Map<String, Set<String>> getPermissionsByRole() {
411        if (permissionToRoleMapper == null) {
412            throw new IllegalStateException("Permissions by role not yet set.");
413        }
414        return permissionToRoleMapper.getPermissionsByRole();
415    }
416
417    /**
418     * <pre>
419     * ldapRealm.resourcePath=classpath:webapp/myroles.ini
420     * </pre>
421     * <p/>
422     * <p/>
423     * where <tt>myroles.ini</tt> is in <tt>src/main/resources/webapp</tt>, and takes the form:
424     * <p/>
425     * <pre>
426     * [roles]
427     * user_role = *:ToDoItemsJdo:*:*,\
428     *             *:ToDoItem:*:*
429     * self-install_role = *:ToDoItemsFixturesService:install:*
430     * admin_role = *
431     * </pre>
432     * <p/>
433     * <p/>
434     * This 'ini' file can then be referenced by other realms (if multiple realm are configured
435     * with the Shiro security manager).
436     *
437     * @see #setResourcePath(String)
438     */
439    public void setResourcePath(String resourcePath) {
440        if (permissionToRoleMapper != null) {
441            throw new IllegalStateException("Permissions already set, " + permissionToRoleMapper.getClass().getName());
442        }
443        final Ini ini = Ini.fromResourcePath(resourcePath);
444        this.permissionToRoleMapper = new PermissionToRoleMapperFromIni(ini);
445    }
446
447    /**
448     * Specify permissions for each role using a formatted string.
449     * <p/>
450     * <pre>
451     * ldapRealm.permissionsByRole=\
452     *    user_role = *:ToDoItemsJdo:*:*,\
453     *                *:ToDoItem:*:*; \
454     *    self-install_role = *:ToDoItemsFixturesService:install:* ; \
455     *    admin_role = *
456     * </pre>
457     *
458     * @see #setResourcePath(String)
459     */
460    @Deprecated
461    public void setPermissionsByRole(String permissionsByRoleStr) {
462        if (permissionToRoleMapper != null) {
463            throw new IllegalStateException("Permissions already set, " + permissionToRoleMapper.getClass().getName());
464        }
465        this.permissionToRoleMapper = new PermissionToRoleMapperFromString(permissionsByRoleStr);
466    }
467
468    public void setPermissionByUserAttribute(String permissionByUserAttr) {
469        String[] list = permissionByUserAttr.split(",");
470        stream(list).forEach(this.permissionByUserAttribute::add);
471    }
472
473    public void setPermissionByGroupAttribute(String permissionByGroupAttribute) {
474        String[] list = permissionByGroupAttribute.split(",");
475        stream(list).forEach(this.permissionByGroupAttribute::add);
476    }
477
478    public void setUserExtractedAttribute(String userExtractedAttribute) {
479        String[] list = userExtractedAttribute.split(",");
480        stream(list).forEach(this.userExtractedAttribute::add);
481    }
482
483    public void setGroupExtractedAttribute(String groupExtractedAttribute) {
484        String[] list = groupExtractedAttribute.split(",");
485        stream(list).forEach(this.groupExtractedAttribute::add);
486    }
487
488    public void setSearchUserBase(String searchUserBase) {
489        this.searchUserBase = searchUserBase;
490    }
491
492    public void setUserObjectClass(String userObjectClass) {
493        this.userObjectClass = userObjectClass;
494    }
495
496    public void setCnAttribute(String cnAttribute) {
497        this.cnAttribute = cnAttribute;
498    }
499
500}