/*
* 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
* docs/licenses/cddl.txt
* or http://www.opensource.org/licenses/cddl1.php.
* 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
* docs/licenses/cddl.txt. 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
*
* Portions Copyright 2021-2024 Ping Identity Corporation
*/
package com.unboundid.directory.sdk.examples;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.unboundid.directory.sdk.common.operation.SimpleBindRequest;
import com.unboundid.directory.sdk.common.types.Entry;
import com.unboundid.directory.sdk.common.types.OperationContext;
import com.unboundid.directory.sdk.ds.api.PassThroughAuthenticationHandler;
import com.unboundid.directory.sdk.ds.config.
PassThroughAuthenticationHandlerConfig;
import com.unboundid.directory.sdk.ds.types.DirectoryServerContext;
import com.unboundid.directory.sdk.ds.types.PassThroughAuthenticationResult;
import com.unboundid.directory.sdk.ds.types.PassThroughAuthenticationResultCode;
import com.unboundid.ldap.sdk.Attribute;
import com.unboundid.ldap.sdk.DN;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.NotNull;
import com.unboundid.util.StaticUtils;
import com.unboundid.util.args.ArgumentParser;
import com.unboundid.util.args.ArgumentException;
import com.unboundid.util.args.FileArgument;
import com.unboundid.util.json.JSONException;
import com.unboundid.util.json.JSONObject;
import com.unboundid.util.json.JSONObjectReader;
/**
* This class provides an example pass-through authentication handler
* implementation that reads the "remote" users from a file with a separate JSON
* object for each of the users. The fields that may appear in each JSON
* object include:
* <BR>
* <UL>
* <LI>dn -- A mandatory string field that holds the DN of the user. This is
* expected to match the DN of the corresponding local user.</li>
* <LI>password -- A mandatory string field that holds the password for the
* user.</LI>
* <LI>result-code -- An optional string field that holds the name of the
* {@code PassThroughAuthenticationResultCode} to return for the user if
* the provided password is correct. If this field is not present for a
* user, then a default result code of "success" will be used.</LI>
* <LI>diagnostic-message -- An optional string field that holds the text of
* a diagnostic message to include in the response to the client.</LI>
* </UL>
* <BR>
* This pass-through authentication handler supports the following configuration
* properties:
* <UL>
* <LI>
* user-file -- The path to the file containing the JSON objects that
* represent the remote users.
* </LI>
* </UL>
*/
public final class ExamplePassThroughAuthenticationHandler
extends PassThroughAuthenticationHandler
{
/**
* The name of the extension argument that specifies the path to the file
* containing the JSON objects that represent the remote users.
*/
@NotNull private static final String ARG_NAME_USER_FILE = "user-file";
/**
* The name of the JSON field that holds the DN for a user.
*/
@NotNull private static final String JSON_FIELD_NAME_USER_DN = "dn";
/**
* The name of the JSON field that holds the password for a user.
*/
@NotNull private static final String JSON_FIELD_NAME_USER_PASSWORD =
"password";
/**
* The name of the JSON field that holds the name of the result code to return
* for a user if the correct password is provided for that user.
*/
@NotNull private static final String JSON_FIELD_NAME_RESULT_CODE =
"result-code";
/**
* The name of the JSON field that holds the name of the result code to return
* for a user if the correct password is provided for that user.
*/
@NotNull private static final String JSON_FIELD_NAME_DIAGNOSTIC_MESSAGE =
"diagnostic-message";
/**
* The name of a custom log field that will be used to hold the reason that
* the pass-through authentication result has the result code it was given.
*/
@NotNull private static final String LOG_FIELD_NAME_RESULT_CODE_REASON =
"passThroughAuthenticationResultCodeReason";
// A map with information about the users read from the user file.
@NotNull private volatile Map<DN,JSONObject> userMap;
/**
* Creates a new instance of this pass-through authentication handler. All
* pass-through authentication handler implementations must include a default
* constructor, but most initialization should be done in the
* {@link #initializePassThroughAuthenticationHandler} method.
*/
public ExamplePassThroughAuthenticationHandler()
{
userMap = Collections.emptyMap();
}
/**
* Retrieves a human-readable name for this extension.
*
* @return A human-readable name for this extension.
*/
@Override()
@NotNull()
public String getExtensionName()
{
return "Example Pass-Through Authentication Handler";
}
/**
* Retrieves a human-readable description for this extension. Each element
* of the array that is returned will be considered a separate paragraph in
* generated documentation.
*
* @return A human-readable description for this extension, or {@code null}
* or an empty array if no description should be available.
*/
@Override()
@NotNull()
public String[] getExtensionDescription()
{
return new String[]
{
"An example pass-through authentication handler that can allow a local " +
"user to authenticate with a password read from a specified file."
};
}
/**
* Updates the provided argument parser to define any configuration arguments
* which may be used by this extension. The argument parser may also be
* updated to define relationships between arguments (e.g., to specify
* required, exclusive, or dependent argument sets).
*
* @param parser The argument parser to be updated with the configuration
* arguments which may be used by this extension.
*
* @throws ArgumentException If a problem is encountered while updating the
* provided argument parser.
*/
@Override()
public void defineConfigArguments(@NotNull final ArgumentParser parser)
throws ArgumentException
{
final Character shortIdentifier = null;
final String longIdentifier = ARG_NAME_USER_FILE;
final boolean required = true;
final int maxOccurrences = 1;
final String placeholder = "{path}";
final String description = "The path to a file containing JSON objects " +
"with information about the 'remote' users that will be " +
"authenticated through this pass-through authentication handler.";
final boolean fileMustExist = true;
final boolean parentMustExist = true;
final boolean mustBeFile = true;
final boolean mustBeDirectory = false;
parser.addArgument(new FileArgument(shortIdentifier, longIdentifier,
required, maxOccurrences, placeholder, description, fileMustExist,
parentMustExist, mustBeFile, mustBeDirectory));
}
/**
* Initializes this pass-through authentication handler.
*
* @param serverContext A handle to the server context for the server in
* which this extension is running. It will not be
* {@code null}.
* @param config The general configuration for this pass-through
* authentication handler. It will not be
* {@code null}.
* @param parser The argument parser which has been initialized from
* the configuration for this pass-through
* authentication handler. It will not be
* {@code null}.
*
* @throws LDAPException If a problem occurs while initializing this
* pass-through authentication handler.
*/
public void initializePassThroughAuthenticationHandler(
@NotNull final DirectoryServerContext serverContext,
@NotNull final PassThroughAuthenticationHandlerConfig config,
@NotNull final ArgumentParser parser)
throws LDAPException
{
userMap = readUserMap(serverContext, parser);
}
/**
* Indicates whether the configuration represented by the provided argument
* parser is acceptable for use by this extension. The parser will have been
* used to parse any configuration available for this extension, and any
* automatic validation will have been performed. This method may be used to
* perform any more complex validation which cannot be performed automatically
* by the argument parser.
*
* @param config The general configuration for this extension.
* @param parser The argument parser that has been used to
* parse the proposed configuration for this
* extension.
* @param unacceptableReasons A list to which messages may be added to
* provide additional information about why the
* provided configuration is not acceptable.
*
* @return {@code true} if the configuration in the provided argument parser
* appears to be acceptable, or {@code false} if not.
*/
@Override()
public boolean isConfigurationAcceptable(
@NotNull final PassThroughAuthenticationHandlerConfig config,
@NotNull final ArgumentParser parser,
@NotNull final List<String> unacceptableReasons)
{
try
{
readUserMap(config.getServerContext(), parser);
return true;
}
catch (final LDAPException e)
{
config.getServerContext().debugCaught(e);
unacceptableReasons.add(e.getMessage());
return false;
}
}
/**
* Attempts to apply the configuration from the provided argument parser to
* this extension.
*
* @param config The general configuration for this extension.
* @param parser The argument parser that has been used to
* parse the new configuration for this
* extension.
* @param adminActionsRequired A list to which messages may be added to
* provide additional information about any
* additional administrative actions that may
* be required to apply some of the
* configuration changes.
* @param messages A list to which messages may be added to
* provide additional information about the
* processing performed by this method.
*
* @return A result code providing information about the result of applying
* the configuration change. A result of {@code SUCCESS} should be
* used to indicate that all processing completed successfully. Any
* other result will indicate that a problem occurred during
* processing.
*/
@Override()
@NotNull()
public ResultCode applyConfiguration(
@NotNull final PassThroughAuthenticationHandlerConfig config,
@NotNull final ArgumentParser parser,
@NotNull final List<String> adminActionsRequired,
@NotNull final List<String> messages)
{
try
{
userMap = readUserMap(config.getServerContext(), parser);
return ResultCode.SUCCESS;
}
catch (final LDAPException e)
{
config.getServerContext().debugCaught(e);
messages.add(e.getMessage());
return e.getResultCode();
}
}
/**
* Reads the user map from the file specified by the user-file argument.
*
* @param serverContext The server context for this pass-through
* authentication handler. It must not be
* {@code null}.
* @param parser The argument parser that has been used to parse the
* configuration for this extension. It must not be
* {@code null}.
*
* @return The map that was read from the target file.
*
* @throws LDAPException If a problem occurs while trying to determine the
* target file or while trying to read and parse its
* contents.
*/
@NotNull()
private Map<DN,JSONObject> readUserMap(
@NotNull final DirectoryServerContext serverContext,
@NotNull final ArgumentParser parser)
throws LDAPException
{
final FileArgument userFileArgument =
parser.getFileArgument(ARG_NAME_USER_FILE);
if ((userFileArgument == null) || (! userFileArgument.isPresent()))
{
// This should not happen, as the argument is declared as required, and
// the server will have already validated the provided set of arguments.
throw new LDAPException(ResultCode.PARAM_ERROR,
"The required '" + ARG_NAME_USER_FILE + "' was not provided.");
}
final File userFile = userFileArgument.getValue();
try (InputStream fileInputStream = userFileArgument.getFileInputStream();
JSONObjectReader jsonReader = new JSONObjectReader(fileInputStream))
{
final Map<DN,JSONObject> m = new HashMap<>();
while (true)
{
final JSONObject jsonObject;
try
{
jsonObject = jsonReader.readObject();
}
catch (final JSONException e)
{
serverContext.debugCaught(e);
throw new LDAPException(ResultCode.DECODING_ERROR,
"An error occurred while attempting to read a JSON object " +
"from file '" + userFile.getAbsolutePath() + ": " +
StaticUtils.getExceptionMessage(e),
e);
}
if (jsonObject == null)
{
return Collections.unmodifiableMap(m);
}
final String dnString =
jsonObject.getFieldAsString(JSON_FIELD_NAME_USER_DN);
if (dnString == null)
{
throw new LDAPException(ResultCode.DECODING_ERROR,
"JSON object " + jsonObject.toSingleLineString() +
" read from file '" + userFile.getAbsolutePath() +
"' is missing the required '" + JSON_FIELD_NAME_USER_DN +
"' field.");
}
final DN dn;
try
{
dn = new DN(dnString);
}
catch (final LDAPException e)
{
serverContext.debugCaught(e);
throw new LDAPException(ResultCode.INVALID_DN_SYNTAX,
"JSON object " + jsonObject.toSingleLineString() +
" read from file '" + userFile.getAbsolutePath() +
"' has a '" + JSON_FIELD_NAME_USER_DN +
"' value that cannot be parsed as a valid DN.",
e);
}
if (dn.isNullDN())
{
throw new LDAPException(ResultCode.INVALID_DN_SYNTAX,
"JSON object " + jsonObject.toSingleLineString() +
" read from file '" + userFile.getAbsolutePath() +
"' has a '" + JSON_FIELD_NAME_USER_DN +
"' value that is an empty DN.");
}
final String password =
jsonObject.getFieldAsString(JSON_FIELD_NAME_USER_PASSWORD);
if (password == null)
{
throw new LDAPException(ResultCode.DECODING_ERROR,
"JSON object " + jsonObject.toSingleLineString() +
" read from file '" + userFile.getAbsolutePath() +
"' is missing the required '" +
JSON_FIELD_NAME_USER_PASSWORD + "' field.");
}
else if (password.isEmpty())
{
throw new LDAPException(ResultCode.DECODING_ERROR,
"JSON object " + jsonObject.toSingleLineString() +
" read from file '" + userFile.getAbsolutePath() +
"' has an empty value for the '" +
JSON_FIELD_NAME_USER_PASSWORD + "' field.");
}
final String resultCodeString =
jsonObject.getFieldAsString(JSON_FIELD_NAME_RESULT_CODE);
if (resultCodeString != null)
{
final PassThroughAuthenticationResultCode resultCode =
PassThroughAuthenticationResultCode.forName(resultCodeString);
if (resultCode == null)
{
throw new LDAPException(ResultCode.DECODING_ERROR,
"JSON object " + jsonObject.toSingleLineString() +
" read from file '" + userFile.getAbsolutePath() +
"' has an unrecognized result code value in the '" +
JSON_FIELD_NAME_RESULT_CODE + "' field.");
}
}
m.put(dn, jsonObject);
}
}
catch (final IOException e)
{
serverContext.debugCaught(e);
throw new LDAPException(ResultCode.LOCAL_ERROR,
"An error occurred while attempting to read from user file '" +
userFileArgument.getValue().getAbsolutePath() + "': " +
StaticUtils.getExceptionMessage(e),
e);
}
}
/**
* Performs any cleanup which may be necessary when this pass-through
* authentication handler is to be taken out of service.
*/
@Override()
public void finalizePassThroughAuthenticationHandler()
{
// No special finalization is required.
}
/**
* Attempts to pass through authentication for the provided bind operation to
* the external service.
*
* @param operationContext The context for the bind operation. It will not
* be {@code null}.
* @param bindRequest The bind request being processed. It will not
* be {@code null}.
* @param localEntry The local entry for the account targeted by the
* bind operation. It will not be {@code null}.
*
* @return The result of the pass-through authentication attempt. It must
* not be {@code null}.
*/
@Override()
@NotNull()
public PassThroughAuthenticationResult attemptPassThroughAuthentication(
@NotNull final OperationContext operationContext,
@NotNull final SimpleBindRequest bindRequest,
@NotNull final Entry localEntry)
{
// Make sure that the bind request has a valid DN. If not, then fail the
// attempt.
final DN bindDN;
final String bindDNString = bindRequest.getDN();
try
{
bindDN = new DN(bindDNString);
}
catch (final LDAPException e)
{
operationContext.getServerContext().debugCaught(e);
final PassThroughAuthenticationResult result =
new PassThroughAuthenticationResult(
PassThroughAuthenticationResultCode.NO_SUCH_USER);
result.setRemoteUserIdentifier(bindDNString);
result.setCustomLogElement(LOG_FIELD_NAME_RESULT_CODE_REASON,
"Unable to parse provided bind DN string '" + bindDNString + "'.");
return result;
}
// Get the JSON object that describes the user with the provided DN. If no
// object exists with that DN, then fail the attempt.
final JSONObject userObject = userMap.get(bindDN);
if (userObject == null)
{
final PassThroughAuthenticationResult result =
new PassThroughAuthenticationResult(
PassThroughAuthenticationResultCode.NO_SUCH_USER);
result.setRemoteUserIdentifier(bindDNString);
result.setCustomLogElement(LOG_FIELD_NAME_RESULT_CODE_REASON,
"User '" + bindDNString + "' does not exist.");
return result;
}
// Make sure that the bind request includes the correct password. if not,
// then fail the attempt.
final String providedPassword = bindRequest.getPassword().stringValue();
final String expectedPassword =
userObject.getFieldAsString(JSON_FIELD_NAME_USER_PASSWORD);
if (! providedPassword.equals(expectedPassword))
{
final PassThroughAuthenticationResult result =
new PassThroughAuthenticationResult(
PassThroughAuthenticationResultCode.WRONG_PASSWORD);
result.setRemoteUserIdentifier(bindDNString);
result.setCustomLogElement(LOG_FIELD_NAME_RESULT_CODE_REASON,
"The wrong password was provided.");
return result;
}
// Create and return an appropriate result. If the user object specifies
// the result code that should be used for this user, then use it.
// Otherwise, go with the default of SUCCESS.
final PassThroughAuthenticationResult result;
final String resultCodeString =
userObject.getFieldAsString(JSON_FIELD_NAME_RESULT_CODE);
if (resultCodeString == null)
{
result = new PassThroughAuthenticationResult(
PassThroughAuthenticationResultCode.SUCCESS);
}
else
{
result = new PassThroughAuthenticationResult(
PassThroughAuthenticationResultCode.forName(resultCodeString));
result.setCustomLogElement(LOG_FIELD_NAME_RESULT_CODE_REASON,
"The result code was specified in the user file.");
}
result.setRemoteUserIdentifier(
userObject.getFieldAsString(JSON_FIELD_NAME_USER_DN));
final String diagnosticMessage =
userObject.getFieldAsString(JSON_FIELD_NAME_DIAGNOSTIC_MESSAGE);
if (diagnosticMessage != null)
{
result.setDiagnosticMessageForClient(diagnosticMessage);
}
return result;
}
/**
* Retrieves a list of any handler-specific attributes that should be included
* in the monitor entry for the associated pluggable pass-through
* authentication plugin.
*
* @return A list of any handler-specific attributes that should be included
* in the monitor entry for the associated plugin. It may be
* {@code null} or empty if no handler-specific monitor attributes
* should be included.
*/
@Override()
@NotNull()
public List<Attribute> getMonitorAttributes()
{
final List<Attribute> monitorAttributes = new ArrayList<>();
monitorAttributes.add(new Attribute("user-count",
String.valueOf(userMap.size())));
return Collections.unmodifiableList(monitorAttributes);
}
/**
* Retrieves a map containing examples of configurations that may be used for
* this extension. The map key should be a list of sample arguments, and the
* corresponding value should be a description of the behavior that will be
* exhibited by the extension when used with that configuration.
*
* @return A map containing examples of configurations that may be used for
* this extension. It may be {@code null} or empty if there should
* not be any example argument sets.
*/
@NotNull()
@Override()
public Map<List<String>,String> getExamplesArgumentSets()
{
return StaticUtils.mapOf(
Collections.singletonList(
ARG_NAME_USER_FILE + "=/path/to/user/file"),
"Defines a pass-through authentication handler that will read user " +
"data from the specified file.");
}
}