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}