001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2013-2015 ForgeRock AS.
015 */
016package org.forgerock.opendj.rest2ldap.servlet;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.util.Collections;
021import java.util.LinkedHashMap;
022import java.util.Map;
023import java.util.StringTokenizer;
024import java.util.concurrent.atomic.AtomicReference;
025
026import javax.servlet.Filter;
027import javax.servlet.FilterChain;
028import javax.servlet.FilterConfig;
029import javax.servlet.ServletException;
030import javax.servlet.ServletRequest;
031import javax.servlet.ServletResponse;
032import javax.servlet.http.HttpServletRequest;
033import javax.servlet.http.HttpServletResponse;
034
035import org.codehaus.jackson.JsonParser;
036import org.codehaus.jackson.map.ObjectMapper;
037import org.forgerock.json.fluent.JsonValue;
038import org.forgerock.json.fluent.JsonValueException;
039import org.forgerock.json.resource.ResourceException;
040import org.forgerock.json.resource.servlet.ServletApiVersionAdapter;
041import org.forgerock.json.resource.servlet.ServletSynchronizer;
042import org.forgerock.opendj.ldap.AuthenticationException;
043import org.forgerock.opendj.ldap.AuthorizationException;
044import org.forgerock.opendj.ldap.ByteString;
045import org.forgerock.opendj.ldap.Connection;
046import org.forgerock.opendj.ldap.ConnectionFactory;
047import org.forgerock.opendj.ldap.Connections;
048import org.forgerock.opendj.ldap.DN;
049import org.forgerock.opendj.ldap.EntryNotFoundException;
050import org.forgerock.opendj.ldap.LdapException;
051import org.forgerock.opendj.ldap.MultipleEntriesFoundException;
052import org.forgerock.opendj.ldap.ResultCode;
053import org.forgerock.opendj.ldap.SearchScope;
054import org.forgerock.opendj.ldap.requests.BindRequest;
055import org.forgerock.opendj.ldap.requests.SearchRequest;
056import org.forgerock.opendj.ldap.responses.BindResult;
057import org.forgerock.opendj.ldap.responses.SearchResultEntry;
058import org.forgerock.opendj.ldap.schema.Schema;
059import org.forgerock.opendj.rest2ldap.Rest2LDAP;
060import org.forgerock.util.AsyncFunction;
061import org.forgerock.util.promise.ExceptionHandler;
062import org.forgerock.util.promise.Promise;
063import org.forgerock.util.promise.ResultHandler;
064
065import static org.forgerock.json.resource.SecurityContext.*;
066import static org.forgerock.json.resource.servlet.SecurityContextFactory.*;
067import static org.forgerock.opendj.ldap.LdapException.*;
068import static org.forgerock.opendj.ldap.requests.Requests.*;
069import static org.forgerock.opendj.rest2ldap.Rest2LDAP.*;
070import static org.forgerock.opendj.rest2ldap.servlet.Rest2LDAPContextFactory.*;
071
072/**
073 * An LDAP based authentication Servlet filter.
074 * <p>
075 * TODO: this is a work in progress. In particular, in order to embed this into
076 * the OpenDJ HTTP listener it will need to provide a configuration API.
077 */
078public final class Rest2LDAPAuthnFilter implements Filter {
079    /** Indicates how authentication should be performed. */
080    private static enum AuthenticationMethod {
081        SASL_PLAIN, SEARCH_SIMPLE, SIMPLE;
082    }
083
084    private static final String INIT_PARAM_CONFIG_FILE = "config-file";
085    private static final ObjectMapper JSON_MAPPER = new ObjectMapper().configure(
086            JsonParser.Feature.ALLOW_COMMENTS, true);
087
088    private String altAuthenticationPasswordHeader;
089    private String altAuthenticationUsernameHeader;
090    private AuthenticationMethod authenticationMethod = AuthenticationMethod.SEARCH_SIMPLE;
091    private ConnectionFactory bindLDAPConnectionFactory;
092    /** Indicates whether or not authentication should be performed. */
093    private boolean isEnabled;
094    private boolean reuseAuthenticatedConnection = true;
095    private String saslAuthzIdTemplate;
096    private final Schema schema = Schema.getDefaultSchema();
097    private DN searchBaseDN;
098    private String searchFilterTemplate;
099    private ConnectionFactory searchLDAPConnectionFactory;
100    private SearchScope searchScope = SearchScope.WHOLE_SUBTREE;
101    private boolean supportAltAuthentication;
102    private boolean supportHTTPBasicAuthentication = true;
103    private ServletApiVersionAdapter syncFactory;
104
105    @Override
106    public void destroy() {
107        if (searchLDAPConnectionFactory != null) {
108            searchLDAPConnectionFactory.close();
109        }
110        if (bindLDAPConnectionFactory != null) {
111            bindLDAPConnectionFactory.close();
112        }
113    }
114
115    @Override
116    public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
117            throws IOException, ServletException {
118        // Skip this filter if authentication has not been configured.
119        if (!isEnabled) {
120            chain.doFilter(request, response);
121            return;
122        }
123
124        // First of all parse the HTTP headers for authentication credentials.
125        if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
126            // This should never happen.
127            throw new ServletException("non-HTTP request or response");
128        }
129
130        // TODO: support logout, sessions, reauth?
131        final HttpServletRequest req = (HttpServletRequest) request;
132        final HttpServletResponse res = (HttpServletResponse) response;
133
134        /*
135         * Store the authenticated connection so that it can be re-used by the
136         * servlet if needed. However, make sure that it is closed on
137         * completion.
138         */
139        final AtomicReference<Connection> savedConnection = new AtomicReference<>();
140        final ServletSynchronizer sync = syncFactory.createServletSynchronizer(req, res);
141
142        sync.addAsyncListener(new Runnable() {
143            @Override
144            public void run() {
145                closeConnection(savedConnection);
146            }
147        });
148
149        try {
150            final String headerUsername = supportAltAuthentication ? req.getHeader(altAuthenticationUsernameHeader)
151                    : null;
152            final String headerPassword = supportAltAuthentication ? req.getHeader(altAuthenticationPasswordHeader)
153                    : null;
154            final String headerAuthorization = supportHTTPBasicAuthentication ? req.getHeader("Authorization") : null;
155
156            final String username;
157            final char[] password;
158            if (headerUsername != null) {
159                if (headerPassword == null || headerUsername.isEmpty() || headerPassword.isEmpty()) {
160                    throw ResourceException.getException(401);
161                }
162                username = headerUsername;
163                password = headerPassword.toCharArray();
164            } else if (headerAuthorization != null) {
165                final StringTokenizer st = new StringTokenizer(headerAuthorization);
166                final String method = st.nextToken();
167                if (method == null || !HttpServletRequest.BASIC_AUTH.equalsIgnoreCase(method)) {
168                    throw ResourceException.getException(401);
169                }
170                final String b64Credentials = st.nextToken();
171                if (b64Credentials == null) {
172                    throw ResourceException.getException(401);
173                }
174                final String credentials = ByteString.valueOfBase64(b64Credentials).toString();
175                final String[] usernameAndPassword = credentials.split(":");
176                if (usernameAndPassword.length != 2) {
177                    throw ResourceException.getException(401);
178                }
179                username = usernameAndPassword[0];
180                password = usernameAndPassword[1].toCharArray();
181            } else {
182                throw ResourceException.getException(401);
183            }
184
185            // If we've got here then we have a username and password.
186            switch (authenticationMethod) {
187            case SIMPLE: {
188                final Map<String, Object> authzid;
189                authzid = new LinkedHashMap<>(2);
190                authzid.put(AUTHZID_DN, username);
191                authzid.put(AUTHZID_ID, username);
192                doBind(req, res, newSimpleBindRequest(username, password), chain, savedConnection, sync, username,
193                        authzid);
194                break;
195            }
196            case SASL_PLAIN: {
197                final Map<String, Object> authzid;
198                final String bindId;
199                if (saslAuthzIdTemplate.startsWith("dn:")) {
200                    final String bindDN = DN.format(saslAuthzIdTemplate.substring(3), schema, username).toString();
201                    bindId = "dn:" + bindDN;
202                    authzid = new LinkedHashMap<>(2);
203                    authzid.put(AUTHZID_DN, bindDN);
204                    authzid.put(AUTHZID_ID, username);
205                } else {
206                    bindId = String.format(saslAuthzIdTemplate, username);
207                    authzid = Collections.singletonMap(AUTHZID_ID, (Object) username);
208                }
209                doBind(req, res, newPlainSASLBindRequest(bindId, password), chain, savedConnection, sync, username,
210                        authzid);
211                break;
212            }
213            default: // SEARCH_SIMPLE
214            {
215                /*
216                 * First do a search to find the user's entry and then perform a
217                 * bind request using the user's DN.
218                 */
219                final org.forgerock.opendj.ldap.Filter filter = org.forgerock.opendj.ldap.Filter.format(
220                        searchFilterTemplate, username);
221                final SearchRequest searchRequest = newSearchRequest(searchBaseDN, searchScope, filter, "1.1");
222                searchLDAPConnectionFactory.getConnectionAsync()
223                        .thenAsync(new AsyncFunction<Connection, SearchResultEntry, LdapException>() {
224                            @Override
225                            public Promise<SearchResultEntry, LdapException> apply(Connection connection)
226                                    throws LdapException {
227                                savedConnection.set(connection);
228                                // Do the search.
229                                return connection.searchSingleEntryAsync(searchRequest);
230                            }
231                        }).thenOnResult(new ResultHandler<SearchResultEntry>() {
232                            @Override
233                            public void handleResult(final SearchResultEntry result) {
234                                savedConnection.get().close();
235                                final String bindDN = result.getName().toString();
236                                final Map<String, Object> authzid = new LinkedHashMap<>(2);
237                                authzid.put(AUTHZID_DN, bindDN);
238                                authzid.put(AUTHZID_ID, username);
239                                doBind(req, res, newSimpleBindRequest(bindDN, password), chain, savedConnection, sync,
240                                        username, authzid);
241                            }
242                        }).thenOnException(new ExceptionHandler<LdapException>() {
243                            @Override
244                            public void handleException(final LdapException exception) {
245                                LdapException normalizedError = exception;
246                                if (savedConnection.get() != null) {
247                                    savedConnection.get().close();
248                                    /*
249                                     * The search error should not be passed
250                                     * as-is back to the user.
251                                     */
252                                    if (exception instanceof EntryNotFoundException
253                                            || exception instanceof MultipleEntriesFoundException) {
254                                        normalizedError = newLdapException(ResultCode.INVALID_CREDENTIALS, exception);
255                                    } else if (exception instanceof AuthenticationException
256                                            || exception instanceof AuthorizationException) {
257                                        normalizedError =
258                                            newLdapException(ResultCode.CLIENT_SIDE_LOCAL_ERROR, exception);
259                                    } else {
260                                        normalizedError = exception;
261                                    }
262                                }
263                                sync.signalAndComplete(asResourceException(normalizedError));
264                            }
265                        });
266                break;
267            }
268            }
269            sync.awaitIfNeeded();
270            if (!sync.isAsync()) {
271                chain.doFilter(request, response);
272            }
273        } catch (final Throwable t) {
274            sync.signalAndComplete(t);
275        } finally {
276            if (!sync.isAsync()) {
277                closeConnection(savedConnection);
278            }
279        }
280    }
281
282    @Override
283    public void init(final FilterConfig config) throws ServletException {
284        // FIXME: make it possible to configure the filter externally, especially
285        // connection factories.
286        final String configFileName = config.getInitParameter(INIT_PARAM_CONFIG_FILE);
287        if (configFileName == null) {
288            throw new ServletException("Authentication filter initialization parameter '"
289                    + INIT_PARAM_CONFIG_FILE + "' not specified");
290        }
291        final InputStream configFile =
292                config.getServletContext().getResourceAsStream(configFileName);
293        if (configFile == null) {
294            throw new ServletException("Servlet filter configuration file '" + configFileName
295                    + "' not found");
296        }
297        try {
298            // Parse the config file.
299            final Object content = JSON_MAPPER.readValue(configFile, Object.class);
300            if (!(content instanceof Map)) {
301                throw new ServletException("Servlet filter configuration file '" + configFileName
302                        + "' does not contain a valid JSON configuration");
303            }
304
305            // Parse the authentication configuration.
306            final JsonValue configuration = new JsonValue(content);
307            final JsonValue authnConfig = configuration.get("authenticationFilter");
308            if (!authnConfig.isNull()) {
309                supportHTTPBasicAuthentication =
310                        authnConfig.get("supportHTTPBasicAuthentication").required().asBoolean();
311
312                // Alternative HTTP authentication.
313                supportAltAuthentication =
314                        authnConfig.get("supportAltAuthentication").required().asBoolean();
315                if (supportAltAuthentication) {
316                    altAuthenticationUsernameHeader =
317                            authnConfig.get("altAuthenticationUsernameHeader").required()
318                                    .asString();
319                    altAuthenticationPasswordHeader =
320                            authnConfig.get("altAuthenticationPasswordHeader").required()
321                                    .asString();
322                }
323
324                // Should the authenticated connection should be cached for use by subsequent LDAP operations?
325                reuseAuthenticatedConnection =
326                        authnConfig.get("reuseAuthenticatedConnection").required().asBoolean();
327
328                // Parse the authentication method and associated parameters.
329                authenticationMethod = parseAuthenticationMethod(authnConfig);
330                switch (authenticationMethod) {
331                case SIMPLE:
332                    // Nothing to do.
333                    break;
334                case SASL_PLAIN:
335                    saslAuthzIdTemplate =
336                            authnConfig.get("saslAuthzIdTemplate").required().asString();
337                    break;
338                case SEARCH_SIMPLE:
339                    searchBaseDN =
340                            DN.valueOf(authnConfig.get("searchBaseDN").required().asString(),
341                                    schema);
342                    searchScope = parseSearchScope(authnConfig);
343                    searchFilterTemplate =
344                            authnConfig.get("searchFilterTemplate").required().asString();
345
346                    // Parse the LDAP connection factory to be used for searches.
347                    final String ldapFactoryName =
348                            authnConfig.get("searchLDAPConnectionFactory").required().asString();
349                    searchLDAPConnectionFactory =
350                            Rest2LDAP.configureConnectionFactory(configuration.get(
351                                    "ldapConnectionFactories").required(), ldapFactoryName);
352                    break;
353                }
354
355                // Parse the LDAP connection factory to be used for binds.
356                final String ldapFactoryName =
357                        authnConfig.get("bindLDAPConnectionFactory").required().asString();
358                bindLDAPConnectionFactory =
359                        Rest2LDAP.configureConnectionFactory(configuration.get(
360                                "ldapConnectionFactories").required(), ldapFactoryName);
361
362                // Set the completion handler factory based on the Servlet API version.
363                syncFactory = ServletApiVersionAdapter.getInstance(config.getServletContext());
364
365                isEnabled = true;
366            }
367        } catch (final ServletException e) {
368            // Rethrow.
369            throw e;
370        } catch (final Exception e) {
371            throw new ServletException("Servlet filter configuration file '" + configFileName
372                    + "' could not be read: " + e.getMessage());
373        } finally {
374            try {
375                configFile.close();
376            } catch (final Exception e) {
377                // Ignore.
378            }
379        }
380    }
381
382    private void closeConnection(final AtomicReference<Connection> savedConnection) {
383        final Connection connection = savedConnection.get();
384        if (connection != null) {
385            connection.close();
386        }
387    }
388
389    /**
390     * Get a bind connection and then perform the bind operation, setting the
391     * cached connection and authorization credentials on completion.
392     */
393    private void doBind(final HttpServletRequest request, final ServletResponse response,
394            final BindRequest bindRequest, final FilterChain chain, final AtomicReference<Connection> savedConnection,
395            final ServletSynchronizer sync, final String authcid, final Map<String, Object> authzid) {
396        bindLDAPConnectionFactory.getConnectionAsync()
397                .thenAsync(new AsyncFunction<Connection, BindResult, LdapException>() {
398                    @Override
399                    public Promise<BindResult, LdapException> apply(Connection connection)
400                            throws LdapException {
401                        savedConnection.set(connection);
402                        return connection.bindAsync(bindRequest);
403                    }
404                }).thenOnResult(new ResultHandler<BindResult>() {
405                    @Override
406                    public void handleResult(final BindResult result) {
407                        /*
408                         * Cache the pre-authenticated connection and prevent
409                         * downstream components from closing it since this
410                         * filter will close it.
411                         */
412                        if (reuseAuthenticatedConnection) {
413                            request.setAttribute(ATTRIBUTE_AUTHN_CONNECTION,
414                                    Connections.uncloseable(savedConnection.get()));
415                        }
416
417                        // Pass through the authentication ID and authorization principals.
418                        request.setAttribute(ATTRIBUTE_AUTHCID, authcid);
419                        request.setAttribute(ATTRIBUTE_AUTHZID, authzid);
420
421                        // Invoke the remainder of the filter chain.
422                        sync.signal();
423                        if (sync.isAsync()) {
424                            try {
425                                chain.doFilter(request, response);
426
427                                /*
428                                 * Fix for OPENDJ-1105: Jetty 8 a bug where
429                                 * synchronous downstream completion (i.e. in
430                                 * the servlet) is ignored due to upstream
431                                 * active async context. The following code
432                                 * should be benign in other containers.
433                                 */
434                                if (response.isCommitted()) {
435                                    sync.signalAndComplete();
436                                }
437                            } catch (Throwable t) {
438                                sync.signalAndComplete(asResourceException(t));
439                            }
440                        }
441                    }
442                }).thenOnException(new ExceptionHandler<LdapException>() {
443                    @Override
444                    public void handleException(final LdapException exception) {
445                        sync.signalAndComplete(asResourceException(exception));
446                    }
447                });
448    }
449
450    private AuthenticationMethod parseAuthenticationMethod(final JsonValue configuration) {
451        if (configuration.isDefined("method")) {
452            final String method = configuration.get("method").asString();
453            if ("simple".equalsIgnoreCase(method)) {
454                return AuthenticationMethod.SIMPLE;
455            } else if ("sasl-plain".equalsIgnoreCase(method)) {
456                return AuthenticationMethod.SASL_PLAIN;
457            } else if ("search-simple".equalsIgnoreCase(method)) {
458                return AuthenticationMethod.SEARCH_SIMPLE;
459            } else {
460                throw new JsonValueException(configuration,
461                        "Illegal authentication method: must be either 'simple', "
462                                + "'sasl-plain', or 'search-simple'");
463            }
464        } else {
465            return AuthenticationMethod.SEARCH_SIMPLE;
466        }
467    }
468
469    private SearchScope parseSearchScope(final JsonValue configuration) {
470        if (configuration.isDefined("searchScope")) {
471            final String scope = configuration.get("searchScope").asString();
472            if ("sub".equalsIgnoreCase(scope)) {
473                return SearchScope.WHOLE_SUBTREE;
474            } else if ("one".equalsIgnoreCase(scope)) {
475                return SearchScope.SINGLE_LEVEL;
476            } else {
477                throw new JsonValueException(configuration,
478                        "Illegal search scope: must be either 'sub' or 'one'");
479            }
480        } else {
481            return SearchScope.WHOLE_SUBTREE;
482        }
483    }
484
485}