/* * 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."); } }