package hudson.plugins.active_directory; import com.sun.jndi.ldap.LdapCtxFactory; import groovy.lang.Binding; import hudson.Extension; import hudson.Functions; import hudson.Util; import hudson.model.Descriptor; import hudson.model.Hudson; import hudson.security.GroupDetails; import hudson.security.SecurityRealm; import hudson.util.FormValidation; import hudson.util.Secret; import hudson.util.spring.BeanBuilder; import org.acegisecurity.AuthenticationException; import org.acegisecurity.AuthenticationManager; import org.acegisecurity.BadCredentialsException; import org.acegisecurity.userdetails.UserDetails; import org.acegisecurity.userdetails.UserDetailsService; import org.acegisecurity.userdetails.UsernameNotFoundException; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.springframework.dao.DataAccessException; import org.springframework.web.context.WebApplicationContext; import javax.naming.Context; import javax.naming.NamingEnumeration; 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 javax.servlet.ServletException; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Hashtable; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import static hudson.Util.fixEmpty; /** * @author Kohsuke Kawaguchi */ public class ActiveDirectorySecurityRealm extends SecurityRealm { /** * Active directory domain name to authenticate against. * * <p> * When this plugin is used on Windows, this field is null, * and we use ADSI and ADO through com4j to perform authentication. * * <p> * OTOH, when this plugin runs on non-Windows, this field * must be non-null, and we'll use LDAP for authentication. */ public final String domain; /** * Active directory site (which specifies the physical concentration of the servers), * if any. If the value is non-null, we'll only contact servers in this site. * * <p> * On Windows, I'm assuming ADSI takes care of everything automatically. */ public final String site; /** * If non-null, use this name and password to bind to LDAP to obtain the DN of the user trying to login. * This is unnecessary in a sigle-domain mode, where we can just bind with the user name and password * provided during the login, but in a forest mode, without some known credential, we cannot figure out * which domain in the forest the user belongs to. */ public final String bindName; public final Secret bindPassword; @DataBoundConstructor public ActiveDirectorySecurityRealm(String domain, String site, String bindName, String bindPassword) { this.domain = fixEmpty(domain); this.site = fixEmpty(site); this.bindName = fixEmpty(bindName); this.bindPassword = Secret.fromString(fixEmpty(bindPassword)); } public SecurityComponents createSecurityComponents() { BeanBuilder builder = new BeanBuilder(getClass().getClassLoader()); Binding binding = new Binding(); binding.setVariable("realm",this); builder.parse(getClass().getResourceAsStream("ActiveDirectory.groovy"),binding); WebApplicationContext context = builder.createApplicationContext(); return new SecurityComponents( findBean(AuthenticationManager.class, context), findBean(UserDetailsService.class, context)); } @Override public DesciprotrImpl getDescriptor() { return (DesciprotrImpl)super.getDescriptor(); } /** * Authentication test. */ public void doAuthTest(StaplerRequest req, StaplerResponse rsp, @QueryParameter String username, @QueryParameter String password) throws IOException, ServletException { // require the administrator permission since this is full of debug info. Hudson.getInstance().checkPermission(Hudson.ADMINISTER); StringWriter out = new StringWriter(); PrintWriter pw = new PrintWriter(out); ClassLoader ccl = Thread.currentThread().getContextClassLoader(); Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); try { UserDetailsService uds = getSecurityComponents().userDetails; if (uds instanceof ActiveDirectoryUnixAuthenticationProvider) { ActiveDirectoryUnixAuthenticationProvider p = (ActiveDirectoryUnixAuthenticationProvider) uds; DesciprotrImpl descriptor = getDescriptor(); try { pw.println("Domain="+domain+" site="+site); List<SocketInfo> ldapServers = descriptor.obtainLDAPServer(domain, site); pw.println("List of domain controllers: "+ldapServers); for (SocketInfo ldapServer : ldapServers) { pw.println("Trying a domain controller at "+ldapServer); try { UserDetails d = p.retrieveUser(username, password, domain, Collections.singletonList(ldapServer)); pw.println("Authenticated as "+d); } catch (AuthenticationException e) { e.printStackTrace(pw); } } } catch (NamingException e) { pw.println("Failing to resolve domain controllers"); e.printStackTrace(pw); } } else { pw.println("Using Windows ADSI. No diagnostics available."); } } catch (Exception e) { e.printStackTrace(pw); } finally { Thread.currentThread().setContextClassLoader(ccl); } req.setAttribute("output",out.toString()); req.getView(this,"test.jelly").forward(req,rsp); } @Extension public static final class DesciprotrImpl extends Descriptor<SecurityRealm> { public String getDisplayName() { return Messages.DisplayName(); } @Override public String getHelpFile() { return "/plugin/active-directory/help/realm.html"; } /** * If true, we can do ADSI/COM based look up that's far more reliable. * False if we need to do the authentication in pure Java via {@link ActiveDirectoryUnixAuthenticationProvider} */ public boolean canDoNativeAuth() { return Hudson.isWindows() && "32".equals(System.getProperty("sun.arch.data.model")); } public FormValidation doValidate( @QueryParameter(fixEmpty=true) String domain, @QueryParameter(fixEmpty=true) String site, @QueryParameter(fixEmpty=true) String bindName, @QueryParameter(fixEmpty=true) String bindPassword) throws IOException, ServletException, NamingException { ClassLoader ccl = Thread.currentThread().getContextClassLoader(); Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); try { Functions.checkPermission(Hudson.ADMINISTER); String n = Util.fixEmptyAndTrim(domain); if(n==null) {// no value given yet return FormValidation.error("No domain name set"); } Secret password = Secret.fromString(bindPassword); if (bindName!=null && password==null) return FormValidation.error("DN is specified but not password"); String[] names = n.split(","); for (String name : names) { if(!name.endsWith(".")) name+='.'; DirContext ictx; // first test the sanity of the domain name itself try { LOGGER.fine("Attempting to resolve "+name+" to A record"); ictx = createDNSLookupContext(); Attributes attributes = ictx.getAttributes(name, new String[]{"A"}); Attribute a = attributes.get("A"); if(a==null) throw new NamingException(); LOGGER.fine(name+" resolved to "+ a.get()); } catch (NamingException e) { LOGGER.log(Level.WARNING,"Failed to resolve "+name+" to A record",e); return FormValidation.error(e,name+" doesn't look like a valid domain name"); } // then look for the LDAP server List<SocketInfo> servers; try { servers = obtainLDAPServer(ictx,name,site); } catch (NamingException e) { String msg = site==null ? "No LDAP server was found in " + name : "No LDAP server was found in the "+site+" site of "+name; LOGGER.log(Level.WARNING, msg,e); return FormValidation.error(e,msg); } if (bindName!=null) { // make sure the bind actually works try { bind(bindName,password.toString(),servers).close(); } catch (BadCredentialsException e) { return FormValidation.error(e,"Bad bind username or password"); } catch (Exception e) { return FormValidation.error(e,e.getMessage()); } } else { // just some connection test // try to connect to LDAP port to make sure this machine has LDAP service IOException error = null; for (SocketInfo si : servers) { try { si.connect().close(); break; // looks good } catch (IOException e) { LOGGER.log(Level.FINE,"Failed to connect to "+si,e); error = e; // try the next server in the list } } if (error!=null) { LOGGER.log(Level.WARNING,"Failed to connect to "+servers,error); return FormValidation.error(error,"Failed to connect to "+servers); } } } // looks good return FormValidation.ok("Success"); } finally { Thread.currentThread().setContextClassLoader(ccl); } } /** * Binds to the server using the specified username/password. * <p> * In a real deployment, often there are servers that don't respond or otherwise broken, * so try all the servers. */ public DirContext bind(String principalName, String password, List<SocketInfo> ldapServers) { // in a AD forest, it'd be mighty nice to be able to login as "joe" as opposed to "joe@europe", // but the bind operation doesn't appear to allow me to do so. Hashtable<String,String> props = new Hashtable<String,String>(); props.put(Context.SECURITY_PRINCIPAL, principalName); props.put(Context.SECURITY_CREDENTIALS,password); props.put(Context.REFERRAL, "follow"); // specifying custom socket factory requires a custom classloader. props.put("java.naming.ldap.factory.socket", TrustAllSocketFactory.class.getName()); NamingException error = null; for (SocketInfo ldapServer : ldapServers) { try { DirContext context = LdapCtxFactory.getLdapCtxInstance("ldaps://" + ldapServer + '/', props); LOGGER.fine("Bound to "+ldapServer); return context; // worked } catch (NamingException e) { LOGGER.log(Level.WARNING,"Failed to bind to "+ldapServer,e); error = e; // retry } } // if all the attempts failed throw new BadCredentialsException("Either no such user '"+principalName+"' or incorrect password",error); } /** * Creates {@link DirContext} for accesssing DNS. */ public DirContext createDNSLookupContext() throws NamingException { Hashtable env = new Hashtable(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); env.put("java.naming.provider.url", "dns:"); return new InitialDirContext(env); } public List<SocketInfo> obtainLDAPServer(String domainName, String site) throws NamingException { return obtainLDAPServer(createDNSLookupContext(),domainName,site); } private static final List<SocketInfo> CANDIDATES = Arrays.asList( new SocketInfo("_gc._tcp.",3269), new SocketInfo("_ldap._tcp.",636) // LDAPS ); /** * Use DNS and obtains the LDAP servers that we should try. * * @return * A list with at least one item. */ public List<SocketInfo> obtainLDAPServer(DirContext ictx, String domainName, String site) throws NamingException { String ldapServer=null; Attribute a=null; SocketInfo mode = null; NamingException failure=null; // try global catalog if it exists first, then the particular domain for (SocketInfo candidate : CANDIDATES) { mode = candidate; ldapServer = candidate.host/*used as a prefix*/+(site!=null?site+"._sites.":"")+domainName; LOGGER.fine("Attempting to resolve "+ldapServer+" to SRV record"); try { Attributes attributes = ictx.getAttributes(ldapServer, new String[]{"SRV"}); a = attributes.get("SRV"); if (a!=null) break; } catch (NamingException e) { // failed retrieval. try next option. failure = e; } } if(a==null) {// all options failed if (failure!=null) throw failure; throw new NamingException(); } int priority = -1; List<SocketInfo> result = new ArrayList<SocketInfo>(); for (NamingEnumeration ne = a.getAll(); ne.hasMoreElements(); ) { String[] fields = ne.next().toString().split(" "); int p = Integer.parseInt(fields[0]); // fields[1]: weight // fields[2]: port // fields[3]: target host name if (priority == -1 || p < priority) { priority = p; result.clear(); } if (priority==p) { String hostName = fields[3]; // cut off trailing ".". HUDSON-2647 if (hostName.endsWith(".")) hostName = hostName.substring(0,hostName.length()-1); result.add(new SocketInfo(hostName,mode.port)); } } if (result.isEmpty()) throw new NamingException("No SRV record found for "+ldapServer); LOGGER.fine(ldapServer+" resolved to "+ result); return result; } } @Override public GroupDetails loadGroupByGroupname(String groupname) throws UsernameNotFoundException, DataAccessException { GroupDetailsService groupDetailsService = (GroupDetailsService) getSecurityComponents().userDetails; return groupDetailsService.loadGroupByGroupname(groupname); } private static final Logger LOGGER = Logger.getLogger(ActiveDirectorySecurityRealm.class.getName()); }