package org.rakam.ui.user.ldap; import com.google.common.base.Throwables; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.UncheckedExecutionException; import com.google.inject.name.Named; import io.airlift.log.Logger; import org.rakam.analysis.JDBCPoolDataSource; import org.rakam.config.JDBCConfig; import org.rakam.ui.AuthService; import org.rakam.ui.RakamUIConfig; import org.rakam.util.RakamException; import org.skife.jdbi.v2.DBI; import org.skife.jdbi.v2.Handle; import javax.annotation.Nonnull; import javax.inject.Inject; import javax.naming.NamingEnumeration; import javax.naming.NamingException; import javax.naming.directory.DirContext; import javax.naming.directory.InitialDirContext; import javax.naming.directory.SearchControls; import javax.naming.directory.SearchResult; import java.security.Principal; import java.util.Hashtable; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutionException; import static com.google.common.base.CharMatcher.JAVA_ISO_CONTROL; import static com.google.common.base.MoreObjects.toStringHelper; import static com.google.common.base.Preconditions.checkState; import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN; import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR; import static io.netty.handler.codec.http.HttpResponseStatus.UNAUTHORIZED; import static java.lang.String.format; import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static javax.naming.Context.INITIAL_CONTEXT_FACTORY; import static javax.naming.Context.PROVIDER_URL; import static javax.naming.Context.SECURITY_AUTHENTICATION; import static javax.naming.Context.SECURITY_CREDENTIALS; import static javax.naming.Context.SECURITY_PRINCIPAL; public class LdapAuthService implements AuthService { private static final Logger log = Logger.get(LdapAuthService.class); private static final String LDAP_CONTEXT_FACTORY = "com.sun.jndi.ldap.LdapCtxFactory"; private final String ldapUrl; private final String userBindSearchPattern; private final Optional<String> groupAuthorizationSearchPattern; private final Optional<String> userBaseDistinguishedName; private final Map<String, String> basicEnvironment; private final LoadingCache<Credentials, Principal> authenticationCache; private final DBI dbi; @Inject public LdapAuthService( LdapConfig serverConfig, RakamUIConfig config, @Named("ui.metadata.jdbc") JDBCPoolDataSource dataSource) { this.dbi = new DBI(dataSource); this.ldapUrl = requireNonNull(serverConfig.getLdapUrl(), "ldapUrl is null"); this.userBindSearchPattern = requireNonNull(serverConfig.getUserBindSearchPattern(), "userBindSearchPattern is null"); this.groupAuthorizationSearchPattern = Optional.ofNullable(serverConfig.getGroupAuthorizationSearchPattern()); this.userBaseDistinguishedName = Optional.ofNullable(serverConfig.getUserBaseDistinguishedName()); if (groupAuthorizationSearchPattern.isPresent()) { checkState(userBaseDistinguishedName.isPresent(), "Base distinguished name (DN) for user is null"); } if (config.getHashPassword()) { throw new IllegalStateException("You can't enable password hashing if you're using LDAP as authentication management. Please set ui.hash-password=false"); } Map<String, String> environment = ImmutableMap.<String, String>builder() .put(INITIAL_CONTEXT_FACTORY, LDAP_CONTEXT_FACTORY) .put(PROVIDER_URL, ldapUrl) .build(); this.basicEnvironment = environment; this.authenticationCache = CacheBuilder.newBuilder() .expireAfterWrite(serverConfig.getLdapCacheTtl().toMillis(), MILLISECONDS) .build(new CacheLoader<Credentials, Principal>() { @Override public Principal load(@Nonnull Credentials key) { return authenticate(key.getUser(), key.getPassword()); } }); } public boolean login(String username, String password) { try { Credentials key = new Credentials(username, password); authenticationCache.refresh(key); authenticationCache.get(key); } catch (UncheckedExecutionException e) { throw Throwables.propagate(e.getCause()); } catch (ExecutionException e) { return false; } return true; } @Override public void checkAccess(int userId) { try (Handle handle = dbi.open()) { Boolean exists = handle.createQuery("select email, password from web_user where id = :id") .bind("id", userId).map((index, r, ctx) -> { String email = r.getString(1); String password = r.getString(2); try { Principal principal = authenticationCache.get(new Credentials(email, password)); if (principal == null) { throw new RakamException("LDAP user doesn't exist", FORBIDDEN); } } catch (UncheckedExecutionException e) { throw Throwables.propagate(e.getCause()); } catch (ExecutionException e) { throw new RakamException("LDAP user doesn't exist", FORBIDDEN); } return Boolean.TRUE; }).first(); if (!Boolean.TRUE.equals(exists)) { throw new RakamException("LDAP user doesn't exist in database", FORBIDDEN); } } } public static InitialDirContext getInitialDirContext(Map<String, String> environment) throws NamingException { return new InitialDirContext(new Hashtable<>(environment)); } private static InitialDirContext createDirContext(Map<String, String> environment) throws NamingException { return getInitialDirContext(environment); } public Principal authenticate(String user, String password) throws RakamException { Map<String, String> environment = createEnvironment(user, password); InitialDirContext context; try { context = createDirContext(environment); checkForGroupMembership(user, context); log.debug("Authentication successful for user %s", user); return new LdapPrincipal(user); } catch (javax.naming.AuthenticationException e) { String formattedAsciiMessage = format("Invalid credentials: %s", JAVA_ISO_CONTROL.removeFrom(e.getMessage())); log.debug("Authentication failed for user [%s]. %s", user, e.getMessage()); throw new RakamException(formattedAsciiMessage, UNAUTHORIZED); } catch (NamingException e) { log.debug("Authentication failed", e.getMessage()); throw new RakamException("Authentication failed", INTERNAL_SERVER_ERROR); } } private Map<String, String> createEnvironment(String user, String password) { return ImmutableMap.<String, String>builder() .putAll(basicEnvironment) .put(SECURITY_AUTHENTICATION, "simple") .put(SECURITY_PRINCIPAL, createPrincipal(user)) .put(SECURITY_CREDENTIALS, password) .build(); } private String createPrincipal(String user) { return replaceUser(userBindSearchPattern, user); } private String replaceUser(String pattern, String user) { return pattern.replaceAll("\\$\\{USER\\}", user); } private void checkForGroupMembership(String user, DirContext context) { if (!groupAuthorizationSearchPattern.isPresent()) { return; } String searchFilter = replaceUser(groupAuthorizationSearchPattern.get(), user); SearchControls searchControls = new SearchControls(); searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE); boolean authorized; NamingEnumeration<SearchResult> search = null; try { search = context.search(userBaseDistinguishedName.get(), searchFilter, searchControls); authorized = search.hasMoreElements(); } catch (NamingException e) { log.debug("Authentication failed", e.getMessage()); throw new RakamException("Authentication failed: " + e.getMessage(), INTERNAL_SERVER_ERROR); } finally { if (search != null) { try { search.close(); } catch (NamingException ignore) { } } } if (!authorized) { String message = format("Unauthorized user: User %s not a member of the authorized group", user); log.debug("Authorization failed for user. " + message); throw new RakamException(message, UNAUTHORIZED); } log.debug("Authorization succeeded for user %s", user); } private static final class LdapPrincipal implements Principal { private final String name; private LdapPrincipal(String name) { this.name = requireNonNull(name, "name is null"); } @Override public String getName() { return name; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } LdapPrincipal that = (LdapPrincipal) o; return Objects.equals(name, that.name); } @Override public int hashCode() { return Objects.hash(name); } @Override public String toString() { return name; } } private static class Credentials { private final String user; private final String password; private Credentials(String user, String password) { this.user = requireNonNull(user); this.password = requireNonNull(password); } public String getUser() { return user; } public String getPassword() { return password; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Credentials that = (Credentials) o; return Objects.equals(this.user, that.user) && Objects.equals(this.password, that.password); } @Override public int hashCode() { return Objects.hash(user, password); } @Override public String toString() { return toStringHelper(this) .add("user", user) .add("password", password) .toString(); } } public static void main(String[] args) { LdapConfig ldapConfig = new LdapConfig(); ldapConfig.setLdapUrl("ldap://ldap.forumsys.com:389") .setUserBindSearchPattern("cn=read-only-admin,dc=example,dc=com"); LdapAuthService ldapAuth = new LdapAuthService(ldapConfig, new RakamUIConfig(), null); ldapAuth.authenticate("riemann", "password"); } }