/*
* CDDL HEADER START
*
* The contents of this file are subject to the terms of the
* Common Development and Distribution License, Version 1.0 only
* (the "License"). You may not use this file except in compliance
* with the License.
*
* You can obtain a copy of the license at
* trunk/opends/resource/legal-notices/OpenDS.LICENSE
* or https://OpenDS.dev.java.net/OpenDS.LICENSE.
* See the License for the specific language governing permissions
* and limitations under the License.
*
* When distributing Covered Code, include this CDDL HEADER in each
* file and include the License file at
* trunk/opends/resource/legal-notices/OpenDS.LICENSE. If applicable,
* add the following below this CDDL HEADER, with the fields enclosed
* by brackets "[]" replaced with your own identifying information:
* Portions Copyright [yyyy] [name of copyright owner]
*
* CDDL HEADER END
*
*
* Copyright 2013 ForgeRock AS
*/
package org.opends.server.protocols.http;
import static org.forgerock.opendj.adapter.server2x.Converters.*;
import static org.opends.messages.ProtocolMessages.*;
import static org.opends.server.loggers.AccessLogger.*;
import static org.opends.server.loggers.ErrorLogger.*;
import static org.opends.server.loggers.debug.DebugLogger.*;
import static org.opends.server.util.StaticUtils.*;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.Charset;
import java.text.ParseException;
import java.util.Collection;
import javax.servlet.AsyncContext;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import org.forgerock.json.resource.ResourceException;
import org.forgerock.opendj.ldap.Connection;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.ErrorResultException;
import org.forgerock.opendj.ldap.Filter;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.ResultHandler;
import org.forgerock.opendj.ldap.requests.BindRequest;
import org.forgerock.opendj.ldap.requests.Requests;
import org.forgerock.opendj.ldap.requests.SearchRequest;
import org.forgerock.opendj.ldap.responses.BindResult;
import org.forgerock.opendj.ldap.responses.SearchResultEntry;
import org.forgerock.opendj.rest2ldap.Rest2LDAP;
import org.forgerock.opendj.rest2ldap.servlet.Rest2LDAPContextFactory;
import org.opends.messages.Message;
import org.opends.server.admin.std.server.ConnectionHandlerCfg;
import org.opends.server.loggers.debug.DebugTracer;
import org.opends.server.schema.SchemaConstants;
import org.opends.server.types.AddressMask;
import org.opends.server.types.AuthenticationInfo;
import org.opends.server.types.ByteString;
import org.opends.server.types.DebugLogLevel;
import org.opends.server.types.DisconnectReason;
import org.opends.server.util.Base64;
/**
* Servlet {@link Filter} that collects information about client connections.
*/
final class CollectClientConnectionsFilter implements javax.servlet.Filter
{
/** This class holds all the necessary data to complete an HTTP request. */
private static final class HTTPRequestContext
{
private AsyncContext asyncContext;
private HttpServletRequest request;
private HttpServletResponse response;
private FilterChain chain;
private HTTPClientConnection clientConnection;
private Connection connection;
/** Whether to pretty print the resulting JSON. */
private boolean prettyPrint;
/** Used for the bind request when credentials are specified. */
private String userName;
/** Used for the bind request when credentials are specified. */
private String password;
}
/**
* This result handler invokes a bind after a successful search on the user
* name used for authentication.
*/
private final class DoBindResultHandler implements
ResultHandler<SearchResultEntry>
{
private HTTPRequestContext ctx;
private DoBindResultHandler(HTTPRequestContext ctx)
{
this.ctx = ctx;
}
@Override
public void handleErrorResult(ErrorResultException error)
{
final ResultCode rc = error.getResult().getResultCode();
if (ResultCode.CLIENT_SIDE_NO_RESULTS_RETURNED.equals(rc)
|| ResultCode.CLIENT_SIDE_UNEXPECTED_RESULTS_RETURNED.equals(rc))
{
// Avoid information leak:
// do not hint to the user that it is the username that is invalid
sendAuthenticationFailure(ctx);
}
else
{
onFailure(error, ctx);
}
}
@Override
public void handleResult(SearchResultEntry resultEntry)
{
final DN bindDN = resultEntry.getName();
if (bindDN == null)
{
sendAuthenticationFailure(ctx);
}
else
{
final BindRequest bindRequest =
Requests.newSimpleBindRequest(bindDN.toString(), ctx.password
.getBytes(Charset.forName("UTF-8")));
ctx.connection.bindAsync(bindRequest, null,
new CallDoFilterResultHandler(ctx, resultEntry));
}
}
}
/**
* This result handler calls {@link javax.servlet.Filter#doFilter()} after a
* successful bind.
*/
private final class CallDoFilterResultHandler implements
ResultHandler<BindResult>
{
private final HTTPRequestContext ctx;
private final SearchResultEntry resultEntry;
private CallDoFilterResultHandler(HTTPRequestContext ctx,
SearchResultEntry resultEntry)
{
this.ctx = ctx;
this.resultEntry = resultEntry;
}
@Override
public void handleErrorResult(ErrorResultException error)
{
onFailure(error, ctx);
}
@Override
public void handleResult(BindResult result)
{
ctx.clientConnection.setAuthUser(ctx.userName);
final AuthenticationInfo authInfo =
new AuthenticationInfo(to(resultEntry), to(resultEntry.getName()),
ByteString.valueOf(ctx.password), false);
try
{
doFilter(ctx, authInfo);
}
catch (Exception e)
{
onFailure(e, ctx);
}
}
}
/** HTTP Header sent by the client with HTTP basic authentication. */
static final String HTTP_BASIC_AUTH_HEADER = "Authorization";
/** The tracer object for the debug logger. */
private static final DebugTracer TRACER = getTracer();
/** The connection handler that created this servlet filter. */
private final HTTPConnectionHandler connectionHandler;
/**
* Configures how to perform the search for the username prior to
* authentication.
*/
private final HTTPAuthenticationConfig authConfig;
/**
* Constructs a new instance of this class.
*
* @param connectionHandler
* the connection handler that accepted this connection
* @param authenticationConfig
* configures how to perform the search for the username prior to
* authentication
*/
public CollectClientConnectionsFilter(
HTTPConnectionHandler connectionHandler,
HTTPAuthenticationConfig authenticationConfig)
{
this.connectionHandler = connectionHandler;
this.authConfig = authenticationConfig;
}
/** {@inheritDoc} */
@Override
public void init(FilterConfig filterConfig) throws ServletException
{
// nothing to do
}
/** {@inheritDoc} */
@Override
public void doFilter(ServletRequest req, ServletResponse resp,
FilterChain chain)
{
final HttpServletRequest request = (HttpServletRequest) req;
final HttpServletResponse response = (HttpServletResponse) resp;
final HTTPRequestContext ctx = new HTTPRequestContext();
ctx.request = request;
ctx.response = new HttpServletResponseWrapper(response)
{
/** {@inheritDoc} */
@Override
public void setStatus(int sc)
{
ctx.clientConnection.log(sc);
super.setStatus(sc);
}
/** {@inheritDoc} */
@SuppressWarnings("deprecation")
@Override
public void setStatus(int sc, String sm)
{
ctx.clientConnection.log(sc);
super.setStatus(sc, sm);
}
};
ctx.chain = chain;
ctx.prettyPrint =
Boolean.parseBoolean(request.getParameter("_prettyPrint"));
final HTTPClientConnection clientConnection =
new HTTPClientConnection(this.connectionHandler, request);
this.connectionHandler.addClientConnection(clientConnection);
ctx.clientConnection = clientConnection;
if (this.connectionHandler.keepStats()) {
this.connectionHandler.getStatTracker().addRequest(
ctx.clientConnection.getMethod());
}
try
{
if (!canProcessRequest(request, clientConnection))
{
return;
}
// logs the connect after all the possible disconnect reasons have been
// checked.
logConnect(clientConnection);
ctx.connection = new SdkConnectionAdapter(clientConnection);
final String[] userPassword = extractUsernamePassword(request);
if (userPassword != null && userPassword.length == 2)
{
ctx.userName = userPassword[0];
ctx.password = userPassword[1];
ctx.asyncContext = getAsyncContext(request);
ctx.connection.searchSingleEntryAsync(buildSearchRequest(ctx.userName),
new DoBindResultHandler(ctx));
}
else if (this.connectionHandler.acceptUnauthenticatedRequests())
{
// use unauthenticated user
doFilter(ctx, new AuthenticationInfo());
}
else
{
sendAuthenticationFailure(ctx);
}
}
catch (Exception e)
{
onFailure(e, ctx);
}
}
private void doFilter(HTTPRequestContext ctx, AuthenticationInfo authInfo)
throws Exception
{
ctx.clientConnection.setAuthenticationInfo(authInfo);
/*
* WARNING: This action triggers 3-4 others: Set the connection for use with
* this request on the HttpServletRequest. It will make
* Rest2LDAPContextFactory create an AuthenticatedConnectionContext which
* will in turn ensure Rest2LDAP uses the supplied Connection object.
*/
ctx.request.setAttribute(
Rest2LDAPContextFactory.ATTRIBUTE_AUTHN_CONNECTION, ctx.connection);
// send the request further down the filter chain or pass to servlet
ctx.chain.doFilter(ctx.request, ctx.response);
}
private void sendAuthenticationFailure(HTTPRequestContext ctx)
{
final int statusCode = HttpServletResponse.SC_UNAUTHORIZED;
try
{
// The user could not be authenticated. Send an HTTP Basic authentication
// challenge if HTTP Basic authentication is enabled.
ResourceException unauthorizedException =
ResourceException.getException(statusCode, "Invalid Credentials");
sendErrorReponse(ctx.response, ctx.prettyPrint, unauthorizedException);
ctx.clientConnection.disconnect(DisconnectReason.INVALID_CREDENTIALS,
false, null);
}
finally
{
ctx.clientConnection.log(statusCode);
if (ctx.asyncContext != null)
{
ctx.asyncContext.complete();
}
}
}
private void onFailure(Exception e, HTTPRequestContext ctx)
{
ResourceException ex = Rest2LDAP.asResourceException(e);
try
{
if (debugEnabled())
{
TRACER.debugCaught(DebugLogLevel.ERROR, e);
}
sendErrorReponse(ctx.response, ctx.prettyPrint, ex);
Message message =
INFO_CONNHANDLER_UNABLE_TO_REGISTER_CLIENT.get(ctx.clientConnection
.getClientHostPort(), ctx.clientConnection.getServerHostPort(),
getExceptionMessage(e));
logError(message);
ctx.clientConnection.disconnect(DisconnectReason.SERVER_ERROR, false,
message);
}
finally
{
ctx.clientConnection.log(ex.getCode());
if (ctx.asyncContext != null)
{
ctx.asyncContext.complete();
}
}
}
private boolean canProcessRequest(HttpServletRequest request,
final HTTPClientConnection clientConnection) throws UnknownHostException
{
InetAddress clientAddr = InetAddress.getByName(request.getRemoteAddr());
// Check to see if the core server rejected the
// connection (e.g., already too many connections
// established).
if (clientConnection.getConnectionID() < 0)
{
clientConnection.disconnect(DisconnectReason.ADMIN_LIMIT_EXCEEDED, true,
ERR_CONNHANDLER_REJECTED_BY_SERVER.get());
return false;
}
// Check to see if the client is on the denied list.
// If so, then reject it immediately.
ConnectionHandlerCfg config = this.connectionHandler.getCurrentConfig();
Collection<AddressMask> allowedClients = config.getAllowedClient();
Collection<AddressMask> deniedClients = config.getDeniedClient();
if (!deniedClients.isEmpty()
&& AddressMask.maskListContains(clientAddr, deniedClients))
{
clientConnection.disconnect(DisconnectReason.CONNECTION_REJECTED, false,
ERR_CONNHANDLER_DENIED_CLIENT.get(clientConnection
.getClientHostPort(), clientConnection.getServerHostPort()));
return false;
}
// Check to see if there is an allowed list and if
// there is whether the client is on that list. If
// not, then reject the connection.
if (!allowedClients.isEmpty()
&& !AddressMask.maskListContains(clientAddr, allowedClients))
{
clientConnection.disconnect(DisconnectReason.CONNECTION_REJECTED, false,
ERR_CONNHANDLER_DISALLOWED_CLIENT.get(clientConnection
.getClientHostPort(), clientConnection.getServerHostPort()));
return false;
}
return true;
}
/**
* Extracts the username and password from the request using one of the
* enabled authentication mechanism: HTTP Basic authentication or HTTP Custom
* headers. If no username and password can be obtained, then send back an
* HTTP basic authentication challenge if HTTP basic authentication is
* enabled.
*
* @param request
* the request where to extract the username and password from
* @return the array containing the username/password couple if both exist,
* null otherwise
* @throws ResourceException
* if any error occur
*/
String[] extractUsernamePassword(HttpServletRequest request)
throws ResourceException
{
// TODO Use session to reduce hits with search + bind?
// Use proxied authorization control for session.
if (authConfig.isCustomHeadersAuthenticationSupported())
{
final String userName =
request.getHeader(authConfig.getCustomHeaderUsername());
final String password =
request.getHeader(authConfig.getCustomHeaderPassword());
if (userName != null && password != null)
{
return new String[] { userName, password };
}
}
if (authConfig.isBasicAuthenticationSupported())
{
String httpBasicAuthHeader = request.getHeader(HTTP_BASIC_AUTH_HEADER);
if (httpBasicAuthHeader != null)
{
String[] userPassword = parseUsernamePassword(httpBasicAuthHeader);
if (userPassword != null)
{
return userPassword;
}
}
}
return null;
}
/**
* Sends an error response back to the client. If the error response is
* "Unauthorized", then it will send a challenge for HTTP Basic authentication
* if HTTP Basic authentication is enabled.
*
* @param response
* where to send the Unauthorized status code.
* @param prettyPrint
* whether to format the JSON document output
* @param re
* the resource exception with the error response content
*/
void sendErrorReponse(HttpServletResponse response, boolean prettyPrint,
ResourceException re)
{
response.setStatus(re.getCode());
if (re.getCode() == HttpServletResponse.SC_UNAUTHORIZED
&& authConfig.isBasicAuthenticationSupported())
{
response.setHeader("WWW-Authenticate",
"Basic realm=\"org.forgerock.opendj\"");
}
try
{
// Send error JSON document out
response.setHeader("Content-Type", "application/json");
response.getOutputStream().println(toJSON(prettyPrint, re));
}
catch (IOException ignore)
{
// nothing else we can do in this case
if (debugEnabled())
{
TRACER.debugCaught(DebugLogLevel.ERROR, ignore);
}
}
}
/**
* Returns a JSON representation of the {@link ResourceException}.
*
* @param prettyPrint
* whether to format the resulting JSON document
* @param re
* the resource exception to convert to a JSON document
* @return a String containing the JSON representation of the
* {@link ResourceException}.
*/
private String toJSON(boolean prettyPrint, ResourceException re)
{
final String indent = "\n ";
final StringBuilder sb = new StringBuilder();
sb.append("{");
if (prettyPrint) sb.append(indent);
sb.append("\"code\": ").append(re.getCode()).append(",");
if (prettyPrint) sb.append(indent);
sb.append("\"message\": \"").append(re.getMessage()).append("\",");
if (prettyPrint) sb.append(indent);
sb.append("\"reason\": \"").append(re.getReason()).append("\"");
if (prettyPrint) sb.append("\n");
sb.append("}");
return sb.toString();
}
/**
* Parses username and password from the authentication header used in HTTP
* basic authentication.
*
* @param authHeader
* the authentication header obtained from the request
* @return an array containing the username at index 0 and the password at
* index 1, or null if the header cannot be parsed successfully
* @throws ResourceException
* if the base64 password cannot be decoded
*/
String[] parseUsernamePassword(String authHeader) throws ResourceException
{
if (authHeader != null
&& (authHeader.startsWith("Basic") || authHeader.startsWith("basic")))
{
// We received authentication info
// Example received header:
// "Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
String base64UserPassword = authHeader.substring("basic".length() + 1);
try
{
// Example usage of base64:
// Base64("Aladdin:open sesame") = "QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
String userPassword = new String(Base64.decode(base64UserPassword));
String[] split = userPassword.split(":");
if (split.length == 2)
{
return split;
}
}
catch (ParseException e)
{
throw Rest2LDAP.asResourceException(e);
}
}
return null;
}
private AsyncContext getAsyncContext(ServletRequest request)
{
return request.isAsyncStarted() ? request.getAsyncContext() : request
.startAsync();
}
private SearchRequest buildSearchRequest(String userName)
{
// use configured rights to find the user DN
final Filter filter =
Filter.format(authConfig.getSearchFilterTemplate(), userName);
return Requests.newSearchRequest(authConfig.getSearchBaseDN(), authConfig
.getSearchScope(), filter, SchemaConstants.NO_ATTRIBUTES);
}
/** {@inheritDoc} */
@Override
public void destroy()
{
// nothing to do
}
}