/*
* The MIT License
*
* Copyright (c) 2008-2014, Kohsuke Kawaguchi, CloudBees, Inc., and contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package hudson.plugins.active_directory;
import com.google.common.cache.Cache;
import com.google.common.util.concurrent.UncheckedExecutionException;
import com4j.COM4J;
import com4j.Com4jObject;
import com4j.ComException;
import com4j.ExecutionException;
import com4j.Variant;
import com4j.typelibs.activeDirectory.IADs;
import com4j.typelibs.activeDirectory.IADsGroup;
import com4j.typelibs.activeDirectory.IADsOpenDSObject;
import com4j.typelibs.activeDirectory.IADsUser;
import com4j.typelibs.ado20.ClassFactory;
import com4j.typelibs.ado20._Command;
import com4j.typelibs.ado20._Connection;
import com4j.typelibs.ado20._Recordset;
import com4j.util.ComObjectCollector;
import hudson.security.GroupDetails;
import hudson.security.SecurityRealm;
import hudson.security.UserMayOrMayNotExistException;
import org.acegisecurity.AuthenticationException;
import org.acegisecurity.BadCredentialsException;
import org.acegisecurity.GrantedAuthority;
import org.acegisecurity.GrantedAuthorityImpl;
import org.acegisecurity.providers.AuthenticationProvider;
import org.acegisecurity.providers.UsernamePasswordAuthenticationToken;
import org.acegisecurity.userdetails.UserDetails;
import org.acegisecurity.userdetails.UserDetailsService;
import org.acegisecurity.userdetails.UsernameNotFoundException;
import org.kohsuke.stapler.framework.io.IOException2;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* {@link AuthenticationProvider} with Active Directory, plus {@link UserDetailsService}
*
* @author Kohsuke Kawaguchi
*/
public class ActiveDirectoryAuthenticationProvider extends AbstractActiveDirectoryAuthenticationProvider {
private final String defaultNamingContext;
/**
* ADO connection for searching Active Directory.
*/
private final _Connection con;
/**
* The cache configuration
*/
private CacheConfiguration cache;
/**
* The {@link UserDetails} cache.
*/
private final Cache<String, UserDetails> userCache;
/**
* The {@link ActiveDirectoryGroupDetails} cache.
*/
private final Cache<String, ActiveDirectoryGroupDetails> groupCache;
public ActiveDirectoryAuthenticationProvider() throws IOException {
this(null);
}
public ActiveDirectoryAuthenticationProvider(ActiveDirectorySecurityRealm realm)throws IOException {
try {
IADs rootDSE = COM4J.getObject(IADs.class, "LDAP://RootDSE", null);
defaultNamingContext = (String)rootDSE.get("defaultNamingContext");
LOGGER.info("Active Directory domain is "+defaultNamingContext);
con = ClassFactory.createConnection();
con.provider("ADsDSOObject");
con.open("Active Directory Provider",""/*default*/,""/*default*/,-1/*default*/);
if (realm != null) {
this.cache = realm.cache;
}
if (this.cache == null) {
this.cache = new CacheConfiguration(0, 0);
}
// On startup userCache and groupCache are not created and cache is different from null
if (cache.getUserCache() == null || cache.getGroupCache() == null) {
this.cache = new CacheConfiguration(cache.getSize(), cache.getTtl());
}
this.userCache = cache.getUserCache();
this.groupCache = cache.getGroupCache();
} catch (ExecutionException e) {
throw new IOException2("Failed to connect to Active Directory. Does this machine belong to Active Directory?",e);
}
}
/**
* Converts a value of the "distinguished name" attribute of some AD object
* and returns the "LDAP://..." URL to connect to it vis {@link IADsOpenDSObject#openDSObject(String, String, String, int)}
*
* AFAICT, MSDN doesn't document exactly describe how a value of the DN attribute is escaped,
* but in my experiment with Windows 2008, it escapes <tt>,+\#<>;"=</tt> but not <tt>/</tt>
*
* This method must escape '/' since it needs to be escaped in LDAP:// URL, but we also need
* to avoid double-escaping what's already escaped.
*
* @see <a href="http://www.rlmueller.net/CharactersEscaped.htm">source</a>
*/
static String dnToLdapUrl(String dn) {
return "LDAP://"+dn.replace("/","\\/");
}
protected UserDetails retrieveUser(final String username,final UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
try {
return userCache.get(username, new Callable<UserDetails>() {
public UserDetails call() {
String password = null;
if(authentication!=null) {
password = (String) authentication.getCredentials();
}
String dn = getDnOfUserOrGroup(username);
ComObjectCollector col = new ComObjectCollector();
COM4J.addListener(col);
try {
// now we got the DN of the user
IADsOpenDSObject dso = COM4J.getObject(IADsOpenDSObject.class,"LDAP:",null);
// turns out we don't need DN for authentication
// we can bind with the user name
// dso.openDSObject("LDAP://"+context,args[0],args[1],1);
// to do bind with DN as the user name, the flag must be 0
IADsUser usr;
try {
usr = (authentication==null
? dso.openDSObject(dnToLdapUrl(dn), null, null, ADS_READONLY_SERVER)
: dso.openDSObject(dnToLdapUrl(dn), dn, password, ADS_READONLY_SERVER))
.queryInterface(IADsUser.class);
} catch (ComException e) {
// this is failing
String msg = String.format("Incorrect password for %s DN=%s: error=%08X", username, dn, e.getHRESULT());
LOGGER.log(Level.FINE, String.format("Login failure: Incorrect password for %s DN=%s: error=%08X", username, dn, e.getHRESULT()), e);
throw (BadCredentialsException)new BadCredentialsException(msg).initCause(e);
}
if (usr == null) // the user name was in fact a group
throw new UsernameNotFoundException("User not found: "+ username);
List<GrantedAuthority> groups = new ArrayList<GrantedAuthority>();
for( Com4jObject g : usr.groups() ) {
if (g==null) {
continue; // according to JENKINS-17357 in some environment the collection contains null
}
IADsGroup grp = g.queryInterface(IADsGroup.class);
// cut "CN=" and make that the role name
groups.add(new GrantedAuthorityImpl(grp.name().substring(3)));
}
groups.add(SecurityRealm.AUTHENTICATED_AUTHORITY);
LOGGER.log(Level.FINE, "Login successful: {0} dn={1}", new Object[] {username, dn});
return new ActiveDirectoryUserDetail(
username, password,
!isAccountDisabled(usr),
true, true, true,
groups.toArray(new GrantedAuthority[groups.size()]),
getFullName(usr), getEmailAddress(usr), getTelephoneNumber(usr)
).updateUserInfo();
} finally {
col.disposeAll();
COM4J.removeListener(col);
}
}
});
} catch (UncheckedExecutionException e) {
Throwable t = e.getCause();
if (t instanceof AuthenticationException) {
AuthenticationException authenticationException = (AuthenticationException)t;
throw authenticationException;
} else {
throw new CacheAuthenticationException("Authentication failed because there was a problem caching user " + username, e);
}
} catch (java.util.concurrent.ExecutionException e) {
LOGGER.log(Level.SEVERE, String.format("There was a problem caching user %s", username), e);
throw new CacheAuthenticationException("Authentication failed because there was a problem caching user " + username, e);
}
}
@Override
protected boolean canRetrieveUserByName(ActiveDirectoryDomain domain) {
return true;
}
private String getTelephoneNumber(IADsUser usr) {
try {
Object t = usr.telephoneNumber();
return t==null ? null : t.toString();
} catch (ComException e) {
if (e.getHRESULT()==0x8000500D) // see http://support.microsoft.com/kb/243440
return null;
throw e;
}
}
private String getEmailAddress(IADsUser usr) {
try {
return usr.emailAddress();
} catch (ComException e) {
if (e.getHRESULT()==0x8000500D) // see http://support.microsoft.com/kb/243440
return null;
throw e;
}
}
private String getFullName(IADsUser usr) {
try {
return usr.fullName();
} catch (ComException e) {
if (e.getHRESULT()==0x8000500D) // see http://support.microsoft.com/kb/243440
return null;
throw e;
}
}
private boolean isAccountDisabled(IADsUser usr) {
try {
return usr.accountDisabled();
} catch (ComException e) {
if (e.getHRESULT()==0x8000500D)
/*
See http://support.microsoft.com/kb/243440 and JENKINS-10086
We suspect this to be caused by old directory items that do not have this value,
so assume this account is enabled.
*/
return false;
throw e;
}
}
private String getDnOfUserOrGroup(String userOrGroupname) throws UsernameNotFoundException {
_Command cmd = ClassFactory.createCommand();
cmd.activeConnection(con);
cmd.commandText("<LDAP://"+defaultNamingContext+">;(sAMAccountName="+userOrGroupname+");distinguishedName;subTree");
_Recordset rs = cmd.execute(null, Variant.getMissing(), -1/*default*/);
if(rs.eof())
throw new UsernameNotFoundException("No such user or group: "+userOrGroupname);
String dn = rs.fields().item("distinguishedName").value().toString();
return dn;
}
public GroupDetails loadGroupByGroupname(final String groupname) {
try {
return groupCache.get(groupname, new Callable<ActiveDirectoryGroupDetails>() {
public ActiveDirectoryGroupDetails call() throws Exception {
ComObjectCollector col = new ComObjectCollector();
COM4J.addListener(col);
try {
// First get the distinguishedName
String dn = getDnOfUserOrGroup(groupname);
IADsOpenDSObject dso = COM4J.getObject(IADsOpenDSObject.class, "LDAP:", null);
IADsGroup group = dso.openDSObject(dnToLdapUrl(dn), null, null, ADS_READONLY_SERVER)
.queryInterface(IADsGroup.class);
// If not a group will throw UserMayOrMayNotExistException
if (group == null) {
throw new UserMayOrMayNotExistException(groupname);
}
return new ActiveDirectoryGroupDetails(groupname);
} catch (UsernameNotFoundException e) {
// failed to convert group name to DN
throw new UsernameNotFoundException("Failed to get the DN of the group " + groupname);
} catch (ComException e) {
// recover gracefully since AD might behave in a way we haven't anticipated
LOGGER.log(Level.WARNING, String.format("Failed to figure out details of AD group: %s", groupname), e);
throw new UserMayOrMayNotExistException(groupname);
} finally {
col.disposeAll();
COM4J.removeListener(col);
}
}
});
} catch (UncheckedExecutionException e) {
Throwable t = e.getCause();
if (t instanceof AuthenticationException) {
AuthenticationException authenticationException = (AuthenticationException)t;
throw authenticationException;
} else {
throw new CacheAuthenticationException("Authentication failed because there was a problem caching group " + groupname, e);
}
} catch (java.util.concurrent.ExecutionException e) {
LOGGER.log(Level.SEVERE, String.format("There was a problem caching group %s", groupname), e);
throw new CacheAuthenticationException("Authentication failed because there was a problem caching group " + groupname, e);
}
}
private static final Logger LOGGER = Logger.getLogger(ActiveDirectoryAuthenticationProvider.class.getName());
/**
* Signify that we can connect to a read-only mirror.
*
* See http://msdn.microsoft.com/en-us/library/windows/desktop/aa772247(v=vs.85).aspx
*/
private static final int ADS_READONLY_SERVER = 0x4;
}