001package com.pingidentity.ds.passwordvalidator; 002 003import com.unboundid.directory.sdk.common.types.Entry; 004import com.unboundid.directory.sdk.common.types.OperationContext; 005import com.unboundid.directory.sdk.ds.api.PasswordValidator; 006import com.unboundid.directory.sdk.ds.config.PasswordValidatorConfig; 007import com.unboundid.directory.sdk.ds.types.DirectoryServerContext; 008import com.unboundid.ldap.sdk.LDAPException; 009import com.unboundid.ldap.sdk.ResultCode; 010import com.unboundid.ldap.sdk.unboundidds.extensions.PasswordQualityRequirement; 011import com.unboundid.util.ByteString; 012import com.unboundid.util.StaticUtils; 013import com.unboundid.util.args.*; 014 015import java.util.ArrayList; 016import java.util.List; 017import java.util.Set; 018import java.util.concurrent.atomic.AtomicInteger; 019 020/** 021 * This class provides functionality to enforce requiring N character sets out of M defined 022 */ 023public class NOutOfMCharacterSets extends PasswordValidator 024{ 025 private static final String ARG_CHARACTER_SET = "character-set"; 026 private static final String ARG_NUM_REQUIRED_SETS = "minimum-sets"; 027 private static final String FIELD_SEPARATOR = ":"; 028 private Integer numberRequiredSets; 029 private List<CharacterSetConstraint> characterSets; 030 031 /** 032 * @return a string with the extension name 033 */ 034 @Override 035 public String getExtensionName() 036 { 037 return "ds-password-validator-n-out-of-m-character-set"; 038 } 039 040 /** 041 * @return an array of strings with detailed description of the extension, each element in the array is displayed 042 * as a paragraph in the manage-extension CLI 043 */ 044 @Override 045 public String[] getExtensionDescription() 046 { 047 return new String[]{"Validates password characters with a configurable number of character sets"}; 048 } 049 050 @Override 051 public PasswordQualityRequirement getPasswordQualityRequirement() 052 { 053 return null; 054 } 055 056 /** 057 * This method applies configuration to the extension instance 058 * 059 * @param config the configuration object for this extension 060 * @param parser the argument parser 061 * @param adminActionsRequired a list of messages describing administrative actions that applying configuration 062 * will require 063 * @param messages a list of messages about the applying this configuration 064 * @return ResultCode SUCCESS if applying configuration succeeded 065 */ 066 @Override 067 public ResultCode applyConfiguration(PasswordValidatorConfig config, ArgumentParser parser, List<String> 068 adminActionsRequired, List<String> messages) 069 { 070 characterSets = new ArrayList<>(); 071 for (String s : parser.getStringArgument(ARG_CHARACTER_SET).getValues()) 072 { 073 characterSets.add(new CharacterSetConstraint(s)); 074 } 075 numberRequiredSets = parser.getIntegerArgument(ARG_NUM_REQUIRED_SETS).getValue(); 076 return ResultCode.SUCCESS; 077 } 078 079 /** 080 * This method performs the necessary processing to ensure the configuration is correct 081 * 082 * @param config the extension configuration 083 * @param parser the argument parser 084 * @param unacceptableReasons list of unacceptable reasons 085 * @return true if the configuration is acceptable 086 */ 087 @Override 088 public boolean isConfigurationAcceptable(PasswordValidatorConfig config, ArgumentParser parser, List<String> 089 unacceptableReasons) 090 { 091 return true; 092 } 093 094 /** 095 * Performs all necessary processing to initialize the extension 096 * 097 * @param serverContext the server context 098 * @param config the extension instance configuration object 099 * @param parser the argument parser for this extension instance 100 * @throws LDAPException in case processing encountered an issue 101 */ 102 @Override 103 public void initializePasswordValidator(DirectoryServerContext serverContext, PasswordValidatorConfig config, 104 ArgumentParser parser) throws LDAPException 105 { 106 // Use the applyConfiguration method to set up the plugin. 107 final ArrayList<String> adminActionsRequired = new ArrayList<>(5); 108 final ArrayList<String> messages = new ArrayList<>(5); 109 final ResultCode resultCode = applyConfiguration(config, parser, adminActionsRequired, messages); 110 if (resultCode != ResultCode.SUCCESS) 111 { 112 throw new LDAPException(resultCode, 113 "One or more errors occurred while trying to initialize " + getExtensionName() + " extension in '" 114 + config.getConfigObjectDN() + ": " + 115 StaticUtils.concatenateStrings(messages)); 116 } 117 } 118 119 /** 120 * Performs all the necessary processing to define the configuration arguments this extension needs 121 * 122 * @param parser the argument parser 123 * @throws ArgumentException when the argument declaration fails 124 */ 125 @Override 126 public void defineConfigArguments(ArgumentParser parser) throws ArgumentException 127 { 128 StringArgument characterSetArgument = new StringArgument(null, ARG_CHARACTER_SET, true, 0, "{set}", 129 "Specifies a character set " + 130 "containing characters that a password may contain and a value indicating the minimum number " + 131 "of " + 132 "characters required from that set. Each value must be an integer (indicating the minimum " + 133 "required " + 134 "characters from the set) followed by a colon and the characters to include in that set (for " + 135 "example," + 136 " \"3:abcdefghijklmnopqrstuvwxyz\" indicates that a user password must contain at least three" + 137 " " + 138 "characters from the set of lowercase ASCII letters). Multiple character sets can be defined " + 139 "in " + 140 "separate values, although no character can appear in more than one character set. Syntax: " + 141 "STRING"); 142 characterSetArgument.addValueValidator(new ArgumentValueValidator() 143 { 144 @Override 145 public void validateArgumentValue(Argument argument, String s) throws ArgumentException 146 { 147 String messageAboutFormat = "The value must be an integer (indicating the minimum required characters" + 148 " from the set) followed by a colon and the characters to include in that set"; 149 if (!s.contains(FIELD_SEPARATOR)) 150 { 151 throw new ArgumentException("The value is missing a colon. " + messageAboutFormat); 152 } 153 String[] argumentValues = s.split(FIELD_SEPARATOR); 154 if (argumentValues.length != 2) 155 { 156 throw new ArgumentException("The value must have exactly 2 fields separated by one " + 157 FIELD_SEPARATOR + " character. " + messageAboutFormat); 158 } 159 try 160 { 161 Integer.parseInt(argumentValues[0]); 162 } catch (NumberFormatException nfe) 163 { 164 throw new ArgumentException("The first field is not an integer. " + messageAboutFormat); 165 } 166 if (argumentValues[1] == null || argumentValues[1].isEmpty()) 167 { 168 throw new ArgumentException("The second field must contain at least one character." + 169 messageAboutFormat); 170 } 171 } 172 }); 173 174 parser.addArgument(characterSetArgument); 175 parser.addArgument(new IntegerArgument(null, ARG_NUM_REQUIRED_SETS, false, 1, "{required-sets}", "Minimum " + 176 "number of sets the proposed password must match", 0, 99)); 177 } 178 179 /** 180 * This method performs all the necessary processing to evaluate if a password is acceptable for this extension 181 * instance 182 * 183 * @param operationContext the operation context 184 * @param newPassword the nre password to evaluate 185 * @param currentPasswords the list of current passwords 186 * @param entry the user entry 187 * @param invalidReason a description of why the provided new password is not acceptable 188 * @return true if the password is acceptable 189 */ 190 @Override 191 public boolean isPasswordAcceptable(OperationContext operationContext, ByteString newPassword, Set<ByteString> 192 currentPasswords, Entry entry, StringBuilder invalidReason) 193 { 194 if (invalidReason == null) 195 { 196 invalidReason = new StringBuilder(); 197 } 198 // no point testing this password if the number of characters is less than the number of classes to match 199 if (newPassword.getValue().length < numberRequiredSets) 200 { 201 invalidReason.append("The provided password is too short to possibly match the required " + 202 numberRequiredSets + " character set" + (numberRequiredSets > 1 ? "s" : "") + "."); 203 return false; 204 } 205 206 // initialize the number of required sets 207 //Integer remainingSetsToMatch = numberRequiredSets; 208 209 // initialize the counter for each set 210 List<CharacterSetConstraint> remainingSets = new ArrayList<>(); 211 for (CharacterSetConstraint set : characterSets) 212 { 213 set.reset(); 214 remainingSets.add(set); 215 } 216 217 for (byte b : newPassword.getValue()) 218 { 219 for (CharacterSetConstraint set : remainingSets) 220 { 221 if (set.contains(b)) 222 { 223 if (set.isSatisfied()) 224 { 225 remainingSets.remove(set); 226 } 227 if ((characterSets.size() - remainingSets.size()) >= numberRequiredSets) 228 { 229 return true; 230 } 231 // if found in one set, move on to next byte, no point iterating over other sets 232 break; 233 } 234 } 235 } 236 237 int matchedSets = characterSets.size() - remainingSets.size(); 238 invalidReason.append("The provided password only matched " + matchedSets + " " + 239 "character set"+(matchedSets>1?"s":"")+"."); 240 invalidReason.append("Retry with a password that includes "+ numberRequiredSets+" of the following: \n"); 241 for (CharacterSetConstraint setConstraint : characterSets ) 242 { 243 invalidReason.append(setConstraint.description()+"\n"); 244 } 245 return false; 246 } 247 248 /** 249 * This class represents a character set constraint with the number of required characters for each character set 250 */ 251 class CharacterSetConstraint 252 { 253 private Integer requiredHits; 254 private byte[] characters; 255 private byte min = Byte.MAX_VALUE; 256 private byte max = Byte.MIN_VALUE; 257 private ThreadLocal<AtomicInteger> counter; 258 259 CharacterSetConstraint(String s) 260 { 261 String[] fields = s.split(FIELD_SEPARATOR); 262 requiredHits = Integer.parseInt(fields[0]); 263 characters = fields[1].getBytes(); 264 // TODO: de-depulicate characters (it is supposed to be a set after all) 265 // TODO: order characters to be able to detect if it is a contiguous set 266 for (byte b : characters) 267 { 268 if (b < min) 269 { 270 min = b; 271 } 272 if (b > max) 273 { 274 max = b; 275 } 276 } 277 counter = new ThreadLocal<>(); 278 reset(); 279 } 280 281 /** 282 * set the counter to the required number of requiredHits 283 */ 284 void reset() 285 { 286 counter.set(new AtomicInteger(requiredHits)); 287 } 288 289 /** 290 * This method performs the necessary processing to evaluate if a byte satisfies the character set constraint 291 * 292 * @param b the byte to evaluate 293 * @return true if the class was already satisfied or if the provided byte satisfies the class 294 */ 295 boolean contains(byte b) 296 { 297 // slight optimization to rule out obvious case 298 if (min <= b && b <= max) 299 { 300 for (byte a : characters) 301 { 302 if (a == b) 303 { 304 if (counter.get().get() > 0 && counter.get().decrementAndGet() == 0) 305 { 306 return true; 307 } 308 } 309 } 310 } 311 return false; 312 } 313 314 boolean isSatisfied() 315 { 316 return (counter.get().get() <= 0); 317 } 318 319 String description() 320 { 321 return requiredHits+ " of "+new String(characters); 322 } 323 } 324}