/*
* $Id$
*
* Copyright 2007 - 2014 University of Dundee. All rights reserved.
* Use is subject to license terms supplied in LICENSE.txt
*/
package ome.logic;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.naming.AuthenticationException;
import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.ldap.InitialLdapContext;
import ome.annotations.RolesAllowed;
import ome.api.ILdap;
import ome.api.ServiceInterface;
import ome.conditions.ApiUsageException;
import ome.conditions.SecurityViolation;
import ome.conditions.ValidationException;
import ome.model.internal.Permissions;
import ome.model.meta.Experimenter;
import ome.model.meta.ExperimenterGroup;
import ome.model.meta.GroupExperimenterMap;
import ome.parameters.Parameters;
import ome.security.SecuritySystem;
import ome.security.auth.AttributeNewUserGroupBean;
import ome.security.auth.AttributeSet;
import ome.security.auth.GroupAttributeMapper;
import ome.security.auth.GroupContextMapper;
import ome.security.auth.LdapConfig;
import ome.security.auth.NewUserGroupBean;
import ome.security.auth.NewUserGroupOwnerBean;
import ome.security.auth.OrgUnitNewUserGroupBean;
import ome.security.auth.PersonContextMapper;
import ome.security.auth.QueryNewUserGroupBean;
import ome.security.auth.RoleProvider;
import ome.system.OmeroContext;
import ome.system.Roles;
import ome.util.SqlAction;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.ldap.core.ContextSource;
import org.springframework.ldap.core.DistinguishedName;
import org.springframework.ldap.core.LdapOperations;
import org.springframework.ldap.filter.AndFilter;
import org.springframework.ldap.filter.EqualsFilter;
import org.springframework.ldap.filter.Filter;
import org.springframework.transaction.annotation.Transactional;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
/**
* Provides methods for administering user accounts, passwords, as well as
* methods which require special privileges.
*
* Developer note: As can be expected, to perform these privileged the Admin
* service has access to several resources that should not be generally used
* while developing services. Misuse could circumvent security or auditing.
*
* @author Aleksandra Tarkowska, A.Tarkowska@dundee.ac.uk
* @see SecuritySystem
* @see Permissions
* @since 3.0-M3
*/
@Transactional(readOnly = true)
public class LdapImpl extends AbstractLevel2Service implements ILdap,
ApplicationContextAware {
private final SqlAction sql;
private final RoleProvider provider;
private final ContextSource ctx;
private final LdapOperations ldap;
private final LdapConfig config;
private final Roles roles;
private OmeroContext appContext;
public LdapImpl(ContextSource ctx, LdapOperations ldap, Roles roles,
LdapConfig config, RoleProvider roleProvider, SqlAction sql) {
this.ctx = ctx;
this.sql = sql;
this.ldap = ldap;
this.roles = roles;
this.config = config;
this.provider = roleProvider;
}
public void setApplicationContext(ApplicationContext arg0)
throws BeansException {
appContext = (OmeroContext) arg0;
}
public Class<? extends ServiceInterface> getServiceInterface() {
return ILdap.class;
}
// ~ System-only interface methods
// =========================================================================
@SuppressWarnings("unchecked")
@RolesAllowed("system")
public List<Experimenter> searchAll() {
return ldap.search(DistinguishedName.EMPTY_PATH, config.getUserFilter()
.encode(), getPersonContextMapper());
}
@SuppressWarnings("unchecked")
@RolesAllowed("system")
public List<Experimenter> searchByAttribute(String dns, String attr,
String value) {
DistinguishedName dn;
if (dns == null) {
dn = DistinguishedName.EMPTY_PATH;
} else {
dn = new DistinguishedName(dns);
}
if (attr != null && !attr.equals("") && value != null
&& !value.equals("")) {
AndFilter filter = new AndFilter();
filter.and(config.getUserFilter());
filter.and(new EqualsFilter(attr, value));
return ldap.search(dn, filter.encode(), getPersonContextMapper());
} else {
return Collections.emptyList();
}
}
@RolesAllowed("system")
public Experimenter searchByDN(String dns) {
DistinguishedName dn = new DistinguishedName(dns);
return (Experimenter) ldap.lookup(dn, getPersonContextMapper());
}
@RolesAllowed("system")
public String findDN(String username) {
PersonContextMapper mapper = getPersonContextMapper();
return mapper.getDn(findExperimenter(username));
}
@RolesAllowed("system")
public String findGroupDN(String groupname) {
GroupContextMapper mapper = getGroupContextMapper();
return mapper.getDn(findGroup(groupname));
}
@RolesAllowed("system")
public Experimenter findExperimenter(String username) {
PersonContextMapper mapper = getPersonContextMapper();
return mapUserName(username, mapper);
}
@RolesAllowed("system")
public ExperimenterGroup findGroup(String groupname) {
GroupContextMapper mapper = getGroupContextMapper();
return mapGroupName(groupname, mapper);
}
/**
* Mapping a username to an {@link Experimenter}. This handles checking the
* username for case exactness. This should be done at the LDAP level, but
* Apache DS (the testing framework used) does not yet support
* :caseExactMatch:. When it does, the check here can be removed.
*
* @param username a user's name
* @param mapper the map to contexts
* @return a non-{@code null} Experimenter
* @see <a href="http://trac.openmicroscopy.org/ome/ticket/2557">Trac ticket #2557</a>
*/
@SuppressWarnings("unchecked")
private Experimenter mapUserName(String username, PersonContextMapper mapper) {
Filter filter = config.usernameFilter(username);
List<Experimenter> p = ldap.search("", filter.encode(),
mapper.getControls(), mapper);
if (p.size() == 1 && p.get(0) != null) {
Experimenter e = p.get(0);
if (provider.isIgnoreCaseLookup()) {
if (e.getOmeName().equalsIgnoreCase(username)) {
return p.get(0);
}
} else {
if (e.getOmeName().equals(username)) {
return p.get(0);
}
}
}
throw new ApiUsageException(
"Cannot find unique user DistinguishedName: found=" + p.size());
}
@SuppressWarnings("unchecked")
private ExperimenterGroup mapGroupName(String groupname,
GroupContextMapper mapper) {
Filter filter = config.groupnameFilter(groupname);
List<ExperimenterGroup> g = ldap.search("", filter.encode(),
mapper.getControls(), mapper);
if (g.size() == 1 && g.get(0) != null) {
ExperimenterGroup grp = g.get(0);
if (grp.getName().equals(groupname)) {
return g.get(0);
}
}
throw new ApiUsageException(
"Cannot find unique group DistinguishedName: found=" + g.size());
}
@RolesAllowed("system")
@SuppressWarnings("unchecked")
public List<String> searchDnInGroups(String attr, String value) {
if (attr != null && !attr.equals("") && value != null
&& !value.equals("")) {
AndFilter filter = new AndFilter();
filter.and(config.getGroupFilter());
filter.and(new EqualsFilter(attr, value));
return ldap.search("", filter.encode(), new GroupAttributeMapper(
config));
} else {
return Collections.emptyList();
}
}
@RolesAllowed("system")
@SuppressWarnings("unchecked")
public List<Experimenter> searchByAttributes(String dn,
String[] attributes, String[] values) {
if (attributes.length != values.length) {
return Collections.emptyList();
}
AndFilter filter = new AndFilter();
for (int i = 0; i < attributes.length; i++) {
filter.and(new EqualsFilter(attributes[i], values[i]));
}
return ldap.search(new DistinguishedName(dn), filter.encode(),
getPersonContextMapper());
}
@RolesAllowed("system")
@Transactional(readOnly = false)
@Deprecated
public void setDN(Long experimenterID, String dn) {
Experimenter experimenter = iQuery.get(Experimenter.class,
experimenterID);
experimenter.setLdap(StringUtils.isNotBlank(dn));
iUpdate.saveObject(experimenter);
}
@RolesAllowed("system")
public boolean getSetting() {
return config.isEnabled();
}
// ~ System-only interface methods
// =========================================================================
//
// WRITES
//
public void synchronizeLdapUser(String username) {
if (!config.isSyncOnLogin()) {
if (getBeanHelper().getLogger().isTraceEnabled()) {
getBeanHelper().getLogger().trace("sync_on_login=false");
}
return;
}
Experimenter omeExp = iQuery.findByString(Experimenter.class,
"omeName", username);
Experimenter ldapExp = findExperimenter(username);
String ldapDN = getPersonContextMapper().getDn(ldapExp);
DistinguishedName dn = new DistinguishedName(ldapDN);
GroupLoader loader = new GroupLoader(username, dn);
List<Long> ldapGroups = loader.getGroups();
List<Long> ownedGroups = loader.getOwnedGroups();
List<Object[]> currentGroups = iQuery
.projection(
"select g.id, g.ldap from ExperimenterGroup g "
+ "join g.groupExperimenterMap m join m.child e where e.id = :id",
new Parameters().addId(omeExp.getId()));
final Set<Long> currentLdapGroups = new HashSet<Long>();
for (Object[] objs : currentGroups) {
Long id = (Long) objs[0];
Boolean isLdap = (Boolean) objs[1];
if (isLdap) {
currentLdapGroups.add(id);
}
}
// All the currentLdapGroups not in ldapGroups should be removed.
modifyGroups(omeExp, currentLdapGroups, ldapGroups, false);
// All the ldapGroups not in currentLdapGroups should be added.
modifyGroups(omeExp, ldapGroups, currentLdapGroups, true);
// Then for all remaining groups, set the ownership flag based
// on ownedGroups
for (Long ldapGroupId : ldapGroups) {
provider.setGroupOwner(omeExp,
new ExperimenterGroup(ldapGroupId, false),
ownedGroups.contains(ldapGroupId));
}
List<String> fields = Arrays.asList(Experimenter.FIRSTNAME,
Experimenter.MIDDLENAME, Experimenter.LASTNAME,
Experimenter.EMAIL, Experimenter.INSTITUTION);
for (String field : fields) {
String fieldname = field.substring(field.indexOf("_") + 1);
String ome = (String) omeExp.retrieve(field);
String ldap = (String) ldapExp.retrieve(field);
if (ome == null) {
if (ldap != null) {
getBeanHelper().getLogger().info(
String.format("Nulling %s for %s, was:", fieldname,
username, ome));
omeExp.putAt(field, ldap);
}
} else if (!ome.equals(ldap)) {
getBeanHelper().getLogger().info(
String.format("Changing %s for %s: %s -> %s",
fieldname, username, ome, ldap));
omeExp.putAt(field, ldap);
}
}
iUpdate.flush();
}
/**
* The IDs in "minus" will be removed from the IDs in "base" and then
* the operation chosen by "add" will be run on them. This method
* ignores all methods known by Roles.
*
* @param e
* @param base
* @param minus
* @param add
*/
private void modifyGroups(Experimenter e, Collection<Long> base,
Collection<Long> minus, boolean add) {
final Logger log = getBeanHelper().getLogger();
Set<Long> ids = new HashSet<Long>(base);
ids.removeAll(minus);
// Take no actions on system/user group.
ids.remove(roles.getSystemGroupId());
ids.remove(roles.getUserGroupId());
if (ids.size() > 0) {
log.info(String.format("%s groups for %s: %s", add ? "Adding"
: "Removing", e.getOmeName(), ids));
Set<ExperimenterGroup> grps = new HashSet<ExperimenterGroup>();
for (Long id : ids) {
grps.add(new ExperimenterGroup(id, false));
}
if (add) {
provider.addGroups(e, grps.toArray(new ExperimenterGroup[0]));
} else {
provider.removeGroups(e, grps.toArray(new ExperimenterGroup[0]));
}
if (add) {
// If we have just added groups, then it's possible that
// the "user" group is at the front of the list, in which
// case we should assign another specific group.
e = iQuery.get(Experimenter.class, e.getId());
log.debug("sizeOfGroupExperimenterMap="
+ e.sizeOfGroupExperimenterMap());
if (e.sizeOfGroupExperimenterMap() > 1) {
GroupExperimenterMap primary = e.getGroupExperimenterMap(0);
GroupExperimenterMap next = e.getGroupExperimenterMap(1);
log.debug("primary=" + primary.parent().getId());
log.debug("next=" + next.parent().getId());
if (primary.parent().getId().equals(roles.getUserGroupId())) {
log.debug("calling setDefaultGroup");
provider.setDefaultGroup(e, next.parent());
}
}
}
}
}
/**
* Creates an {@link Experimenter} based on the supplied LDAP username.
* Doesn't validate the user's password and can be only executed by admin
* users.
*
* @param username
* The user's LDAP username.
* @param password
* The user's LDAP password, not null.
* @return true if a user is created
*/
@Deprecated
@RolesAllowed("system")
@Transactional(readOnly = false)
public boolean createUserFromLdap(String username, String password) {
return null != createUser(username, password, true);
}
/**
* Creates an {@link Experimenter} based on the supplied LDAP username.
* Doesn't validate the user's password and can be only executed by admin
* users.
*
* @param username
* The user's LDAP username.
* @return The newly created {@link Experimenter} object.
*/
@RolesAllowed("system")
@Transactional(readOnly = false)
public Experimenter createUser(String username) {
return createUser(username, null, false);
}
/**
* Creates an {@link Experimenter} based on the supplied LDAP username.
* Enforces user password validation.
*
* @param username
* The user's LDAP username.
* @param password
* The user's LDAP password, not null.
* @return The newly created {@link Experimenter} object.
*/
public Experimenter createUser(String username, String password) {
return createUser(username, password, true);
}
/**
* Creates an {@link Experimenter} based on the supplied LDAP username.
* A boolean flag controls if password checks should be performed.
*
* @param username
* The user's LDAP username.
* @param password
* The user's password.
* @param checkPassword
* Flag indicating if password check should be performed.
* @return The newly created {@link Experimenter} object.
*/
public Experimenter createUser(String username, String password,
boolean checkPassword) {
if (provider.isIgnoreCaseLookup()) {
username = username.toLowerCase();
}
if (iQuery.findByString(Experimenter.class, "omeName", username) != null) {
throw new ValidationException("User already exists: " + username);
}
Experimenter exp = findExperimenter(username);
String ldapDn = getPersonContextMapper().getDn(exp);
DistinguishedName dn = new DistinguishedName(ldapDn);
boolean access = true;
if (checkPassword) {
access = validatePassword(dn.toString(), password);
}
if (access) {
GroupLoader loader = new GroupLoader(username, dn);
List<Long> groups = loader.getGroups();
List<Long> ownerOfGroups = loader.getOwnedGroups();
if (groups.size() == 0) {
throw new ValidationException("No group found for: " + dn);
}
// Create the unloaded groups for creation
Long gid = groups.remove(0);
ExperimenterGroup grp1 = new ExperimenterGroup(gid, false);
Set<Long> otherGroupIds = new HashSet<Long>(groups);
ExperimenterGroup grpOther[] = new ExperimenterGroup[otherGroupIds
.size() + 1];
int count = 0;
for (Long id : otherGroupIds) {
grpOther[count++] = new ExperimenterGroup(id, false);
}
grpOther[count] = new ExperimenterGroup(roles.getUserGroupId(),
false);
long uid = provider.createExperimenter(exp, grp1, grpOther);
for (Long toBeOwned : ownerOfGroups) {
provider.setGroupOwner(
new Experimenter(uid, false),
new ExperimenterGroup(toBeOwned, false), true);
}
return iQuery.get(Experimenter.class, uid);
} else {
return null;
}
}
static private final Pattern p = Pattern.compile(
"^:(ou|" +
"attribute|filtered_attribute|" +
"dn_attribute|filtered_dn_attribute|" +
"query|bean):(.*)$");
@Deprecated // Use GroupLoader to handle ownership
public List<Long> loadLdapGroups(String username, DistinguishedName dn) {
return new GroupLoader(username, dn).getGroups();
}
/**
* Data class which stores the state of the {@link NewUserGroupBean} and
* {@link NewUserGroupOwnerBean} operations.
*/
public class GroupLoader {
final String username;
final DistinguishedName dn;
final String grpSpec;
final List<Long> groups;
final List<Long> ownedGroups;
final NewUserGroupBean bean;
final AttributeSet attrSet;
/**
* Return the found groups for the given username.
* @return Never null.
*/
List<Long> getGroups() {
return groups;
}
/**
* Return the found owned groups for the given username.
* @return Never null.
*/
public List<Long> getOwnedGroups() {
return ownedGroups;
}
GroupLoader(String username, DistinguishedName dn) {
this.username = username;
this.dn = dn;
grpSpec = config.getNewUserGroup();
groups = new ArrayList<Long>();
ownedGroups = new ArrayList<Long>();
if (!grpSpec.startsWith(":")) {
// The default case is the original logic: use the spec as name
// No support for group ownership.
groups.add(provider.createGroup(grpSpec, null, false, true));
bean = null;
attrSet = null;
return; // EARLY EXIT!
}
final Matcher m = p.matcher(grpSpec);
if (!m.matches()) {
throw new ValidationException(grpSpec
+ " spec currently not supported.");
}
final String type = m.group(1);
final String data = m.group(2);
if ("ou".equals(type)) {
bean = new OrgUnitNewUserGroupBean(dn);
attrSet = getAttributeSet(username, getPersonContextMapper());
} else if ("filtered_attribute".equals(type)) {
bean = new AttributeNewUserGroupBean(data, true, false);
attrSet = getAttributeSet(username, getPersonContextMapper(data));
} else if ("attribute".equals(type)) {
bean = new AttributeNewUserGroupBean(data, false, false);
attrSet = getAttributeSet(username, getPersonContextMapper(data));
} else if ("filtered_dn_attribute".equals(type)) {
bean = new AttributeNewUserGroupBean(data, true, true);
attrSet = getAttributeSet(username, getPersonContextMapper(data));
} else if ("dn_attribute".equals(type)) {
bean = new AttributeNewUserGroupBean(data, false, true);
attrSet = getAttributeSet(username, getPersonContextMapper(data));
} else if ("query".equals(type)) {
bean = new QueryNewUserGroupBean(data);
attrSet = getAttributeSet(username, getPersonContextMapper());
} else if ("bean".equals(type)) {
bean = appContext.getBean(data, NewUserGroupBean.class);
// Likely, this should be added to the API in order to allow bean
// implementations to provide an attribute set.
attrSet = getAttributeSet(username, getPersonContextMapper());
} else {
throw new RuntimeException("Unknown spec: " + grpSpec);
}
groups.addAll(bean.groups(username, config, ldap, provider, attrSet));
if (bean instanceof NewUserGroupOwnerBean) {
ownedGroups.addAll(((NewUserGroupOwnerBean) bean).ownerOfGroups(
username, config, ldap, provider, attrSet));
}
}
}
private AttributeSet getAttributeSet(String username,
PersonContextMapper mapper) {
Experimenter exp = mapUserName(username, mapper);
String dn = mapper.getDn(exp);
AttributeSet attrSet = mapper.getAttributeSet(exp);
attrSet.put("dn", dn); // For queries
return attrSet;
}
//
// READS
//
/**
* Validates password for base. Base is user's DN. When context was created
* successful specified requirements are valid.
* @param dn the user's distinguished name
* @param password the user's password
* @return boolean if the user's password is correct
*/
public boolean validatePassword(String dn, String password) {
try {
isAuthContext(dn, password);
return true;
} catch (SecurityViolation sv) {
return false;
}
}
/**
* Queries the LDAP server and returns the DN for all OMERO users that have
* the <code>ldap</code> flag enabled.
*
* @return a list of DN to user ID maps.
*/
public List<Map<String, Object>> lookupLdapAuthExperimenters() {
List<Long> ldapExperimenters = sql.getLdapExperimenters();
List<Map<String, Object>> rv = Lists
.newArrayListWithExpectedSize(ldapExperimenters.size());
for (Long id : ldapExperimenters) {
Map<String, Object> values = Maps.newHashMap();
// This will break whenever the mapping in AdminI changes
try {
values.put("dn", lookupLdapAuthExperimenter(id));
} catch (ApiUsageException aue) {
values.put("dn", "ERROR");
}
values.put("experimenter_id", id);
rv.add(values);
}
return rv;
}
/**
* Queries the LDAP server and returns the DN for the specified OMERO user
* ID. The LDAP server is queried and the DN returned only for IDs that have
* the <code>ldap</code> flag enabled.
*
* @param id
* The user ID.
* @return The DN as a String. Null if user isn't from LDAP.
*/
public String lookupLdapAuthExperimenter(Long id) {
// First, check that the supplied user ID is an LDAP user
String dn = null;
Experimenter experimenter = iQuery.get(Experimenter.class, id);
if (experimenter.getLdap()) {
dn = findDN(experimenter.getOmeName());
}
return dn;
}
@RolesAllowed("system")
public List<Experimenter> discover() {
List<Experimenter> discoveredExperimenters = Lists.newArrayList();
Roles r = getSecuritySystem().getSecurityRoles();
List<Experimenter> localExperimenters = iQuery.findAllByQuery(
"select distinct e from Experimenter e "
+ "where id not in (:ids) and ldap = :ldap",
new Parameters()
.addIds(Lists.newArrayList(r.getRootId(), r.getGuestId()))
.addBoolean("ldap", false));
for (Experimenter e : localExperimenters) {
try {
findExperimenter(e.getOmeName());
} catch (ApiUsageException aue) {
// This user doesn't have an LDAP account
continue;
}
discoveredExperimenters.add(e);
}
return discoveredExperimenters;
}
@RolesAllowed("system")
public List<ExperimenterGroup> discoverGroups() {
List<ExperimenterGroup> discoveredGroups = Lists.newArrayList();
Roles r = getSecuritySystem().getSecurityRoles();
List<ExperimenterGroup> localGroups = iQuery.findAllByQuery(
"select distinct g from ExperimenterGroup g "
+ "where id not in (:ids) and ldap = :ldap",
new Parameters().addIds(
Lists.newArrayList(r.getGuestGroupId(),
r.getSystemGroupId(), r.getUserGroupId()))
.addBoolean("ldap", false));
for (ExperimenterGroup g : localGroups) {
try {
findGroup(g.getName());
} catch (ApiUsageException aue) {
// This group doesn't exist in the LDAP server
continue;
}
discoveredGroups.add(g);
}
return discoveredGroups;
}
// Helpers
// =========================================================================
private PersonContextMapper getPersonContextMapper() {
return new PersonContextMapper(config, getBase());
}
private PersonContextMapper getPersonContextMapper(String attr) {
return new PersonContextMapper(config, getBase(), attr);
}
private GroupContextMapper getGroupContextMapper() {
return new GroupContextMapper(config, getBase());
}
private GroupContextMapper getGroupContextMapper(String attr) {
return new GroupContextMapper(config, getBase(), attr);
}
/**
* Creates the initial context with no connection request controls in order
* to check authentication. If authentication fails, this method throws
* a {@link SecurityViolation}.
* @param username the user's name
* @param password the user's password
* @throws SecurityViolation if authentication failed
*/
@SuppressWarnings("unchecked")
private void isAuthContext(String username, String password) {
Hashtable<String, String> env = new Hashtable<String, String>(5, 0.75f);
try {
// See discussion on anonymous bind in LdapPasswordProvider
if (username == null || username.equals("") || password == null
|| password.equals("")) {
throw new SecurityViolation(
"Refused to authenticate without username and password!");
}
env = (Hashtable<String, String>) ctx.getReadOnlyContext()
.getEnvironment();
env.put(Context.SECURITY_PRINCIPAL, username);
env.put(Context.SECURITY_CREDENTIALS, password);
new InitialLdapContext(env, null);
} catch (AuthenticationException authEx) {
throw new SecurityViolation("Authentication falilure! "
+ authEx.toString());
} catch (NamingException e) {
throw new SecurityViolation("Naming exception! " + e.toString());
}
}
private String getBase() {
String base = null;
try {
base = ctx.getReadOnlyContext().getNameInNamespace();
} catch (NamingException e) {
throw new ApiUsageException(
"Cannot get BASE from ContextSource. Naming exception! "
+ e.toString());
}
return base;
}
}