UnboundID Server SDK

Ping Identity
UnboundID Server SDK Documentation

ExamplePassThroughAuthenticationHandler.java

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