001package com.pingidentity.ds.plugin;
002
003import com.pingidentity.developer.pingid.Application;
004import com.pingidentity.developer.pingid.Operation;
005import com.unboundid.directory.sdk.common.operation.UpdatableBindResult;
006import com.unboundid.directory.sdk.common.operation.UpdatableSimpleBindRequest;
007import com.unboundid.directory.sdk.common.types.ActiveOperationContext;
008import com.unboundid.directory.sdk.ds.api.Plugin;
009import com.unboundid.directory.sdk.ds.config.PluginConfig;
010import com.unboundid.directory.sdk.ds.types.DirectoryServerContext;
011import com.unboundid.directory.sdk.ds.types.PreParsePluginResult;
012import com.unboundid.ldap.sdk.BindResult;
013import com.unboundid.ldap.sdk.LDAPException;
014import com.unboundid.ldap.sdk.ResultCode;
015import com.unboundid.ldap.sdk.SearchResultEntry;
016import com.unboundid.util.ByteString;
017import com.unboundid.util.StaticUtils;
018import com.unboundid.util.args.*;
019
020import java.io.File;
021import java.io.FileInputStream;
022import java.io.IOException;
023import java.net.MalformedURLException;
024import java.net.URL;
025import java.util.*;
026
027public class PingIDOnSimpleBind extends Plugin {
028    private static final String ARG_TOTP_LENGTH = "totp-length";
029    private static final String ARG_TOTP_LAST = "totp-is-last";
030    private static final String ARG_TOTP_ENABLED = "totp-enabled";
031    private static final String ARG_PUSH_ENABLED = "push-enabled";
032    private static final String ARG_SEPARATOR = "separator";
033    public static final String ARG_SEPARATOR_DEFAULT = ",";
034    private static final Integer ARG_RESULT_CODE_DEFAULT = 49;
035    private static final String ARG_RESULT_CODE = "result-code";
036    private static final String ARG_RESULT_MESSAGE_DEFAULT = "Invalid authentication request for PingID MFA";
037    private static final String ARG_RESULT_MESSAGE = "result-message";
038    public static final int ARG_TOTP_LENGTH_DEFAULT = 6;
039    private static final String ARG_PUSH_MARKER_DEFAULT = "push";
040    private static final String ARG_PUSH_MARKER = "push-marker";
041    public static final String ARG_PINGID_PROPERTIES_FILE = "pingid-properties-file";
042    public static final String ARG_USER_ATTRIBUTE_DEFAULT = "mail";
043    private static final String ARG_USERNAME_ATTRIBUTE = "pingid-username-attribute";
044    public static final String PROP_ORG_ALIAS = "org_alias";
045    public static final String PROP_TOKEN = "token";
046    public static final String PROP_BASE64_KEY = "use_base64_key";
047    public static final String PROP_IDP_URL = "idp_url";
048    private static final String ARG_APPLICATION_NAME_DEFAULT = "ldap";
049    private static final String ARG_APPLICATION_NAME = "app-name";
050
051    private static final PreParsePluginResult IMMEDIATE_RESULT = new PreParsePluginResult(false, false, true, true);
052    public static final String ARG_AUTH_TYPE_CONFIRM = "CONFIRM";
053    public static final String ARG_AUTH_TYPE_PERMISSIVE = "FINGERPRINT_PERMISSIVE";
054    public static final String ARG_AUTH_TYPE_RESTRICTIVE = "FINGERPRINT_RESTRICTIVE";
055    public static final String ARG_AUTH_TYPE_HARD = "FINGERPRINT_HARD_RESTRICTIVE";
056    private static final String ARG_AUTH_TYPE = "auth-type";
057
058    private DirectoryServerContext serverContext;
059    private Integer totpLength;
060    private Boolean totpIsSuffix;
061    private Boolean totpEnabled;
062    private String separator;
063    private Boolean pushEnabled;
064    private ResultCode resultCode;
065    private String message;
066    private String pushMarker;
067    private File propertiesFile;
068    private String pingidUsernameAttribute;
069    private Operation pingidOperation;
070    private String authType;
071    private Application application;
072
073    @Override
074    public void defineConfigArguments(ArgumentParser parser)
075            throws ArgumentException {
076        parser.addArgument(new IntegerArgument(null, ARG_TOTP_LENGTH, false, 1, "{length}", "totp code length(Default: " + ARG_TOTP_LENGTH_DEFAULT + ")", ARG_TOTP_LENGTH_DEFAULT));
077        parser.addArgument(new BooleanValueArgument(null, ARG_TOTP_LAST, false, "{boolean}", "Indicates that the TOTP code should be appended after the password", Boolean.TRUE));
078        parser.addArgument(new BooleanValueArgument(null, ARG_TOTP_ENABLED, false, "{boolean}", "Indicates that PingID TOTP code is enabled", Boolean.TRUE));
079        parser.addArgument(new BooleanValueArgument(null, ARG_PUSH_ENABLED, false, "{boolean}", "Indicates that the PingID push is enabled", Boolean.TRUE));
080        parser.addArgument(new StringArgument(null, ARG_SEPARATOR, false, 1, "{separator}", "The separator between the password to use for simple BIND authentication and the PingID marker(Default: " + ARG_SEPARATOR_DEFAULT + ")", ARG_SEPARATOR_DEFAULT));
081        parser.addArgument(new IntegerArgument(null, ARG_RESULT_CODE, false, 1, "{code}", "The result code to return to the client upon failure (Default: " + ARG_RESULT_CODE_DEFAULT + ")", ARG_RESULT_CODE_DEFAULT));
082        parser.addArgument(new StringArgument(null, ARG_RESULT_MESSAGE, false, 1, "{message}", "The result message to return to the client upon failure (Default: " + ARG_RESULT_MESSAGE_DEFAULT + ")", ARG_RESULT_MESSAGE_DEFAULT));
083        parser.addArgument(new StringArgument(null, ARG_PUSH_MARKER, false, 1, "{push-marker}", "The marker to use to indicate to the plugin to initiate a push notification (Default: " + ARG_PUSH_MARKER_DEFAULT + ")", ARG_PUSH_MARKER_DEFAULT));
084        parser.addArgument(new FileArgument(null, ARG_PINGID_PROPERTIES_FILE, true, 1, "{file}", "Path to the properties files containing the PingID connection settings"));
085        parser.addArgument(new StringArgument(null, ARG_APPLICATION_NAME, false, 1, "{app-name}", "The application name (Default: " + ARG_APPLICATION_NAME_DEFAULT + ")", ARG_APPLICATION_NAME_DEFAULT));
086        parser.addArgument(new StringArgument(null, ARG_AUTH_TYPE, false, 1, "{auth-type}", "The type of online authentication (Default:" + ARG_AUTH_TYPE_CONFIRM + ")", new HashSet<>(Arrays.asList(new String[]{ARG_AUTH_TYPE_CONFIRM, ARG_AUTH_TYPE_PERMISSIVE, ARG_AUTH_TYPE_RESTRICTIVE, ARG_AUTH_TYPE_HARD})), ARG_AUTH_TYPE_CONFIRM));
087        StringArgument argUsernameAttribute = new StringArgument(null, ARG_USERNAME_ATTRIBUTE, false, 1, "{attribute}", "The attribute to lookup in the user entry after successful simple BIND to get the PingID user name (Default:" + ARG_USER_ATTRIBUTE_DEFAULT + ")", ARG_USER_ATTRIBUTE_DEFAULT);
088        argUsernameAttribute.addValueValidator(new AttributeNameArgumentValueValidator());
089        parser.addArgument(argUsernameAttribute);
090    }
091
092
093    @Override
094    public String getExtensionName() {
095        return "PingIDOnSimpleBind";
096    }
097
098    @Override
099    public String[] getExtensionDescription() {
100        return new String[]{
101            "Provides a mechanism to intercept a simple BIND and use PingID for MFA.",
102            "To use this plugin, it is strongly advised to always create a request criteria first"
103                    + "to ensure the PingID does not apply blindly to all BIND requests.",
104            "To do so, here a simple example:"
105                    + "<pre>"
106                    + "dsconfig create-request-criteria \n"
107                    + "    --criteria-name simple_PingID_request_criteria \n"
108                    + "    --type simple \n"
109                    + "    --set operation-type:bind \n"
110                    + "    --set operation-origin:external-request \n"
111                    + "    --set included-target-entry-dn:ou=people,dc=example,dc=com\n"
112                    + "</pre>",
113            "Once a criteria has been created, you can create the plugin like so:"
114                    + "<pre>"
115                    + "dsconfig create-plugin \n"
116                    + "    --plugin-name PingID-on-simple-bind \n"
117                    + "    --type third-party \n"
118                    + "    --set enabled:false \n"
119                    + "    --set plugin-type:preparsebind \n"
120                    + "    --set extension-class:com.pingidentity.ds.plugin.PingIDOnSimpleBind \n"
121                    + "    --set extension-argument:pingid-properties-file=/opt/in/pingid.properties \n"
122                    + "    --set request-criteria:simple_PingID_request_criteria"
123                    + "</pre>"
124        };
125    }
126
127    @Override
128    public void initializePlugin(DirectoryServerContext serverContext,
129                                 PluginConfig config,
130                                 com.unboundid.util.args.ArgumentParser parser)
131            throws LDAPException {
132        final ArrayList<String> adminActionsRequired = new ArrayList<>(5);
133        final ArrayList<String> messages = new ArrayList<>(5);
134        final ResultCode resultCode = applyConfiguration(config, parser, adminActionsRequired, messages);
135        if (resultCode != ResultCode.SUCCESS) {
136            throw new LDAPException(resultCode,
137                    "One or more errors occurred while trying to initialize  " + getExtensionName() + " Plugin  in '"
138                            + config.getConfigObjectDN() + ":  " +
139                            StaticUtils.concatenateStrings(messages));
140        }
141    }
142
143    @Override
144    public ResultCode applyConfiguration(PluginConfig config,
145                                         ArgumentParser parser,
146                                         List<String> adminActionsRequired,
147                                         List<String> messages) {
148
149        serverContext = config.getServerContext();
150        totpLength = parser.getIntegerArgument(ARG_TOTP_LENGTH).getValue();
151        totpIsSuffix = parser.getBooleanValueArgument(ARG_TOTP_LAST).getValue();
152        totpEnabled = parser.getBooleanValueArgument(ARG_TOTP_ENABLED).getValue();
153        pushEnabled = parser.getBooleanValueArgument(ARG_PUSH_ENABLED).getValue();
154        separator = parser.getStringArgument(ARG_SEPARATOR).getValue();
155        resultCode = ResultCode.valueOf(parser.getIntegerArgument(ARG_RESULT_CODE).getValue());
156        message = parser.getStringArgument(ARG_RESULT_MESSAGE).getValue();
157        pushMarker = parser.getStringArgument(ARG_PUSH_MARKER).getValue();
158        propertiesFile = parser.getFileArgument(ARG_PINGID_PROPERTIES_FILE).getValue();
159        pingidUsernameAttribute = parser.getStringArgument(ARG_USERNAME_ATTRIBUTE).getValue();
160        application = new Application(parser.getStringArgument(ARG_APPLICATION_NAME).getValue());
161        authType = parser.getStringArgument(ARG_AUTH_TYPE).getValue();
162        Properties properties = new Properties();
163        try {
164            properties.load(new FileInputStream(propertiesFile));
165            String orgAlias = properties.getProperty(PROP_ORG_ALIAS);
166            String token = properties.getProperty(PROP_TOKEN);
167            String key = properties.getProperty(PROP_BASE64_KEY);
168            String url = properties.getProperty(PROP_IDP_URL);
169            pingidOperation = new Operation(orgAlias, token, key, url);
170        } catch (IOException e) {
171            messages.add("Could not load " + propertiesFile.getPath());
172            return ResultCode.OTHER;
173        }
174        return ResultCode.SUCCESS;
175    }
176
177    @Override
178    public boolean isConfigurationAcceptable(PluginConfig config, ArgumentParser parser, List<String> unacceptableReasons) {
179        if (unacceptableReasons == null) {
180            unacceptableReasons = new ArrayList<>(5);
181        }
182        int initialSize = unacceptableReasons.size();
183        propertiesFile = parser.getFileArgument(ARG_PINGID_PROPERTIES_FILE).getValue();
184        Properties properties = new Properties();
185        try {
186            properties.load(new FileInputStream(propertiesFile));
187            for (String propertyName : new String[]{PROP_ORG_ALIAS, PROP_TOKEN, PROP_BASE64_KEY, PROP_IDP_URL}) {
188                String propertyValue = properties.getProperty(propertyName);
189                if (propertyValue == null || propertyValue.isEmpty()) {
190                    unacceptableReasons.add(propertyName + " must be provided and was not empty or absent from the provided properties file");
191                }
192            }
193            String url = properties.getProperty(PROP_IDP_URL);
194            URL pingURL = new URL(url);
195        } catch (MalformedURLException mue) {
196            unacceptableReasons.add(mue.getMessage());
197        } catch (IOException ioe) {
198            unacceptableReasons.add(ioe.getMessage());
199        }
200        if (unacceptableReasons.size() > initialSize) {
201            return false;
202        }
203        return true;
204    }
205    private PreParsePluginResult bail(final UpdatableBindResult result) {
206        result.setResultCode(resultCode);
207        result.setDiagnosticMessage(message);
208        return IMMEDIATE_RESULT;
209    }
210
211    @Override
212    public PreParsePluginResult doPreParse(ActiveOperationContext operationContext, UpdatableSimpleBindRequest request, UpdatableBindResult result) {
213        result.setResultCode(resultCode);
214        if ( request == null) {
215            // this makes no sense but never too cautious
216            serverContext.debugError("No request to process. Request was null.");
217            return bail(result);
218        }
219        String bindDN = request.getDN();
220        if ( bindDN == null || bindDN.isEmpty() ) {
221            result.setDiagnosticMessage("Anonymous authentication not supported");
222            // this makes no sense but never too cautious
223            serverContext.debugError("No request to process. Request was null.");
224            return bail(result);
225        }
226
227        ByteString pwdBS = request.getPassword();
228        if (pwdBS == null) {
229            result.setDiagnosticMessage("password-less authentication not supported");
230            serverContext.debugError("No password with the BIND request.");
231            return bail(result);
232        }
233
234        String providedPassword = pwdBS.stringValue();
235        if ( providedPassword == null || providedPassword.isEmpty()) {
236            result.setDiagnosticMessage("password-less authentication not supported");
237            serverContext.debugError("Empty password with the BIND request.");
238            return bail(result);
239        }
240
241        if (!providedPassword.contains(separator)) {
242            result.setDiagnosticMessage("The credentials must be provided in the format <password>"+separator+"<otp> or <password>"+separator+pushMarker);
243            serverContext.debugError("Password with the BIND request did not contain the configured separator.");
244            return bail(result);
245        }
246
247        serverContext.debugInfo("Starting password processing");
248        String simplePassword = providedPassword.substring(0, providedPassword.lastIndexOf(separator));
249        try {
250            BindResult bind = operationContext.getServerContext().getInternalRootConnection().bind(request.getDN(), simplePassword);
251            if (!ResultCode.SUCCESS.equals(bind.getResultCode())) {
252                serverContext.debugInfo("Bind request did not success, bailing early");
253                result.setResultData(bind);
254                return IMMEDIATE_RESULT;
255            }
256
257            // TODO: this drops inbound controls
258            SearchResultEntry entry = operationContext.getServerContext().getInternalRootConnection().getEntry(request.getDN());
259            // TODO: check if the attribute is multivalued and bail
260            String pingidUsername = entry.getAttributeValue(pingidUsernameAttribute);
261            if (pingidUsername == null || pingidUsername.isEmpty()) {
262                result.setDiagnosticMessage("This user does not have a pingID user name set in its "+pingidUsernameAttribute+" attribute.");
263                return IMMEDIATE_RESULT;
264            }
265
266            Operation operation = new Operation(pingidOperation);
267            operation.setTargetUser(pingidUsername);
268
269            StringBuffer clientData = new StringBuffer();
270            clientData.append("conn= ");
271            clientData.append(operationContext.getClientContext().getConnectionID());
272            clientData.append(" op=");
273            clientData.append(operationContext.getOperationID());
274            operation.setClientData(clientData.toString());
275
276            if (totpEnabled && providedPassword.length() > (totpLength + separator.length()) && providedPassword.matches(".*" + separator + "\\d{" + totpLength + "}$")) {
277                String code = providedPassword.substring(providedPassword.lastIndexOf(separator) + 1);
278                operation.AuthenticateOnline(application, "OTP");
279                if (operation.getWasSuccessful()) {
280                    operation.AuthenticateOffline(code);
281                }
282            } else if (pushEnabled && providedPassword.length() > (pushMarker.length() + separator.length()) && providedPassword.endsWith(separator + pushMarker)) {
283                operation.AuthenticateOnline(application, authType);
284            } else {
285                result.setDiagnosticMessage("Unrecognized keyword provided after password");
286                return IMMEDIATE_RESULT;
287            }
288            if (!operation.getWasSuccessful()) {
289                result.setDiagnosticMessage(operation.getErrorMsg());
290                return IMMEDIATE_RESULT;
291            }
292            result.setResultCode(ResultCode.SUCCESS);
293            result.setDiagnosticMessage("Authentication succeeded with PingID MFA");
294            return IMMEDIATE_RESULT;
295        } catch (LDAPException e) {
296            result.setResultData(e.toLDAPResult());
297            return IMMEDIATE_RESULT;
298        }
299    }
300}