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}