/******************************************************************************* * * Copyright (c) 2004-2012 Oracle Corporation. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * * Kohsuke Kawaguchi, Seiji Sogabe, Winston Prakash * *******************************************************************************/ package hudson.security; import hudson.Extension; import static hudson.Util.fixNull; import static hudson.Util.fixEmptyAndTrim; import static hudson.Util.fixEmpty; import hudson.model.Descriptor; import hudson.model.Hudson; import hudson.model.User; import hudson.tasks.MailAddressResolver; import hudson.util.FormValidation; import hudson.util.Scrambler; import org.apache.commons.lang3.StringUtils; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import org.springframework.dao.DataAccessException; import javax.naming.Context; import javax.naming.NamingException; import javax.naming.directory.Attribute; import javax.naming.directory.Attributes; import javax.naming.directory.DirContext; import javax.naming.directory.InitialDirContext; import java.io.IOException; import java.net.InetAddress; import java.net.Socket; import java.net.UnknownHostException; import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.eclipse.hudson.security.HudsonSecurityEntitiesHolder; import org.springframework.ldap.core.ContextSource; import org.springframework.ldap.core.DirContextOperations; import org.springframework.ldap.core.support.DefaultDirObjectFactory; import org.springframework.security.authentication.AnonymousAuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.RememberMeAuthenticationProvider; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.ldap.DefaultSpringSecurityContextSource; import org.springframework.security.ldap.SpringSecurityLdapTemplate; import org.springframework.security.ldap.authentication.LdapAuthenticationProvider; import org.springframework.security.ldap.search.FilterBasedLdapUserSearch; import org.springframework.security.ldap.search.LdapUserSearch; import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator; import org.springframework.security.ldap.userdetails.InetOrgPerson; import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator; import org.springframework.security.ldap.userdetails.LdapUserDetails; import org.springframework.security.ldap.userdetails.LdapUserDetailsService; /** * {@link SecurityRealm} implementation that uses LDAP for authentication. * * * <h2>Key Object Classes</h2> * * <h4>Group Membership</h4> * * <p> Two object classes seem to be relevant. These are in RFC 2256 and * core.schema. These use DN for membership, so it can create a group of * anything. I don't know what the difference between these two are. * <pre> * attributetype ( 2.5.4.31 NAME 'member' * DESC 'RFC2256: member of a group' * SUP distinguishedName ) * * attributetype ( 2.5.4.50 NAME 'uniqueMember' * DESC 'RFC2256: unique member of a group' * EQUALITY uniqueMemberMatch * SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 ) * * objectclass ( 2.5.6.9 NAME 'groupOfNames' * DESC 'RFC2256: a group of names (DNs)' * SUP top STRUCTURAL * MUST ( member $ cn ) * MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description ) ) * * objectclass ( 2.5.6.17 NAME 'groupOfUniqueNames' * DESC 'RFC2256: a group of unique names (DN and Unique Identifier)' * SUP top STRUCTURAL * MUST ( uniqueMember $ cn ) * MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description ) ) * </pre> * * <p> This one is from nis.schema, and appears to model POSIX group/user thing * more closely. * <pre> * objectclass ( 1.3.6.1.1.1.2.2 NAME 'posixGroup' * DESC 'Abstraction of a group of accounts' * SUP top STRUCTURAL * MUST ( cn $ gidNumber ) * MAY ( userPassword $ memberUid $ description ) ) * * attributetype ( 1.3.6.1.1.1.1.12 NAME 'memberUid' * EQUALITY caseExactIA5Match * SUBSTR caseExactIA5SubstringsMatch * SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) * * objectclass ( 1.3.6.1.1.1.2.0 NAME 'posixAccount' * DESC 'Abstraction of an account with POSIX attributes' * SUP top AUXILIARY * MUST ( cn $ uid $ uidNumber $ gidNumber $ homeDirectory ) * MAY ( userPassword $ loginShell $ gecos $ description ) ) * * attributetype ( 1.3.6.1.1.1.1.0 NAME 'uidNumber' * DESC 'An integer uniquely identifying a user in an administrative domain' * EQUALITY integerMatch * SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) * * attributetype ( 1.3.6.1.1.1.1.1 NAME 'gidNumber' * DESC 'An integer uniquely identifying a group in an administrative domain' * EQUALITY integerMatch * SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) * </pre> * * <p> Active Directory specific schemas (from <a * href="http://www.grotan.com/ldap/microsoft.schema">here</a>). * <pre> * objectclass ( 1.2.840.113556.1.5.8 * NAME 'group' * SUP top * STRUCTURAL * MUST (groupType ) * MAY (member $ nTGroupMembers $ operatorCount $ adminCount $ * groupAttributes $ groupMembershipSAM $ controlAccessRights $ * desktopProfile $ nonSecurityMember $ managedBy $ * primaryGroupToken $ mail ) ) * * objectclass ( 1.2.840.113556.1.5.9 * NAME 'user' * SUP organizationalPerson * STRUCTURAL * MAY (userCertificate $ networkAddress $ userAccountControl $ * badPwdCount $ codePage $ homeDirectory $ homeDrive $ * badPasswordTime $ lastLogoff $ lastLogon $ dBCSPwd $ * localeID $ scriptPath $ logonHours $ logonWorkstation $ * maxStorage $ userWorkstations $ unicodePwd $ * otherLoginWorkstations $ ntPwdHistory $ pwdLastSet $ * preferredOU $ primaryGroupID $ userParameters $ * profilePath $ operatorCount $ adminCount $ accountExpires $ * lmPwdHistory $ groupMembershipSAM $ logonCount $ * controlAccessRights $ defaultClassStore $ groupsToIgnore $ * groupPriority $ desktopProfile $ dynamicLDAPServer $ * userPrincipalName $ lockoutTime $ userSharedFolder $ * userSharedFolderOther $ servicePrincipalName $ * aCSPolicyName $ terminalServer $ mSMQSignCertificates $ * mSMQDigests $ mSMQDigestsMig $ mSMQSignCertificatesMig $ * msNPAllowDialin $ msNPCallingStationID $ * msNPSavedCallingStationID $ msRADIUSCallbackNumber $ * msRADIUSFramedIPAddress $ msRADIUSFramedRoute $ * msRADIUSServiceType $ msRASSavedCallbackNumber $ * msRASSavedFramedIPAddress $ msRASSavedFramedRoute $ * mS-DS-CreatorSID ) ) * </pre> * * * <h2>References</h2> <dl> <dt><a * href="http://www.openldap.org/doc/admin22/schema.html">Standard Schemas</a> * <dd> The downloadable distribution contains schemas that define the structure * of LDAP entries. Because this is a standard, we expect most LDAP servers out * there to use it, although there are different objectClasses that can be used * for similar purposes, and apparently many deployments choose to use different * objectClasses. * * <dt><a href="http://www.ietf.org/rfc/rfc2256.txt">RFC 2256</a> <dd> Defines * the meaning of several key datatypes used in the schemas with some * explanations. * * <dt><a * href="http://msdn.microsoft.com/en-us/library/ms675085(VS.85).aspx">Active * Directory schema</a> <dd> More navigable schema list, including core and MS * extensions specific to Active Directory. </dl> * * @author Kohsuke Kawaguchi * @since 1.166 */ public class LDAPSecurityRealm extends AbstractPasswordBasedSecurityRealm { /** * LDAP server name, optionally with TCP port number, like "ldap.acme.org" * or "ldap.acme.org:389". */ public final String server; /** * The root DN to connect to. Normally something like "dc=sun,dc=com" * * How do I infer this? */ public final String rootDN; /** * Specifies the relative DN from {@link #rootDN the root DN}. This is used * to narrow down the search space when doing user search. * * Something like "ou=people" but can be empty. */ public final String userSearchBase; /** * Query to locate an entry that identifies the user, given the user name * string. * * Normally "uid={0}" * * @see FilterBasedLdapUserSearch */ public final String userSearch; /** * This defines the organizational unit that contains groups. * * Normally "" to indicate the full LDAP search, but can be often narrowed * down to something like "ou=groups" * * @see FilterBasedLdapUserSearch */ public final String groupSearchBase; /* Other configurations that are needed: group search base DN (relative to root DN) group search filter (uniquemember={1} seems like a reasonable default) group target (CN is a reasonable default) manager dn/password if anonyomus search is not allowed. See GF configuration at http://weblogs.java.net/blog/tchangu/archive/2007/01/ldap_security_r.html Geronimo configuration at http://cwiki.apache.org/GMOxDOC11/ldap-realm.html */ /** * If non-null, we use this and {@link #managerPassword} when binding to * LDAP. * * This is necessary when LDAP doesn't support anonymous access. */ public final String managerDN; /** * Scrambled password, used to first bind to LDAP. */ private final String managerPassword; /** * Created in {@link #createSecurityComponents()}. Can be used to connect to * LDAP. */ private transient SpringSecurityLdapTemplate ldapTemplate; /** * LDAP filter to look for Roles of a user (Groups to which the user belongs to) */ private static String ROLE_SEARCH_FILTER = System.getProperty(LDAPSecurityRealm.class.getName() + ".roleSearch", "(| (member={0}) (uniqueMember={0}) (memberUid={1}))"); /** * LDAP filter to look for groups by their names. Can be overridden by System Property * * "{0}" is the group name as given by the user. See * http://msdn.microsoft.com/en-us/library/aa746475(VS.85).aspx for the * syntax by example. WANTED: The specification of the syntax. */ public static String GROUP_SEARCH_FILTER = System.getProperty(LDAPSecurityRealm.class.getName() + ".groupSearch", "(& (cn={0}) (| (objectclass=groupOfNames) (objectclass=groupOfUniqueNames) (objectclass=posixGroup)))"); @DataBoundConstructor public LDAPSecurityRealm(String server, String rootDN, String userSearchBase, String userSearch, String groupSearchBase, String managerDN, String managerPassword) { this.server = server.trim(); this.managerDN = fixEmpty(managerDN); this.managerPassword = Scrambler.scramble(fixEmpty(managerPassword)); if (fixEmptyAndTrim(rootDN) == null) { rootDN = fixNull(inferRootDN(server)); } this.rootDN = rootDN.trim(); this.userSearchBase = fixNull(userSearchBase).trim(); userSearch = fixEmptyAndTrim(userSearch); this.userSearch = userSearch != null ? userSearch : "uid={0}"; this.groupSearchBase = fixEmptyAndTrim(groupSearchBase); } public String getServerUrl() { return addPrefix(server); } /** * Infer the root DN. * * @return null if not found. */ private String inferRootDN(String server) { try { Hashtable<String, String> props = new Hashtable<String, String>(); if (managerDN != null) { props.put(Context.SECURITY_PRINCIPAL, managerDN); props.put(Context.SECURITY_CREDENTIALS, getManagerPassword()); } props.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); props.put(Context.PROVIDER_URL, getServerUrl() + '/'); DirContext ctx = new InitialDirContext(props); Attributes atts = ctx.getAttributes(""); Attribute a = atts.get("defaultNamingContext"); if (a != null) // this entry is available on Active Directory. See http://msdn2.microsoft.com/en-us/library/ms684291(VS.85).aspx { return a.toString(); } a = atts.get("namingcontexts"); if (a == null) { LOGGER.warning("namingcontexts attribute not found in root DSE of " + server); return null; } return a.get().toString(); } catch (NamingException e) { LOGGER.log(Level.WARNING, "Failed to connect to LDAP to infer Root DN for " + server, e); return null; } } public String getManagerPassword() { return Scrambler.descramble(managerPassword); } public String getLDAPURL() { return getServerUrl() + '/' + fixNull(rootDN); } public synchronized SecurityComponents createSecurityComponents() { DefaultDirObjectFactory factory = new DefaultDirObjectFactory(); DefaultSpringSecurityContextSource securityContextSource = new DefaultSpringSecurityContextSource(getLDAPURL()); if (managerDN != null) { securityContextSource.setUserDn(managerDN); securityContextSource.setPassword(getManagerPassword()); } Map envProps = new HashMap(); envProps.put(Context.REFERRAL, "follow"); securityContextSource.setDirObjectFactory(factory.getClass()); securityContextSource.setBaseEnvironmentProperties(envProps); try { securityContextSource.afterPropertiesSet(); } catch (Exception ex) { LOGGER.log(Level.WARNING, "Failed to set security Context for LDAP Server " + server, ex); } ldapTemplate = new SpringSecurityLdapTemplate(securityContextSource); FilterBasedLdapUserSearch ldapUserSearch = new FilterBasedLdapUserSearch(userSearchBase, userSearch, securityContextSource); ldapUserSearch.setSearchSubtree(true); BindAuthenticator2 bindAuthenticator = new BindAuthenticator2(securityContextSource); bindAuthenticator.setUserSearch(ldapUserSearch); AuthoritiesPopulatorImpl authoritiesPopulator = new AuthoritiesPopulatorImpl(securityContextSource, groupSearchBase); authoritiesPopulator.setSearchSubtree(true); authoritiesPopulator.setGroupSearchFilter(ROLE_SEARCH_FILTER); // talk to LDAP LdapAuthenticationProvider ldapAuthenticationProvider = new LdapAuthenticationProvider(bindAuthenticator, authoritiesPopulator); // these providers apply everywhere RememberMeAuthenticationProvider rememberMeAuthenticationProvider = new RememberMeAuthenticationProvider(); rememberMeAuthenticationProvider.setKey(HudsonSecurityEntitiesHolder.getHudsonSecurityManager().getSecretKey()); // this doesn't mean we allow anonymous access. // we just authenticate anonymous users as such, // so that later authorization can reject them if so configured AnonymousAuthenticationProvider anonymousAuthenticationProvider = new AnonymousAuthenticationProvider(); anonymousAuthenticationProvider.setKey("anonymous"); AuthenticationProvider[] authenticationProvider = { ldapAuthenticationProvider, rememberMeAuthenticationProvider, anonymousAuthenticationProvider }; ProviderManager providerManager = new ProviderManager(); providerManager.setProviders(Arrays.asList(authenticationProvider)); return new SecurityComponents(providerManager, new LDAPUserDetailsService(ldapUserSearch, authoritiesPopulator)); } /** * {@inheritDoc} * @param username * @param password * @return */ @Override protected UserDetails authenticate(String username, String password) throws AuthenticationException { return (UserDetails) getSecurityComponents().manager.authenticate( new UsernamePasswordAuthenticationToken(username, password, ACL.NO_AUTHORITIES)).getPrincipal(); } /** * {@inheritDoc} * @param username * @return */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException { return getSecurityComponents().userDetails.loadUserByUsername(username); } /** * Lookup a group; given input must match the configured syntax for group * names in GROUP_SEARCH_FILTER of authoritiesPopulator entry. The defaults * are a prefix of "ROLE_" and using all uppercase. This method will not * return any data if the given name lacks the proper prefix and/or case. * @param groupname * @return */ @Override public GroupDetails loadGroupByGroupname(String groupname) throws UsernameNotFoundException, DataAccessException { // Check proper syntax based on Spring Security configuration String prefix = ""; boolean onlyUpperCase = false; try { AuthoritiesPopulatorImpl api = (AuthoritiesPopulatorImpl) ((LDAPUserDetailsService) getSecurityComponents().userDetails).authoritiesPopulator; prefix = api.rolePrefix; onlyUpperCase = api.convertToUpperCase; } catch (Exception ignore) { } if (onlyUpperCase && !groupname.equals(groupname.toUpperCase())) { throw new UsernameNotFoundException(groupname + " should be all uppercase"); } if (!groupname.startsWith(prefix)) { throw new UsernameNotFoundException(groupname + " is missing prefix: " + prefix); } groupname = groupname.substring(prefix.length()); // TODO: obtain a DN instead so that we can obtain multiple attributes later String searchBase = groupSearchBase != null ? groupSearchBase : ""; final Set<String> groups = (Set<String>) ldapTemplate.searchForSingleAttributeValues(searchBase, GROUP_SEARCH_FILTER, new String[]{groupname}, "cn"); if (groups.isEmpty()) { throw new UsernameNotFoundException(groupname); } return new GroupDetails() { public String getName() { return groups.iterator().next(); } }; } public static class LDAPUserDetailsService implements UserDetailsService { private final LdapUserSearch ldapSearch; private final LdapAuthoritiesPopulator authoritiesPopulator; LDAPUserDetailsService(LdapUserSearch ldapSearch, LdapAuthoritiesPopulator authoritiesPopulator) { this.ldapSearch = ldapSearch; this.authoritiesPopulator = authoritiesPopulator; } public LdapUserSearch getLdapSearch() { return ldapSearch; } public LdapAuthoritiesPopulator getAuthoritiesPopulator() { return authoritiesPopulator; } public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException { LdapUserDetailsService ldapUserDetailsService = new LdapUserDetailsService(ldapSearch, authoritiesPopulator); return ldapUserDetailsService.loadUserByUsername(username); } } /** * If the security realm is LDAP, try to pick up e-mail address from LDAP. */ @Extension public static final class MailAdressResolverImpl extends MailAddressResolver { @Override public String findMailAddressFor(User u) { // LDAP not active SecurityRealm realm = HudsonSecurityEntitiesHolder.getHudsonSecurityManager().getSecurityRealm(); if (!(realm instanceof LDAPSecurityRealm)) { return null; } try { UserDetails details = realm.getSecurityComponents().userDetails.loadUserByUsername(u.getId()); if (details instanceof InetOrgPerson){ InetOrgPerson inetOrgPerson = (InetOrgPerson) details; return inetOrgPerson.getMail(); } return null; } catch (UsernameNotFoundException e) { LOGGER.log(Level.FINE, "Failed to look up LDAP for e-mail address", e); return null; } catch (DataAccessException e) { LOGGER.log(Level.FINE, "Failed to look up LDAP for e-mail address", e); return null; } } } /** * {@link LdapAuthoritiesPopulator} that adds the automatic 'authenticated' * role. */ public static final class AuthoritiesPopulatorImpl extends DefaultLdapAuthoritiesPopulator { // Make these available (private in parent class and no get methods!) String rolePrefix; boolean convertToUpperCase; public AuthoritiesPopulatorImpl(ContextSource initialDirContextFactory, String groupSearchBase) { super(initialDirContextFactory, fixNull(groupSearchBase)); // These match the defaults in Spring Security 1.0.5; set again to store in non-private fields: setRolePrefix("ROLE_"); setConvertToUpperCase(true); } @Override protected Set getAdditionalRoles(DirContextOperations ldapUser, String username) { return Collections.singleton(AUTHENTICATED_AUTHORITY); } @Override public void setRolePrefix(String rolePrefix) { super.setRolePrefix(rolePrefix); this.rolePrefix = rolePrefix; } @Override public void setConvertToUpperCase(boolean convertToUpperCase) { super.setConvertToUpperCase(convertToUpperCase); this.convertToUpperCase = convertToUpperCase; } } @Extension public static final class DescriptorImpl extends Descriptor<SecurityRealm> { public String getDisplayName() { return Messages.LDAPSecurityRealm_DisplayName(); } public FormValidation doServerCheck( @QueryParameter final String server, @QueryParameter final String managerDN, @QueryParameter final String managerPassword) { if (!HudsonSecurityEntitiesHolder.getHudsonSecurityManager().hasPermission(Hudson.ADMINISTER) || StringUtils.isEmpty(server)) { return FormValidation.ok(); } try { Hashtable<String, String> props = new Hashtable<String, String>(); if (managerDN != null && managerDN.trim().length() > 0 && !"undefined".equals(managerDN)) { props.put(Context.SECURITY_PRINCIPAL, managerDN); } if (managerPassword != null && managerPassword.trim().length() > 0 && !"undefined".equals(managerPassword)) { props.put(Context.SECURITY_CREDENTIALS, managerPassword); } props.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); props.put(Context.PROVIDER_URL, addPrefix(server) + '/'); DirContext ctx = new InitialDirContext(props); ctx.getAttributes(""); return FormValidation.ok(); // connected } catch (NamingException e) { // trouble-shoot Matcher m = Pattern.compile("(ldaps://)?([^:]+)(?:\\:(\\d+))?").matcher(server.trim()); if (!m.matches()) { return FormValidation.error(Messages.LDAPSecurityRealm_SyntaxOfServerField()); } try { InetAddress adrs = InetAddress.getByName(m.group(2)); int port = m.group(1) != null ? 636 : 389; if (m.group(3) != null) { port = Integer.parseInt(m.group(3)); } Socket s = new Socket(adrs, port); s.close(); } catch (UnknownHostException x) { return FormValidation.error(Messages.LDAPSecurityRealm_UnknownHost(x.getMessage())); } catch (IOException x) { return FormValidation.error(x, Messages.LDAPSecurityRealm_UnableToConnect(server, x.getMessage())); } // otherwise we don't know what caused it, so fall back to the general error report // getMessage() alone doesn't offer enough return FormValidation.error(e, Messages.LDAPSecurityRealm_UnableToConnect(server, e)); } catch (NumberFormatException x) { // The getLdapCtxInstance method throws this if it fails to parse the port number return FormValidation.error(Messages.LDAPSecurityRealm_InvalidPortNumber()); } } } /** * If the given "server name" is just a host name (plus optional host name), * add ldap:// prefix. Otherwise assume it already contains the scheme, and * leave it intact. */ private static String addPrefix(String server) { if (server.contains("://")) { return server; } else { return "ldap://" + server; } } private static final Logger LOGGER = Logger.getLogger(LDAPSecurityRealm.class.getName()); }